Changelog
0.92.0 (2026-10-15)
Nested reads are faster, and four CLI fixes. A findMany with a with
decodes its relation rows in one pass, which takes the 50-users-with-posts
read from 1.86x to 1.33x of a hand-written query on the benchmark machine.
turbine migrate now takes its lock through the adapter in your config,
turbine observe reads --metrics-url, and two help screens state the
right numbers. No query changes its SQL or its result.
Fixed
turbine migrateuses the configuredadapter.migrate up,deployanddownnever passed the config file'sadapterto the migration runner, so a CockroachDB or YugabyteDB project still tookpg_try_advisory_lock(), which on those engines does not keep two concurrent runs apart. The lock now goes through the adapter's lock table.turbine observe --metrics-urlworks. The flag was accepted and ignored, soobserverefused to start unlessTURBINE_OBSERVE_URLwas set. It now reads--metrics-urlfirst, thenTURBINE_OBSERVE_URL.turbine mcp --helpsays eleven tools, the number the server lists. A test now compares the two.turbine doctor --helpnames both pooler ports it refuses, 6543 and 6432. A test now checks every refused port appears.
Changed
- Studio and the observe dashboard use the brand blue,
#6f9bf7, in place of#60a5fa. - Bundle-size limits move by 1 kB: the main entry is held under 149 kB brotli and the edge entry under 117 kB. The growth is the one-pass decoder below.
Performance
- Nested rows decode in one pass. Under the positional encoding (the
PostgreSQL default), each relation row used to be built three times: as a
keyed object, as a copy with its
::textcolumns decoded, and as the parsed row, which also cost anObject.keysper row to find its decode plan. The row parser now compiles each relation shape into a plan once per query and writes every nested row exactly once. The plan reads the same field names, cell kinds, wire rules and cell decodes the general path does, and a query whose shapes it cannot prove identical (two columns mapping to one field, a field named like a relation, an integer-like orObject.prototypekey, a_count__field) takes the general path unchanged. Rows are identical on every strategy and encoding; a differential test holds the plan to the general path over seeded random shapes and payloads, malformed ones included, on three client configurations, and against real rows with BC dates, five-digit years and infinity. timestamptzvalues skip the regex work. A value ending in a±HH:MMoffset, which is how everytimestamptzreaches a nested row, is now handed straight tonew Date. The general path gave the same answer for that shape after one regex test and two replaces that could not match, so the result is unchanged; BC and five-digit years still take the general parser.
Measured
On the dedicated benchmark machine (benchmarks.yml: a 4-vCPU Firecracker VM
on an AMD Ryzen 9 7950X host, PostgreSQL 17 over a Unix socket, three runs),
against the same workflow on 0.91.0:
| Scenario | 0.91.0 | 0.92.0 |
|---|---|---|
findMany, 50 users + posts |
1.470 ms (1.86x hand-written) | 1.072 ms (1.33x) |
findMany, 10 users + posts + comments |
0.795 ms | 0.636 ms |
| to-one, 500 posts + author | 1.774 ms | 1.511 ms |
| many-to-many, 200 posts + tags | 1.642 ms | 1.490 ms |
| overhead over hand-written SQL, geomean of 8 | 1.141x | 1.100x |
Paired and interleaved in one process (this build against 0.91.0, plus a second copy of 0.91.0 as the negative control), 600 rounds, local PostgreSQL 17, medians:
| Scenario | 0.91.0 | This build | Paired change |
|---|---|---|---|
findMany, 50 users + posts |
1.605 ms | 1.376 ms | -0.229 ms (-14%) |
| its row decode alone | 0.441 ms | 0.178 ms | -0.263 ms (-60%) |
findMany, 50 posts + to-one user |
0.188 ms | 0.172 ms | -0.017 ms (-8%) |
findMany, 50 users, no relations |
0.071 ms | 0.070 ms | none |
Against a hand-written query that decodes the same rows to objects and Dates,
the nested read went from 1.39x to 1.19x on this machine. The ::text casts on
int8 / numeric relation columns stay: they are what keeps a value past
2^53 exact.
0.91.0 (2026-10-14)
Two additions and a SQLite speedup. createMany({ returning: false })
resolves to { count } and sends no RETURNING, and _percentile takes an
expression, including the new expr.secondsBetween() for the time between
two timestamp columns. On SQLite a large createMany runs 70 to 140 times
faster. Existing calls behave as in 0.90.2 with three exceptions: a
createMany on SQLite larger than 250 bind parameters now runs as several
statements in one transaction, createMany through
turbine-orm/prisma-compat sends no RETURNING, and its
createMany({ skipDuplicates: true }) on MySQL is E017.
Added
-
createMany({ returning: false })resolves to{ count }. A plain insert sends noRETURNING(noOUTPUTon SQL Server) and takes the engine's row count, so neither the server nor JavaScript handles rows the caller does not read. AnonConflictcall returns only its conflict-key columns, which keeps the check that two rows equal under a case-insensitive key are refused, and counts those rows: an ignored conflict, and a row itswhereexcludes, are not counted.skipDuplicatescounts the rows inserted; on MySQL, which counts a skipped row as written, the two together areUnsupportedFeatureError(E017) for anydata, empty included. The result type follows the flag:{ count: number }forfalse, rows fortrueor no flag.CreateManyArgstakes the flag as a fifth type parameter, defaulting to the rows form, so args typed ahead of the call and generic wrappers still resolve to rows.selectoromitwithreturning: false, or areturningthat is not a boolean, isValidationError(E003). Every engine, andbuildCreateManyfor a pipeline:const [orders, lines] = await db.pipeline( db.orders.buildCreateMany({ data: orderRows, returning: false }), db.orderLines.buildCreateMany({ data: lineRows, returning: false }), ); // [{ count: 3 }, { count: 12 }], in one transaction -
_percentileover an expression. A_percentilekey that names no field takesof, an expression built withexpr, and the result comes back under that key:const s = await db.jobs.aggregate({ _percentile: { wait: { of: expr.secondsBetween(col('requestedAt'), col('readyAt')), p: [0.5, 0.95] }, }, }); s._percentile.wait; // number[] | null, in secondsoftakes arithmetic,coalesce,greatest,leastandcaseover the row's own columns, with numbers or bigints as bound values. The expression is ordered as double precision, so both methods return anumber. The key never reaches the SQL.aggregateandgroupBy, PostgreSQL only, as_percentilealready was. A key that names no field and has noofis a type error, and E003 at run time; a field key withof,col()alone asof, a string,Dateor boolean bound value, a non-numeric result,expr.now()andexpr.excluded()are E003. A PII column the expression reads as a value needsincludePii: UNSAFE, as a column percentile does; a column only acasearm'swhentests does not. -
expr.secondsBetween(from, to): the seconds from one timestamp column to another, fractional,NULLwhen either is. Both columns must betimestamptz, or both zone-lesstimestamp. Accepted in_percentile'sofin this release, and E003 in update data. -
PercentileExprSpec,PercentileArgs,PercentileKeysChecked,FieldPercentiles,CreateManyCountandCreateManyResultare exported types.
Performance
-
A large
createManyon SQLite runs 70 to 140 times faster. SQLite finds each named parameter of a statement by a linear search, when it prepares the statement and when it binds it, so a statement's cost grew with the square of its parameters.createManyfilled each statement to SQLite's limit of 32,766. It now splits at 250 parameters, in one transaction as before. 40,000 rows into an in-memory database, both sizings in one process:Rows of Returns 32,766 parameters 250 parameters 2 columns rows 34.2 s 0.48 s 6 columns rows 141.2 s 0.98 s 2 columns { count }22.6 s 0.27 s 6 columns { count }59.1 s 0.61 s The size is a new
EngineCapabilitieskey,chunkBindParameters; every other engine's equals itsmaxBindParameters, so their statements do not change. Two calls keep one statement as large as the engine allows: anonConflictcall whose written rows cannot be told apart across statements (itsupdateassigns the key, or the only key is a PII column), and a call on a pool with noconnect(), which cannot run several statements in one transaction.
Fixed
createManythroughturbine-orm/prisma-compatreportedcount: 0on MySQL. The adapter counted the rowscreateManyreturned, and a plain MySQL insert returns none. It now asks for the count. WithskipDuplicatesit cannot (see Changed).createMany({ data: [] })threw on SQL Server. The zero-row statement was writtenWHERE false, which T-SQL rejects as a condition. It isWHERE 1 = 0on every engine now.
Changed
EngineCapabilitieshas two new required keys,chunkBindParametersandskipDuplicatesCountsSkipped. A custom dialect copies them only if it spreads a shipped dialect'scapabilitiesobject as well as the dialect; otherwise it fails to compile until it states them. At run time a dialect withoutchunkBindParameterssplits atmaxBindParameters, as before.aggregate,groupByand theirbuild*methods are overloaded: args of a generic type take field percentiles only, so a wrapper generic over its args compiles as it did in 0.90.2.- Through
turbine-orm/prisma-compaton MySQL,createMany({ skipDuplicates: true })is now E017, as it already was on SQL Server and PowDB. MySQL counts a skipped row as written, so the count Prisma returns cannot be read, and 0.90.2 reportedcount: 0.
Docs
- The queries page covers
returning: false, writing to several tables in one transaction with a pipeline ofbuildCreateManycalls, and expression percentiles.
0.90.2 (2026-10-13)
A performance patch. createMany no longer searches the column list for every
field of every row, a $transaction reuses SQL built by earlier transactions,
and findFirst, aggregate and groupBy now use named prepared statements
like the other reads. No query's SQL text changes. The one new refusal is a
createMany row that is not an object.
Performance
-
createManyspends about a tenth of the JavaScript it did. The check that refuses a write to aGENERATED ALWAYS AS ... STOREDcolumn searched the column list for every field of every row, with acamelToSnakeper comparison, and the check that every row names the same fields sorted each row's keys. Both now do their per-call work once. Building a 150-row upsert (onConflictwith fiveexpr.addupdates) went from 533 µs to 52 µs, 3.6 µs to 0.35 µs a row. A test counts reads of the column list and requires the same count for 2 rows and for 500. -
A
$transactionno longer rebuilds the SQL of every statement it sends. A transaction builds its own query interface per table, and each one started with an empty SQL template cache, so every statement in every transaction was built from scratch. All transactions of a client, and the nested writes inside them, now share one cache per table. A cache is shared only between query interfaces built from the same client options and schema, so two configurations are never served each other's SQL.cacheStats().sizenow reports that shared cache;hitsandmissesare still per accessor. -
Together, on a heartbeat-shaped transaction (a locking read, 11 updates with
expr, a 150-row upsert) against local PostgreSQL 16, with 0.90.1, 0.90.2 and the same SQL throughrawalternating in one process:Measure 0.90.1 0.90.2 rawTurbine's JavaScript per transaction, no database 685 µs 217 µs 68 µs Transaction p50, one at a time 2.67 ms 2.15 ms 1.79 ms Transactions per second, 16 concurrent 411 524 613 The machine was shared with other work, so read the last two rows as ratios.
Fixed
findFirst,findFirstOrThrow,findUniqueOrThrow,aggregateandgroupBywere always sent unnamed, even withpreparedStatements: true, so the server parsed them on every call. The three wrappers built their statement fromfindManyorfindUniqueand dropped its name;aggregateandgroupBynever asked for one. They are now named as the other reads are. A caller-writtenAND/ORarray (inwhereorhaving) still sends the statement unnamed, and so does agroupBywith a JSON-path key or a JSON aggregate target, whose aliases are caller text written into the SQL.create,createMany,upsertand raw SQL stay unnamed.preparedStatements: trueon a pool you pass in now warns when the pool points at a pooler. Theprepared-statements-on-poolerwarning read only the settings of a pool Turbine opens. It now also reads apg.Pool'soptions(connection string, else host and port, elsePGHOST/PGPORT), and only when prepared statements are turned on. Measured behind PgBouncer 1.26 in transaction mode withmax_prepared_statements = 0: the defaults completed 200 of 200 concurrent reads, and a supplied pool withpreparedStatements: truecompleted 15, the rest failing withConnectionError(E004). A supplied pool still defaults to unnamed statements, and that does not warn.- A
createManyrow that is not an object is refused.[{}, null]inserted two rows of defaults, and[{}, 5]did too. Each now throwsValidationError(E003) naming the row.
Docs
- The prepared-statements callout on the queries page lists which operations are named and gives the PgBouncer measurements above.
0.90.1 (2026-10-13)
A patch release. It fixes a PowDB filter that could write every row, gives a
custom dialect without capabilities a typed error at every entry point,
takes about 1 kB off every SQL and PowDB entry, and makes the release pipeline
ready for npm trusted publishing. Apart from the PowDB refusals below, a
TurbineClient application sees no change in behavior.
Fixed
- PowDB: a JSON filter that names no comparison could delete or update every
row.
{ meta: { mode: 'insensitive' } }compiled to a condition that matched no row, soNOTaround it matched every row with a value, and adeleteManyorupdateManycarrying it wrote all of them. PowDB now refuses it with theValidationError(E003) the SQL engines give, in the same words. The same check now refuses, on a JSON column,gt,gte,ltorlteagainstnull, an unknownmode, and a non-stringstartsWithorendsWith, as the SQL engines do. A field filter that still compiles to no condition is anInvariantError(E020,invariant: 'powql-filter-empty'), never a constant. - A dialect with no
capabilitiesgets E003 wherever it enters.QueryInterface,schemaToSQL,schemaToSQLStringandintrospectnow refuse it with the messageTurbineClientgives. They used to throw aTypeError("cannot read properties of undefined"), andintrospectopened a connection first.
Changed
-
Every SQL and PowDB entry is about 1 kB smaller.
query/builder.tsand the PowDB generator reachedTransactionClientthrough a dynamic import ofclient.ts, and a bundler that does not split code (esbuild, Wrangler) wrapped the whole client graph in lazy-init closures to keep that import lazy.client.tsnow registers the class when it loads. Brotli,pgexternal, measured bysize-limiton one machine, this release's tree without and with the change:Entry Before After turbine-orm, the whole barrel145.8 kB 144.5 kB import { TurbineClient }, tree-shaken114.1 kB 112.9 kB turbine-orm/serverless114.2 kB 113.0 kB turbine-orm/sqlite121.3 kB 120.3 kB turbine-orm/mysql121.9 kB 120.7 kB turbine-orm/mssql123.2 kB 122.0 kB turbine-orm/powdb143.5 kB 142.2 kB turbine-orm/prisma-compat18.4 kB 18.1 kB The published claims move to 146 kB (barrel) and 114 kB (edge). One consequence: a
QueryInterfacebuilt directly, in a program that never loadsTurbineClientor an engine factory, now gets E003 for a nested write outside a transaction instead of loading the client on demand. Run the write insidedb.$transaction(), or importTurbineClient. -
PowDB is in maintenance mode. It receives bug and data-safety fixes, no new features, and it is verified against the 0.20 PowDB packages. STABILITY.md says how a deprecation would be announced.
Tooling / CI
- A release publish tries npm trusted publishing (OIDC) first and falls back
to the
NPM_TOKENsecret, and each run says which credential it used. It uses npm 11.20.0, which repacks the tested tarball to the same integrity as npm 10. npm allows one trusted publisher per package, so the@nextpublish keeps using the token. Both publish jobs run in a protectedreleaseenvironment that only av*tag ormaincan deploy to. - Dependabot waits 14 days after a release before proposing it, which is this repository's dependency-age rule, and groups the site and Actions updates.
- Development dependencies, the site's dependencies and the pinned Actions are updated to versions at least two weeks old. The site moves to shiki 4, which renders all 505 highlighted code blocks byte-identically.
Tests
- The engine-name guard now catches every way of testing a dialect's name:
.includes,Object.hasOwn,in, a destructured or aliased name, and a dialect held under another name, besides comparisons, switches and keyed lookups. Each exemption is pinned to an exact count of sites, so a new name test in an exempt file fails. A dialect renamed from an engine dialect is tested to run$transaction({ readOnly: true })exactly as that engine does. - The import-cycle check refuses a dynamic import of any of the package's own modules from library code.
- Two prisma-compat tests passed whatever their option did
(
warnOnUnlimited: falseandallowFullTableScan). Each now fails when its option is dropped. - Workflow tests pin the publish job's environment, its npm version and the token fallback.
0.90.0 (2026-10-13)
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. A smaller set changes results with no error; they lead the list below. The upgrade guide at turbineorm.dev/upgrading-to-0-90 has before and after code for most of them.
Breaking
Results that change without an error
- A pathless JSON
equalscompares the whole document.{ meta: { equals: { plan: 'pro' } } }used containment (@>), so it also matched{ plan: 'pro', seats: 5 }, and adeleteManywith it deleted the superset. PostgreSQL now emits"meta"::jsonb = $1::jsonband MySQLmeta = CAST(? AS JSON). SQLite and SQL Server refuse it with E017 rather than approximate it. On a JSON column,not: <document>andnot: { equals: <document> }mean "not this document" on PostgreSQL and MySQL. Upgrade: usecontainswhere containment was meant, andNOT: { meta: { contains: ... } }for "does not contain". globalFilterskeyed by a client accessor apply. A filter keyedblogPosts(the accessor ofblog_posts, the spelling the docs showed) was never applied, because the builder looked filters up by table name. Both spellings now apply. Upgrade: if you relied on such a filter not applying, you were reading rows it was meant to hide; check the affected queries.OR: []matches no row, as in Prisma. It used to add no predicate and return every row.AND: [],NOT: {}andNOT: []still add none. In ahaving,NOT: [a, b]now meansNOT a AND NOT b, as inwhere. Upgrade: leaveORout when a runtime-built list is empty and "no filter" is meant; writeNOT: { AND: [a, b] }for the oldhavingmeaning.- An
ORbranch that applies no condition is true for every row, asOR: [{}]already was. A branch written{}(or{ AND: [] }) was dropped, soOR: [{ id: 1 }, {}]matched row 1 only, itsNOTmatched every other row, and adeleteManyover thatNOTdeleted them. A mutation whosewhereis always true this way is refused. Same rule on PowDB, whereNOT: { OR: [x, {}] }matched every row butx.OR: [{}]now compiles to(1 = 1). A branch emptied byundefinedis still removed first. Upgrade: leave empty branches out of the list. - Security: MySQL compares a number with a text column as text. A number,
bigint or boolean compared with a text column (in
where, inin/notInlists, and inhaving_min/_max) binds as text. MySQL compared it numerically, so{ resetToken: 0 }matched every token that does not start with a digit, and a group whose smallest token was'abc'matched_min: 0. Upgrade: pass values with the column's type. - MySQL
in/notInmatch whatequals/notmatch on every column type. The list was compared as JSON values, so a boolean, date, time, binary value, decimal or bigint written as a string, or case-folded text, never matched, andnotInmatched every row:deleteMany({ where: { published: { notIn: [true] } } })deleted the published rows too. Each element is now its own placeholder. turbine generateapplies the schema file'spiiandupdatedAttags. It only introspected the database, which has no column for them, so adefineSchemathenpushthengenerateproject got a client that returned PII by default and never stampedupdatedAt. Upgrade: regenerate. Installtsxif your schema file is.ts.- JSON path
equalscompares typed JSON values. PostgreSQL emitted#>> = text, soequals: nullmatched only the string"null"andequals: 5matched the string"5". It now compares asjsonb. MySQL comparesnull, arrays and objects as JSON values; SQL Server compares numbers as numbers and strings case-sensitively, and refuses arrays and objects. nottakes an operator object.not: { lt: 4 }bound{"lt":4}as a value and matched nearly every row. It now compiles toNOT (... < $1).- A cursor page is always ordered. A cursor with no
orderByemitted noORDER BY; it now orders by the primary key. A cursor under anorderByon another column seeked on the wrong key; one cursor plan now orders byorderByplus a primary-key tiebreaker and seeks from the row the cursor names, withNULLsort keys handled on every engine.implicitPkOrderingno longer orders a cursor query by the cursor's own field; every cursor page uses this order, flag or not. - Streams and
withhonour every pagination key. A stream's first fetch ignored the caller'slimit.take,skipandoffsetinsidewithwere silently ignored, and onlylimitapplied; they now page each parent's rows on the join plan and the batched loader alike, and are typed on relation options. upsertstampsupdatedAton every path, including the single-statement conflict update.- belongsTo nested writes follow to-one rules. A nested belongsTo
deletewas accepted and did nothing; it now clears a nullable foreign key and deletes the row it pointed at.connectOrCreateinsideupdate()was ignored and now runs.disconnectwith fields must match the connected row. With nothing connected,disconnect: trueis a no-op. - Writes inside
$transactionare all-or-nothing per call. A multi-statement write (a nested create or update, a lookup-first upsert) ontxruns in its own savepoint, so a failure part way through leaves none of it behind. Concurrent nested$transactionblocks under one parent run one at a time instead of sharing a savepoint stack. A writingpipelineholds itsCOMMITuntil every transform accepts, so an*OrThrowmiss or an optimistic-lock conflict rolls the batch back, as$transaction([...])does. - JSON arrays are stored as JSON arrays. A JS array bound to a
json/jsonbcolumn was sent as a Postgres array literal, so[]stored{}andcreateManyturned one array cell into several rows. NewjsonValue()marks any value, a string included, as a JSON document. { increment: 1 }written to a JSON column is stored as that document. On a JSON column onlysetis an operator.- PowDB filters reach every relation. Global filters were skipped on nested
projections, native joins, batched loaders and link hops, so a tenant could
read another tenant's related rows. A global filter that holds a relation
filter (
users: { posts: { some: {...} } }, function filters too) works on every read, write and relation load; every query on the table threw E003.is/isNotrelation filters matched every row, including indeleteMany.none,everyandnotnow follow SQLNULLsemantics, whole-number operands on float columns match,_min/_maxkeep the column's type, and declared defaults apply on create. - SQLite callers wait for an open transaction. Another caller's statement
ran inside the open transaction and was rolled back with it, and could read
its uncommitted rows. It now waits, up to
busyTimeoutMs, then fails with a retryableSerializationFailureError. Embedded PowDB queues the same way. - A paginated
findManygets the cursor tiebreaker. Withlimit/offset(ortake/skip) and anorderBythat can tie, theORDER BYnow ends in the primary key in the last term's direction, so page 1 and the cursor pages share one order and an offset walk visits every row once. An order that already contains a unique NOT NULL key,findFirstwithoutoffset, unpaginated queries,distinctand an order by vector distance are unchanged (an HNSW or IVFFlat index serves onlyORDER BY <distance> LIMIT k). AfindFirstwithoffsetgets the key, so the row it returns no longer depends on the engine's choice among tied rows. Cost: a limited order on a non-unique column gains a key inORDER BY. On a low-cardinality sort column, an index scan that stopped afterlimitrows becomes an incremental sort over the whole first group (measured: 0.04 ms to 20 ms on 200k rows with 3 groups). Checkexplain()on those queries. - Enum-array columns read as arrays. A top-level read returned the raw
array text (
'{draft,live}'); it now returnsstring[], as a nested read did. Nested reads decode BC timestamps, offsets with seconds, years past 9999 and floatNaN/Infinityas top-level reads do. Upgrade: remove any parsing of the{...}text. - A per-query
timeoutcancels the statement on the server. On PostgreSQL through a node-postgres pool, outside a transaction, Turbine sends a cancel request and waits for the statement to stop, so a timed-out write can no longer commit after the error and a retry applies once.TimeoutError.outcomeis'not-executed'when the statement made no change and'unknown'when it may still complete (on other engines, or when the server did not confirm in time). A statement that finishes during the wait returns its result. - A per-query
timeoutinside$transactionrolls the transaction back. On PostgreSQL the statement is cancelled on the transaction's connection and the error isTimeoutErrorwithoutcome: 'not-executed'. The transaction is then rolled back: a callback that catches the error and returns getsTransactionRolledBackError(E019). In a nestedtx.$transaction, only that block rolls back. Upgrade: retry the whole transaction, or put the timed statement in its own nested block. - On PostgreSQL, a transaction
timeoutstops the work on the server. On PostgreSQL through node-postgres, the running statement is cancelled and the transaction rolled back before the call rejects, so its locks are released at that moment. The connection used to be destroyed instead. On other engines and pools a running statement cannot be cancelled, so the connection is still destroyed, as before.timeoutmust be a positive number of milliseconds below 2^31, or 0 for none. - Un-awaited work rolls the transaction back. A
$transactionortransaction()callback that returns while a query it started is still running now rolls back and throws E003. It committed whatever part of the work had finished. Upgrade: await every query (for ... of,await Promise.all(...)). - A pooler connection string sends unnamed statements. A connection string,
hostorportthat names a pooler (apooler/pgbouncerhost, or port 6432 or 6543) now defaultspreparedStatementstofalse. Upgrade: setpreparedStatements: trueif the pooler tracks prepared statements (PgBouncer 1.21+ withmax_prepared_statementsabove 0). - Pools Turbine opens retire connections after 30 minutes
(
maxLifetimeSeconds: 1800). Upgrade:maxLifetimeSeconds: 0keeps the old behaviour. - A pool you supply reads like one Turbine opens. On a PostgreSQL pool
passed as
pool, Turbine's own queries readbigintas a number and zone-lesstimestamp/date(and their arrays) as UTC. The caller's own queries on that pool are unchanged. Upgrade:utcTimestamps: falsekeeps the driver's temporal readings. - A
Datein raw SQL binds as UTC on PostgreSQL. UnderutcTimestamps, aDatepassed toraw,rawUnsafe,rawQueryorsqlbinds as its UTC fields; it bound as local time fortimestampcolumns. An Invalid Date is E003. - Lock timeouts are
LockNotAvailableError. A MySQL lock wait timeout (1205) and a SQL Server lock request timeout (1222) were a plainSerializationFailureError. The code (E013),isRetryableandinstanceof SerializationFailureErrorare unchanged; only a check oferr.nameorerr.constructorsees the difference. - PowDB
NOTfollows SQLNULLsemantics. A top-levelNOT, and a global filter'sNOT, no longer match a row whose compared value isNULL, as on the SQL engines. Upgrade: to include those rows, addOR: [{ col: null }, ...]. - PowDB
_sumover anint8/bigintcolumn returns exact decimal text ('9007199254740996'), as on PostgreSQL. Upgrade: wrap it inNumber()orBigInt()where a number was expected. - MySQL single-row writes run in one transaction.
update,deleteandupsertwrite and re-read the row inside one transaction with the row locked: of concurrent deletes of one row exactly one returns it, an update that changes awherecolumn returns the updated row, and a timed-out write is rolled back. They now need a pool withconnect(). - SQLite reads
booleanandjsoncolumns as PostgreSQL does. A column the schema declaresboolean,jsonorjsonbreads back as a boolean or a parsed value on every read path (top level,withunder every strategy, write returns, groupBy keys, raw queries). Tables created by Turbine's DDL returned1/0and JSON text, and the join strategy disagreed with the others. Needs Node 22.16+ or 23.11+; older builds keep the stored form. Upgrade: drop comparisons to1/0andJSON.parsecalls on these columns. - SQLite binds an integral number 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 sends a
DateasDATETIME2, so milliseconds are stored exactly (23:59:59.999was stored as the next day). This also changeswherecomparisons at millisecond precision. _avgover an integer column keeps its fraction on SQL Server (56.4, was56), in results and inhaving, and full double precision on MySQL (an average of4 / 3read1.3333). Not changed: MySQL's_avgover adecimalorbigintcolumn has PostgreSQL's value with a different number of digits (the input's scale plus 4, against 16 significant digits), and rounds a repeating quotient at a different digit.- MySQL integer
dividetruncates, as on PostgreSQL (75 / 2is37, was38), and so doesexpr.divbetween integers. - SQL Server
jsoncolumns read back parsed. A column the schema declaresjson/jsonb(stored asNVARCHAR(MAX)) reads back as the parsed value, at top level and insidewithrows. It read back as JSON text. Upgrade: dropJSON.parseon these columns. - MySQL compares a bare JSON document as a document.
{ data: { a: 1 } }in awherewas compared as a string, so it matched nothing and itsNOTmatched every row. - A cursor stream idle for five minutes is closed. A
findManyStream/findManyStreamBatchesthat holds a connection between pulls and whose consumer asks for nothing forstreamIdleTimeoutMs(default 300,000 ms) has its cursor closed and its connection released, logsstream-idle-timeout, and its next pull throwsTimeoutError(E002,outcome: 'not-executed'). Time Turbine spends fetching does not count. On PostgreSQL the stream's transaction also carries a transaction-localidle_in_transaction_session_timeoutof the timeout plus 10 s (a stricter existing limit is kept; never a sessionSET), with aSELECT 1keepalive at half the timeout, so a frozen process cannot hold the transaction open. Upgrade: a consumer that pauses longer on purpose passesidleTimeoutMson the call, orstreamIdleTimeoutMs: 0on the client. - MySQL LIKE clauses escape with
!.contains,startsWith,endsWithand the JSON substring filters emitESCAPE '!'instead ofESCAPE '\'. Results are unchanged where the old SQL ran; anything that matches emitted MySQL SQL text sees the new clause (see Fixed). turbineSqlite(path).disconnect()closes the database it opened. The client kept answering queries; a query afterdisconnect()is nowConnectionError(E004). ADatabaseSyncyou pass in stays open.- A
Dateon adatecolumn binds its UTC day on every SQL engine. Written or compared (eachin/notInelement included), it isYYYY-MM-DD, as on PostgreSQL, andexpr.now()into a SQLitedatecolumn stores the UTC day too. MySQL rounded23:59:59.999into the next day; SQLite stored the full ISO text. Upgrade (SQLite): rows written before hold ISO text and stop matching an equality on the column until rewritten:UPDATE t SET col = substr(col, 1, 10) WHERE length(col) > 10;. - MySQL
upsertupdates the rowwherenames. Whencreatealso sets another unique key, the single statement could update the row that collided on that key. It now looks the row up bywherefirst; the batched form of that shape throws E017. - A MySQL
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) returns the written row instead ofNotFoundError(E001). It costs oneSELECT ... FOR UPDATE. NotFoundError.whereis not enumerable undererrorMessages: 'safe', soJSON.stringifyand serializers no longer carry the lookup values. It is still readable aserr.where.- A custom dialect built by spreading an engine dialect under a new
namekeeps that engine's behavior. About sixty decisions compareddialect.name, so a PostgreSQL-compatible dialect under its own name got the non-PostgreSQL answer: noDISTINCT ON, object JSON encoding, no enum casts, noREAD ONLYtransactions, no server-side cancel, no raw-SQL transaction-control screening. Turbine now reads the dialect'scapabilities, which the spread carries over. SQL for the five shipped engines is unchanged. Exception: update expressionsexpr.now(),expr.greatest()andexpr.least()are still spelled per engine name. A dialect under any name other thanpostgresql,mysql,sqliteormssql, even one spread from a shipped engine dialect, gets E017 for those three; every otherexpr.*followscapabilities.schemaToSQLcolumnDEFAULTspellings also still follow the engine name, as before.
Calls that now throw
-
Column references are built with
col(). A plain{ col: 'id' }object from request JSON compared a column with itself. Only the brandedcol()helper, exported from the package root, is a column reference; a plain object throws E003. This is a compile error first. -
whereoperands must fit the column. An object or array at a value position,gt/gte/lt/lte: null, a non-stringcontains, a non-arrayin, an operator object naming no operator ({ mode: 'insensitive' }alone), aMap/Set/Promise, and an unknownmodethrow E003. They were bound as JSON text and matched nothing, or the wrong rows. -
Combinators and filter keys the query cannot read throw E003.
ORgiven an object, any combinator givennullor a scalar, a relation-filter key other thansome/every/none/is/isNot, adistinctelement that is not a field name, and a parsed__proto__key. Each used to compile to no condition, which inside a relation or global filter matched every row. -
A relation filter quantifier must be a where object.
some: null,none: null,every: null, a scalar or an array throws E003 on every engine and at every depth (global filters andwithwheres included). It was dropped, so the query matched every row anddeleteManydeleted every row.is/isNottake a where object ornull; a scalar or an array throws. Upgrade:some: {}for "has a related row",none: {}for "has none". -
More shapes throw E003.
is/isNoton a to-many relation (the message namessome/every/none; PowDB too). An Invalid Date in awhere, on every engine. An unknown key or a non-booleanisEmptyin an array filter.lockin a relation'swithoptions, and a_countrelation entry that is not a boolean, such as{ where }(an unknownwithoption is named once in the advice log). A groupBybythat is missing, a string, an empty array or holdsnull; it was aTypeError, an "Unknown field" per character, or a bareGROUP BYthe server rejected. The empty case points ataggregate(). -
SQLite and SQL Server refuse a whole JSON document as a
wherevalue andnot: <document>with E017, as they refuse a pathlessequals. They compared JSON text, so key order decided the match. -
Mutations refuse a
wherethat is always true.update,updateMany,deleteanddeleteManyrefuseNOT: { OR: [] },{ id: { notIn: [] } }or{ posts: { none: { OR: [] } } }with the empty-where E003. These compiled to SQL matching every row. So do anORwith an empty branch and, on a NOT NULL column,contains: ''(andstartsWith/endsWith),not: null, a column compared with itself andhasEvery: [], and a bare to-one relation filter over a NOT NULL foreign key (not on SQLite, and not when the target table has a global filter). Reads are unchanged. An always-falsewhere(in: [],OR: []) is not refused. Upgrade: guard a list that may be empty, or passallowFullTableScan: UNSAFEif every row is meant. -
A JSON
notwith only operator keys throws E003.not: { gt: 1 }on a JSON column was compared with the literal document{"gt":1}and matched nearly every row (PowDB read it as a negated filter). Upgrade:NOT: { col: { ... } }to negate a filter, ornot: jsonValue({ ... })to compare against such a document. -
A PII column in
selectneedsincludePii: UNSAFE. Naming one inselectreturned it, so a request body spread intofindManycould unlock PII. It now throws E003 at everywithdepth and on PowDB. A write'sselectnever returns one. -
Global filters fail closed. A key naming no table, two keys for one table, a static filter naming no column, and a non-object value throw E003 from the constructor. A function filter returning
undefined, or holdingundefinedat any depth, throws per query instead of being dropped (it used to read and write every tenant). Returnnullor{}for no filter. Static filters are compiled at construction, so a bad operand, a plain{ col }object, an operator object with no operator, a misspelled column inside a relation filter or anundefinedthrows from the constructor, namingglobalFilters["key"](the message no longer repeats the code tag). A static filter that reaches its own table through relation filters, directly or through another table's filter, throws E003 naming the chain; it made every query on the table throwRangeError. Function filters that reach each other apply once per chain. Upgrade: an app whose client holds a broken static filter now fails at startup, even if it never queries that table. -
findFirstrefuses awhereemptied byundefined. It returned an arbitrary row.findManyandcountstill treat it as no filter, and every mutation refuses it through the empty-where guard, including one emptied inside a relation filter. -
One-row lookups take a plain value or
{ equals }.findUnique,update,deleteandupsertaccepted{ equals: { col: 'id' } },mode: 'insensitive'orinas identifying a row; a request body could turndelete({ where: { id: body.id } })intoWHERE "id" = "id". -
A cursor must identify one row. A cursor naming only a non-unique field throws E003 naming the key it lacks. A cursor value must be a plain value or
{ equals }: an operator object, an array or a column reference throws E003, and so does a cursor whose every field isundefined(it would restart at the first row).cursor: null,cursor: {}and a non-object cursor throw E003; they meant no cursor, and that first page was ordered without the tiebreaker later pages used, socursor: last ? { id: last.id } : {}could skip rows. Anullcursor value on a NOT NULL or primary-key column throws E003 (no row holds it);nullon a nullable column is accepted. prisma-compat'scursor: nullreaches the same refusals. Upgrade: leavecursorout, or passundefined, for the first page. -
optimisticLockonupsert,updateMany,deleteanddeleteManythrows E003. It was ignored, so a stale write or delete went through. The message names the equivalent. Anupsertcarrying nested relation data is refused too. Upgrade (deletes): put the version inwhere(where: { id, version }). A stale row then makesdeletethrowNotFoundError, anddeleteMany's count is the rows still at that version. -
An object or array written to a scalar column throws E003. On PostgreSQL: text, numeric, boolean, temporal, uuid and similar columns. On MySQL, SQLite and SQL Server: numeric, boolean, date and time, uuid and binary columns, and MySQL text columns. SQLite and SQL Server text columns still take an object as JSON text. A
MaporSetthrows on every column, json included; it was written as{}. Upgrade:JSON.stringify(value)orjsonValue(value)for JSON text;Object.fromEntries(map)/[...set]for collections. -
buildCreateManypast the engine's bind-parameter limit throws E003 naming the limit, instead of failing in the driver. MySQL and SQLite also name a chunk size that fits. The one-statement build is what apipelineor$transaction([...])runs;createMany()itself chunks (see Added). -
Middleware cannot change the arguments.
params.argsis a plain copy of the caller's arguments, made on first read. A middleware that changes the copy, or passesnext()args that differ from the caller's, makesnext()throw aTypeErrornaming the first differing path (params.args.where.role); assigningparams.argsthrows at once. Such a change never reached the compiled query. Reading, spreading,JSON.stringifyandstructuredCloneofparams.argswork (structuredCloneused to throw), and passing the unchanged copy, or a structurally equal one, works. Upgrade: put the condition in the query, or useglobalFilters. -
updateManyanddeleteManyrefuselimit,take,offsetandskipwith E003 on every engine. They were ignored, so every matching row was written. Upgrade: select the ids withfindMany, then writewhere: { id: { in: ids } }. -
An invalid
timeoutorrelationLoadStrategythrows E003, per query and on the client. A timeout must be a positive number of milliseconds up to 2147483647, or0for no timeout (the pre-0.90 meaning, on queries and on$transactionalike); a negative number,NaNor a string throws. Node used to clamp a bad one to 1 ms or ignore it. -
Error classes. A write in a read-only transaction (PostgreSQL 25006, MySQL 1792) is
ReadOnlyError(E018) with the newreason: 'transaction', so an exhaustive switch overreasonneeds the case. A bind-time protocol error (aBufferbound to a non-byteacolumn) is E003, not E004. pg 42883 is E003 keeping the type names. pg-pool's checkout timeout is E004, on a pool Turbine opened and on one you pass in. pg 42P05 and 26000 (a named prepared statement behind a transaction pooler) are E004 withsqlstateand advice naming the fixes (direct endpoint,preparedStatements: false, PgBouncer 1.21+ withmax_prepared_statementsabove 0), not an untyped driver error. Upgrade: readerr.sqlstateinstead of the driver's.code. A failed SQL-cache self-check is the newInvariantError(E020, see Added), not E003, and so are a relation payload that does not parse as JSON ('relation-payload-json') and an internal where-scope misuse ('where-scope-reference'), which were E003. On PowDB, three PowQL generator checks no argument can reach were E003 with aninternal:prefix; they areInvariantErrortoo ('powql-relation-filter-unresolved','powql-json-operator','powql-json-filter-empty'). SQLSTATE 25P03 (a session the server ended for idling in an open transaction) isConnectionError(E004) with a hint naming the limit; it was an unmapped driver error. -
Transactions. A query on a
txafter its transaction settled throws E003 instead of running on a connection that may belong to another request. A$transactionPostgreSQL rolled back atCOMMIT(the callback caught a failed statement) rejects instead of resolving, with the newTransactionRolledBackError(E019) carrying the first failure ascause. A deadlock, serialization failure or lock-not-available error as the first failure keeps its class (DeadlockError,SerializationFailureError,LockNotAvailableError) so a retry still works. A nested block that rolls back, and a pipeline whose COMMIT is answered with ROLLBACK, are E019 too.db.transaction()checks the commit the same way. A promise in$transaction([...])orpipeline([...])throws E003 before anything runs. AsessionContextvalue that is not a string, number or boolean throws E003 before a connection is taken;undefinedused to reach RLS policies as the text"undefined". Upgrade: matchTransactionRolledBackErrororTURBINE_E019where code matchedValidationError/ E003 on a rolled-back transaction. -
sessionContextnames without a dot throw E003.role,search_path,statement_timeoutand the other built-in settings change who the transaction runs as or which limits it has. Upgrade: namespace custom settings (app.tenant), or list a built-in one your code sets on purpose withallowBuiltinSettings: ['search_path']. A server refusal of a setting is E003 naming the setting and the reason. -
transaction(fn)closes its client. The raw client refuses statements after the transaction settles, and refusesrelease(), with E003. Upgrade: finish the work inside the callback; never release the client. -
The statement after a failure in a PostgreSQL transaction is refused locally. It rejects with the first failure's class when that was a deadlock, serialization conflict or unavailable lock (so
$retryretries), else withTransactionRolledBackError(E019) carrying the failure ascause. It used to reach the server and come back as an untyped25P02, losing the first failure.25P02itself now maps to E019 on any path Turbine wraps. -
A nested
tx.$transaction(fn)callback gets its own handle. A statement through the outertxwhile the block is open throws E003; it ran inside the block's savepoint and was undone with it. Upgrade: use the handle the block's callback receives. -
Transaction-control SQL inside a transaction throws E003.
COMMIT,ROLLBACK,BEGIN,START TRANSACTION,SAVEPOINT,RELEASE,ROLLBACK TO,SET TRANSACTION,END,ABORTandPREPARE TRANSACTIONthroughtx.raw,tx.rawUnsafe,tx.sqlortx.$connection().queryended the transaction behind Turbine's back, so later writes autocommitted. Each engine's comments and literals are respected. The rawtransaction()client still takesSAVEPOINT,RELEASEandROLLBACK TO. Upgrade: return to commit, throw to roll back,tx.$transactionfor a savepoint. -
tx.$connection()refusesend()andconnect()(E003, "$connection(): the $transaction callback must not end its client; ...") and no longer exposes the driver's.connection. -
Raw SQL takes only a tagged template.
db.raw,tx.rawanddb.sqlthrow E003 when passed an array (db.raw([text])), whose text reached the SQL unescaped. Upgrade:db.rawUnsafe(text, ...values)with$1..$nplaceholders. -
rawUnsafeplaceholders are$1..$non every engine. SQLite, MySQL and SQL Server rewrite them to the engine's own form outside literals, quoted identifiers and comments. A?placeholder, or a$npast the values given, throws E003. SQLite read$1as a parameter named$1, MySQL rejected it, and SQL Server read it as a money literal, so a statement could run with a constant where a value was meant. -
$on/$offwith a name other than'query','poolError'or'connectionLost'throw E003. -
Static
globalFilterscombinators are checked at construction.OR: { ... }(an object) or a combinator holding a scalar ornullthrows E003, and so does a static relation filter that is not a where object (author: { is: 5 }), which used to fail on the first query. Upgrade:OR: [{ ... }]. -
A
skipGlobalFiltersentry that names no table throws E003 with a did-you-mean. Entries may be table names (blog_posts) or accessors (blogPosts); an accessor used to skip nothing. -
A pool without
connect()gets E017 naming the alternative for$transaction,transaction(), a pipeline sent as one batch, cursor streams, a chunkedcreateManyand a MySQL write, instead of aTypeError. -
Connections. Pool exhaustion is
ConnectionError(E004) naming the pool size. A client with no database configured fails at its first query with E004 namingDATABASE_URL, instead of a driver error. -
prisma-compat.
take,skiporcursoroncountandaggregatethrow E017; Prisma computes those over one page of rows. Acursorordistinctinside a nestedincludethrows E017 (distinctreturned duplicate rows). A filtered relation count (_count: { select: { posts: { where } } }) throws E017; it returned the unfiltered count. An option the adapter does not translate is E003 instead of ignored: an unknown key on an included relation (Turbine'slimit,offsetandwithincluded), an unknown key or relation under_count, an unknown$transactionoption, an unread key in a nestedupdate,updateMany,connectOrCreateorupsertpayload, and aselect/include/omiton a relation whose model is not in the map.createManywith nodata, ordatathat is neither an object nor an array, throws E003; it inserted nothing and returnedcount: 0. Upgrade: count through the related model (prisma.post.count({ where })); use Prisma's spellings insideinclude. -
SQLite. Calling the client (not
tx) inside its own$transactioncallback, or opening a nesteddb.$transactionthere, throws E017 instead of deadlocking. A string that is not a number, written to a numeric or boolean column, throws 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. -
MySQL.
NaN,Infinityand an Invalid Date as a bound value throw E003; MySQL stored them as0.createon a table whose new key the engine cannot report back (a UUID or other default the server does not number, no primary key) throws E017 before the INSERT; it re-read by a guessed key and could return another row, another tenant's included. A single-rowupdate,deleteorupsertwhose re-read or lock matches more than one row (a number compared with a text key) throws E017 and rolls back. Anin/notInlist that takes a statement past MySQL's 65,535 placeholders throws E003 ("Split the list across several queries"). Upgrade: pass the key indata(crypto.randomUUID()), or make itAUTO_INCREMENT; pass key values with the column's type. -
SQL Server. A binary element in an
in/notInlist throws E017, since no JSON text converts toVARBINARY. Upgrade: write the list asORbranches. -
Every
defineSchemarefusal isValidationError(E003). Nine threw a plainError: an unknown column type, an enum with noenumName, a vector with nodimensions, a non-arrayindexes/manyToMany/checks/primaryKey, a composite primary key naming no column, and an unknown type on the deprecatedcolumnproxy. -
PowDB.
containswith%or_throws E017 (PowQL'slikehas no escape). Comparisons, ordering, aggregates and arithmetic on a numeric text column throw E017. A relation filter matching more than 10,000 keys throws E017. A malformed relation filter throws E003. Embedded: calling the client from inside its own$transactioncallback throws E017 namingtx. A malformedAND/OR/NOT, an operator object naming no operator ({ mode: 'insensitive' }) and a non-stringdistinctelement throw E003. A badtimeoutorrelationLoadStrategythrows E003, middleware cannot rewrite arguments,updateMany/deleteManyrefuse paging options, and a cursor is validated before PowDB's cursor refusal (E017), as on the SQL engines: a cursor that names no row is E003 with their message. An unknown relation inwithis E005RelationError(it was E003), even when the base query matches no row. A number, bigint or boolean compared with or written to aDatecolumn throws E003, as on PostgreSQL; it was bound as raw microseconds, so a filter matched nothing and a write stored the wrong instant. A whole JSON document in awhere({ meta: { a: 1 } },not: doc) is E017 (it was E003). Anupsertonto a key a global filter hides isNotFoundError(E001), as on PostgreSQL, and writes nothing (it was E008). An enum column is astrholding its label (it was declaredjson, so every write of a label failed): a value that is not a label is E003,gt/gte/lt/ltecompare in declaration order,contains/startsWith/endsWith,mode: 'insensitive',_sumand_avgon an enum are E003, and ordering by one (orderBy,_min/_max, groupBy ordering) is E017, since PowDB would sort the label text. A groupBybythat is missing, a string, empty or names something it cannot group by is E003 with the SQL engines' message.updateMany/deleteManywithnot: null(orNOT: { col: null }) on a NOT NULL column is refused withoutallowFullTableScan: UNSAFE; it wrote every row. Upgrade: passnew Date(ms)or ISO text for aDatecolumn; order an enum by its label's position in application code. -
PowDB:
pipelineand the array form of$transactionthrow E017.db.pipeline(...),db.$transaction([...])and prisma-compat's array form threw aTypeError("is not a function") because a PowDB table had nobuild*methods. All fifteen now throwUnsupportedFeatureError(E017) naming the interactive$transaction, before anything runs.cacheStats()on a PowDB table returns zeros; it was not defined there. Upgrade: run the calls insidedb.$transaction(async (tx) => ...). -
defineSchemarefuses a key it does not know. A misspelled column, table, index,references,check,manyToManyor option key was ignored, sodefaultValueemitted a column with no DEFAULT anduniqa non-unique index. It now throws E003 naming the key and the nearest valid one, and in a TypeScript literal it is a compile error. Upgrade: rename the key the error names, then runturbine push --dry-runorturbine migrate createand review the difference, since the DDL those keys were meant to produce was never emitted. -
A dialect must state
capabilities.Dialect.capabilities(EngineCapabilities, exported as a type) is required, andTurbineClientrefuses a dialect without it (E003) at construction. A custom dialect that spreadspostgresDialectfor an engine that is not PostgreSQL inherits PostgreSQL's answers, so it has to state its own. Upgrade:capabilities: { ...postgresDialect.capabilities, distinctOn: false }, overriding every key your engine answers differently, or a full literal. The dialects page lists all 33 keys with PostgreSQL's value. -
postgresDialect.introspectoris removed.introspect()reads the PostgreSQL catalog itself when a dialect has no introspector, so its result for PostgreSQL is unchanged. Code that readpostgresDialect.introspector, or spread it into another dialect, now getsundefined. The SQLite, MySQL and SQL Server dialects keep their own. The field reached the catalog reader through a dynamic import that put it into the serverless entry and every engine entry. Upgrade: callintrospect(options), exported from the package root, instead ofpostgresDialect.introspector.introspect(options), and drop the field from a PostgreSQL-compatible dialect.
Tooling
-
migrate up,migrate deployandmigrate downrefuse a transaction-pooler URL (apoolerorpgbouncerhostname token, or port 6543 or 6432) before connecting: the session-level advisory lock can be taken on one backend and released on another.--dry-run,statusandcreateare not refused. Upgrade: use the direct endpoint, or pass--allow-pooler. -
push --dry-run --exit-codeexits 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). Items the diff discloses as not compared, and a namedCHECKwhose stored text differs only by PostgreSQL's rewrite of the expression, stay at 0. -
migrate status --exit-codeexits 2 on a misnamed.sqlfile in the migrations directory, and names it. -
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 throws E003 naming the text. Upgrade: give the type an alias and writeimport('./x.js').T. -
defineSchemarefuses a__proto__table or column name and an index that declares bothcolumnsanddocField(E003). -
defineSchemarefuses names it cannot resolve (E003): areferencesthat is nottable.column(it emitted no foreign key), or that names a column its in-schema target table lacks; an index naming an unknown column; two fields mapping to one database column (userIdanduser_id), and two tables mapping to one table name. A reference to a table outside the schema is not checked, and a reference by field name (authors.displayName) emits the real column. -
Construction warnings are always on. An unknown client config key,
planCacheModeon an external pool, and prisma-compat's stale-map check used to be silent underNODE_ENV=production; they now print once per process in every mode, as do the new pooler and external-pool option warnings. A$listenreconnect line moved fromconsole.errortoconsole.warn. Upgrade: fix the cause, or pass alogger({}silences Turbine). -
turbine generatefails when a named schema file is missing or cannot be loaded, or when apii/updatedAttag names a table or column the database lacks. A table whose accessor would beconstructoror__proto__is refused.introspect()returns null-prototype records. -
migrate create --autoand--from-diffrefuse while migrations are pending.--allow-pendingoverrides. -
Migration filenames are stamped in UTC. Existing files keep working.
-
migrate createrefuses to replace a file created with the same name in the same second (MigrationError, E006); it overwrote the first one, edits included. Upgrade: wait a second, or pick another name. -
turbine init --urlcreates.env,.env.exampleand.gitignoreexclusively. 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. -
schemaToSQLkeeps each dialect's types and defaults on MySQL, SQLite and SQL Server: auto-increment and identity markers,VARCHARlengths,NVARCHAR(MAX)text on SQL Server, table-level MySQL foreign keys (MySQL 8 ignored the inline form),VARCHAR(255)/NVARCHAR(255)for text in a key position, and per-dialect defaults. MySQL timestamps areDATETIME(3)with aCURRENT_TIMESTAMP(3)default. SQL Servertimestampandtimestamptzcolumns areDATETIMEOFFSET(both wereDATETIME2;defineSchema'stimestampmeanstimestamptz). PostgreSQL output is unchanged. -
schemaToSQLdeclares enum columns off PostgreSQL. It emitted PostgreSQL'sCREATE TYPE ... AS ENUMon every engine. MySQL now getsENUM(...), SQLiteTEXTwith aCHECKover the labels, SQL ServerNVARCHARwith the sameCHECK. There the enum must be declared indefineSchema'senumswith at least one label (E003), an enum array is E017, and a MySQL label holding a backslash is E003. -
A
byteakey, or abyteawithmaxLength, isVARBINARY(n)on MySQL and SQL Server (255 withoutmaxLength);BLOB/VARBINARY(MAX)cannot be a key. -
Column defaults.
'it''s'and a PostgreSQL cast ('x'::text, dropped) are accepted on every dialect. A defaultschemaToSQLcannot write is 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 is E003. -
migrate status --exit-codefails on an upper-case.SQLmigration file, which the runner never runs. -
The destructive-migration guard flags more: an
UPDATE/DELETEwhoseWHEREis constant-true, statements insidePREPARE ... AS,ON CONFLICT DO UPDATEwithout aWHERE,DROP VIEW ... CASCADE,lo_unlink, and quoted unqualified table names. -
Studio and Observe print a one-time ticket, exchanged on first visit for a session cookie; a reused URL gets 403. Both refuse a foreign
Hostwith 403. Studio answers 413 above 1 MiB and 400 to malformed JSON, caps the Query tab at 1,000 rows, and keeps at most 500 saved queries. -
MCP splits camelCase, PascalCase and kebab-case column names when it looks for secret words, so
apiKey,refreshTokenandpasswordHashare hidden fromsample_rowsand refused inexplain_query. -
Generated row types follow the driver:
intervalis the parsed object,pointis{ x, y },circleis{ x, y, radius },bigint[]isstring[], andmacaddr8,bit,varbitand ranges arestring. -
Generated client types are stricter (compile time only; regenerate):
createrequires every required column. A required belongsTo takes its foreign key or the relation; a nested create omits the keys its parent supplies.findUnique,findUniqueOrThrow,upsertand nestedconnecttake a unique key (XWhereUnique); extra predicates beside it are allowed.- A belongsTo relation whose foreign key is NOT NULL is non-null when
loaded, and nullable again under a relation
where. - belongsTo nested writes take the to-one operations: single objects for
connect/create/connectOrCreate,trueor a unique key fordisconnect/delete,{ data }forupdate, and noset. _count: trueis one number per to-many relation, notRecord<string, number>.- Accessors are
QueryInterface<T, TRelations, TInputs>, and the constructor andturbine()takeTypedTurbineConfig. A hand-writtenQueryInterface<T, R>keeps working.
Fixed
-
PGHOSTandPGPORTare read when deciding whether the endpoint is a connection pooler, so named prepared statements default off behind a pooler named only by the environment. -
A
sessionContextvalue PostgreSQL refuses names the role only when the setting isroleorsession_authorization, and$transactionoption messages report the real value (NaN, a bigint) instead ofnull. -
The unknown-option warning no longer calls a key "ignored" when the operation refuses it with its own error (
limitonupdateMany,optimisticLockonupsert,lockon a stream). -
A cursor field written as an operator object, an array or a column reference gets an error that says what a cursor value is.
-
MySQL introspection marks an
AUTO_INCREMENTcolumnisGenerated, so acreatere-reads its row by the insert id even when another column of the table has a default. -
A declared
tsTypeno longer changes runtime metadata.ColumnMetadata.tsTypestays the mapped type and the declared one is kept indeclaredTsType, read only byturbine generate. PowDB creates no longer fail on integer,timestamptzand boolean columns, and a MySQL boolean through a join no longer reads1. -
MySQL DDL defaults.
TEXT,BLOBandJSONdefaults are written as expressions (DEFAULT ('member')),DATEandTIMEclock defaults as(CURRENT_DATE)and(CURRENT_TIME), and aDATETIME(n)default follows the column's precision. MySQL rejected the old forms. -
columnTypesunderinclude. A misspelt table is refused with a suggestion instead of being skipped as ifincludehad left it out. -
Engine values. SQLite REAL and NUMERIC values read through a
withjoin keep full double precision (SQLite 3.47 to 3.51 rounded them to 15 digits). On SQL Server,createManywithbigintvalues past 2^53 mixed with small ones no longer fails with "overflowed an int column", and an array-formorderByinside a relation works on the join strategy. On SQL Server and MySQL, abigintcolumn declared indefineSchemakeeps its exact value insidewithrows. -
turbine observeandturbine studio. On a machine with no browser launcher (noxdg-openon a headless box), both exited with an uncaughtspawn ... ENOENTright after printing their URL; they now keep running. A malformed request target (GET //) gets 400 instead of 500.turbine observecloses its database connection when start-up fails (refused probe, port in use) instead of leaving it idle until the pool timeout. -
Queries. An
undefinedoperand was skipped by the builder but counted by the SQL-cache fingerprint, so one call's cached template could serve another call's parameters. One absent-value rule now drives the fingerprint, the build and the cache-hit collect, and every operation (writes,count, thebuild*twins) prunesundefinedat its seam. An unknown key no longer suggestsidjust because it contains it (tenentIdsuggeststenantId). -
Relations. A chained to-one relation ordering under a global filter bound the wrong parameters. Batched many-to-many with
limit: 0returns no rows, like the join plan. -
Nested writes. An operation holding
undefinedis absent (set: undefinedon a belongsTo was refused). -
SQLite.
SQLITE_BUSYandSQLITE_LOCKEDareSerializationFailureErroron every path,db.pool.queryanddb.transaction()included.isolationLevelis accepted. -
MySQL. Data errors (too long, out of range, bad value, bad date, division by zero, invalid JSON) are
ValidationErrorcarryingcolumnandsqlstate. Pipelined writes run their reselect.createwithid: 0on anAUTO_INCREMENTkey returns the inserted row (MySQL assigns the next id); it threw E001 after inserting.createManyonConflictwith0in anAUTO_INCREMENTtarget throws E017. -
prisma-compat. The array form
$transaction([...], { isolationLevel, timeout })applies its options; they were ignored.distinct: 'field',groupBy({ by: 'field' })andcreateMany({ data: { ... } })(one row) work.$transactionaccepts Prisma'smaxWait, warns once that it has no effect, and does not forward it. -
defineSchema. A number or boolean
default(default: 0,default: true) is written as a literal; it crashed with aTypeError. Any other non-string default throws E003. A table with columns namednameandcolumnsis read as a table of columns. -
SQL Server. A relation's
offset/skipbinds inside theFOR JSONsubquery instead of being refused. A single-rowupsertwith an atomic operator ({ increment: 1 }) on a columncreatealso supplies failed with "Ambiguous column name". The bind limit is 2098 parameters, not 2100: themssqldriver'ssp_executesqlcall takes two. -
PowDB.
OR: []matches no row,AND: { ... }is honored andNOT: [a, b]meansNOT a AND NOT b, as on the SQL engines (they threw a TypeError, matched every row, or were ignored). An object written to ajsoncolumn byupdate,updateManyorupsertis stored as the document (only{ set }is an operator there); it was refused as a misspelled operator.with: { _count: { rel: true } }returns relation counts, honoring the counted table's global filter; it threw "Unknown relation _count". ISO 8601 date text compares and writes against a Date column; zone-less text against atimestamptzcolumn is E003. Pages get the primary-key tiebreaker. Introspection keeps tables and columns named__proto__,constructorortoString, and refuses two columns resolving to one field. -
Where.
AND: { ... }(an object) keeps its prepared-statement name. SQLite's refusal of a pathless JSONequalsnames document equality, not containment. -
Studio and MCP. Studio's Query tab and the MCP
explain_queryandcompile_querytools refuse alockoption by name, with a message pointing atdb.$transaction, instead of failing in PostgreSQL, compiling it, or dropping it.compile_queryfails closed on_percentilewhen the PII tag file cannot be read. -
Introspection. SQLite, MySQL and SQL Server keep tables and columns named
__proto__orconstructor, and refuse two columns that resolve to one field, as PostgreSQL does. -
prisma-compat. A Prisma
cursortranslates to an inclusive core cursor plusoffset: skip, matching Prisma for every order shape (the exclusive translation returned wrong rows under anorderByon another field, and most bare cursors were refused).count({ select })returns the per-field object. A nestedinclude'sskipmaps to the relation offset. Models and fields namedconstructor,toStringorvalueOfwork, and a parsed__proto__key is refused instead of dropping a filter. A cursor always reads the row it names, so a cursor whose row was deleted gives an empty page, as in Prisma. -
migrate-from-prisma survives models, fields and enums named
__proto__orconstructor. -
CLI. An exported but empty
DATABASE_URLno longer hides theurlinturbine.config.ts. A TypeScript seed that does not calldefineSeedexits 0 with a notice instead of failing. -
Migrations. A lossless widening (
inttobigint, a longervarchar) is no longer treated as destructive. "Already in sync" is printed only when the diff has nothing else to report; otherwise it lists what it does not compare._turbine_*tables are not reported as extra. Enum values keep their declared order. A dropped column gets anIRREVERSIBLEnote in itsDOWN,migrate downwarns when aDOWNran no SQL, and adding aNOT NULLcolumn with no default points at--recipe backfill. -
SQLite and SQL Server list operands. SQLite
in/notIntake a bigint past 2^53 (aTypeError) and binary values (they matched nothing). SQL Server binds bigint andDatelist elements asequalsbinds them. -
Batched relations with binary or bigint keys on MySQL and SQLite attach children to the right parent, as the join plan does; two binary keys could share a stitch key.
-
JSON path keys holding a backslash, a quote or a newline are written as JSON string literals on MySQL, SQLite and SQL Server, so they find their value.
-
SQLite: reading a long non-numeric value from a
REALcolumn throughwithno longer takes quadratic time. -
Column type names with a long run of whitespace no longer take quadratic time to classify as a time or timestamp type. Every real spelling classifies as before.
-
PowDB: classifying a very long engine error message no longer takes quadratic time. A type-mismatch message whose column name is over 256 characters gets the generic
PowDB query rejectedtext instead of the comparison-specific one. -
SQL Server binary values. A
Uint8Arraythat is not a NodeBufferbinds asVARBINARY. It bound as text, so equality on a binary column failed with the driver's "Invalid string" error while every other engine answered. -
PowDB. A
batchedorjoinread whoseselect/omitleaves out a foreign key returns the related row (every belongsTo came backnull). Nestedsetworks (it threw E003). A bigint default past 2^53 keeps its digits. -
The cursor message for
countandaggregatenames the operation, notfindMany. -
The CLI names the reason when a connection to
localhostis refused. It printed an empty message. -
MySQL LIKE filters on a pool you pass in.
contains,startsWith,endsWithand the JSON substring filters namedESCAPE '\', 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 was a syntax error. MySQL now escapes with!, and your session's mode is never changed. AJSON_OBJECTkey holding a backslash is written as its UTF-8 bytes, so it reads back the same in either mode. -
Engine factories close what they opened when they fail.
turbineMysql,turbineMssql,turbineSqliteandturbinePowDBclose a pool or handle they opened when a later step throws (a refused server version, an embedded PowDBsyncModethe addon cannot apply, or a client option refused at construction such as aglobalFilterskey naming no table). A failedturbineMysql(url)kept the process from exiting. A pool, handle orPowdbPoolyou pass in is never closed. -
introspect()on SQLite, MySQL and SQL Server sends relation-naming notes to theloggeryou pass instead of the console. -
PowDB logging. The no-limit warning and unknown-option advice go through the client's
loggerand followdiagnostics; they printed to the console whatever the client said. -
The
findManyno-limit warning repeated in every$transaction. It was remembered per table accessor, and each transaction builds its own, so the same query warned again in every transaction. It is now said once per table per process, on every engine;resetUnlimitedWarnings()clears it. -
migrate-from-prismareads string 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. -
Studio and Observe never render a thrown value that is not an
Error(they answerunexpected error), answer an error with an empty message with its aggregated messages, cause or code, and open the browser only for the plainhttpaddress they print.
Added
col()andjsonValue()(with theJsonValuetype), exported from the package root.$transaction([...], options): the array form takesisolationLevel,timeoutandsessionContext.generate --schema-fileand--import-ext ts(importExtension: 'ts'), for running the generated client with Node's type stripping.push --dry-run --exit-codeandmigrate status --exit-code(exit 2 on drift, see the tooling changes above for what counts),migrate deploy --wait[=seconds], and--allow-pooleronmigrate up/deploy/down.DiffResult.widenings,DiffResult.notComparedandColumnMetadata.defaultValueare on the exported types.- int8 writes accept a
stringorbigint; numeric writes accept anumber. On PowDB, integer text is accepted for an integer column in writes and filters, and other text is E003 naming the column. - A column's
tsTypeindefineSchemaandcolumnTypesinturbine.config.tsset the generated TypeScript type of a column, mainly to type ajson/jsonbcolumn that otherwise generates asunknown. The config entry wins on the same column.schemaDefToMetadatarecords each column's default expression, so PowDB applies a declared default. createMany()past the engine's per-statement limit (65,535 bind parameters on PostgreSQL and MySQL, 32,766 on SQLite, 1,000 rows or 2,098 parameters on SQL Server) inserts in chunks inside one transaction instead of throwing. A chunked or multi-statement write (a MySQL write and its re-read) shares onetimeoutacross its statements and rolls back when it passes. Inside a$transactioncallback the chunks run in a savepoint of their own, so they land all or nothing even when the caller catches the error.TimeoutError.outcome('not-executed' | 'unknown'), and server-side cancellation of a timed-out query (see the breaking change above).- Prepared statement names are capped at 200 per process. Later shapes run unnamed, which bounds backend memory per connection (measured: 2,000 distinct shapes on one connection, +29.2 MB before, +2.3 MB after).
ReadOnlyError.reason'transaction'.- Generated types: typed
globalFiltersper table,aggregateresults derived from their arguments, nestedselect/omitnarrowing related rows,{ set }and the atomic operators on update fields, editor completion inside an emptywith/select/omit, and a client that compiles underexactOptionalPropertyTypesand passes@typescript-eslint/no-unsafe-declaration-merging. - Row-locking reads.
findMany,findFirst,findFirstOrThrow,findUniqueandfindUniqueOrThrowtakelock:'update','noKeyUpdate','share','keyShare', or{ mode, noWait: true }/{ mode, skipLocked: true }. Turbine emitsFOR <mode> OF "<table>"after pagination, so only the queried table's rows are locked, never a relation loaded bywith. A locking read must run inside$transaction(E003 otherwise, since autocommit releases the lock before the call returns), and is refused on streams, withdistinct, and in apipelinewithtransactional: false. PostgreSQL supports all four modes; MySQL 8 supports'update'and'share'; SQLite, SQL Server and PowDB throw E017. LockNotAvailableError(E013,isRetryable, a subclass ofSerializationFailureError): anoWaitlock that could not be taken, or a lock wait that hit the server's lock timeout (PostgreSQL 55P03, MySQL 3572 and 1205, SQL Server 1222). Existing E013 retry loops catch it. On PostgreSQL the failure aborts the transaction; take the lock in a nestedtx.$transaction(...)to recover.- Percentiles.
aggregateandgroupBytake_percentile: { field: { p, method? } }.pis a fraction or an array of them (the result mirrors the shape);method: 'cont'(default) interpolates,'disc'returns a stored value. PostgreSQL only (E017 elsewhere). A PII column needsincludePii: UNSAFE.conton a date/time column throws E003 naming'disc'. Ordering or filtering groups by_percentileis not supported yet (E003). - Update expressions.
update,updateMany,upsert'supdate, nestedupdate/upsertdata andcreateMany'sonConflict.updatetakeexpr.add/sub/mul/div,expr.coalesce,expr.greatest/expr.least(NULLs ignored on every engine),expr.case,expr.now(),expr.excluded()andcol(). Example:update({ where: { id }, data: { views: expr.add(col('views'), 1), seenAt: expr.now() } }). Expressions are built only byexprand carry a symbol brand: a plain{ kind, args }object is a value and is refused on a non-JSON column (E003). The same expression stores the same value on every engine: arithmetic between integers is integer arithmetic (expr.divtruncates toward zero, float target included), and a result that can be a non-integer is refused on an integer column (E003). PostgreSQL, MySQL, SQLite and SQL Server; PowDB and prisma-compat throw E017. Lint rules that ban athenproperty (BiomenoThenProperty,unicorn/no-thenable) flagexpr.casearms. - Multi-row upsert.
createMany({ data, onConflict: { target: ['email'], update: ['name'] } })inserts new keys and updates existing rows in one statement.updatealso takes per-column values, operators and expressions, withexpr.excluded('field')for the incoming row;wherelimits which existing rows are updated;{ target, ignore: true }skips them. The result holds the rows inserted or updated, in no guaranteed order. Two rows with the same key in one call throw E003, and so do two rows equal only under the column's comparison rules (a case-insensitive collation,citext, SQLite'sCOLLATE NOCASE), on every engine and across the statements of a chunked call, and the call is rolled back (on SQLite the single statement runs in a transaction for this). Global filters apply to the conflict update, andupdatedAtcolumns are stamped on it. MySQL needs 8.0.19+ and refuseswhere,ignore, a global filter, a NULL target value and a second unique key in the data (E017). PowDB throws E017. createManytakesselect/omit, likecreate, on every engine including PowDB.TransactionRolledBackError(E019, not retryable), exported from the package root: see the transactions breaking change.- Client options
statementTimeoutMs(PostgreSQL; sent as theoptions=-c statement_timeoutconnection parameter, never aSET) andmaxLifetimeSeconds. $transactionoptionsreadOnly(PostgreSQL, MySQL; E017 elsewhere) andallowBuiltinSettings.- Inside a transaction:
tx.sql<T>typed SQL,tx.rawUnsafe(text, ...values), andtx.$connection(), the driver connection the transaction runs on, closed when it settles.db.rawUnsafe(text, ...values)on the client.$1..$nplaceholders work on every engine. db.$ping()resolves the round trip in milliseconds.disconnect({ timeoutMs })andend({ timeoutMs })resolve{ timedOut, forcedConnections }, on the MySQL, SQL Server and PowDB factories too.$on('poolError')and$on('connectionLost')for pools Turbine opens.loggerclient option: every line Turbine writes goes to an object with optionaldebug/info/warn/errormethods, called as(message, entry);{}silences Turbine. Each entry carries a stablecode; the message text is not stable. No line carries a bound parameter value.introspect()andgenerate()take the samelogger.diagnosticsclient option:'development'or'production', or{ mode, advice, cacheCheck }. Resolved once at construction fromNODE_ENVwhen unset (production when there is noprocess). Set it on a worker that does not setNODE_ENV.advicegoverns every per-query advice line andcacheCheckthe SQL-cache self-check; an explicitcacheCheckwins overTURBINE_DISABLE_CACHE_CHECK/TURBINE_CACHE_CHECK_SAMPLE. The logger and diagnostics types are exported from the package root.sqlCommentclient option: appends sqlcommenter key/values to every statement a model call sends, fromstaticpairs,model(the table andaction),tag(the innermost$taglabel) and a per-statementdynamicfunction. Values are URL-encoded, so none can leave the comment. Added after the SQL template cache, so cache keys and hit rates do not change;$on('query')events keepsqlwithout it and carrycomment. Static, model and tag comments keep a prepared statement; anydynamicvalue sends the statement unnamed. A bad option is E003 at construction; a baddynamicvalue is dropped and logged once (sql-comment-dropped). Raw SQL and transaction control carry none. Refused on PowDB, anddynamicis refused on MySQL (E017), where each distinct text holds a server-side prepared statement againstmax_prepared_stmt_count. The OpenTelemetry recipe shows adynamicthat adds the active span's W3Ctraceparent.SqlCommentOptionsis exported from the package root.Dialect.likeEscapeCharacter(optional, for engine authors): the character the emittedESCAPEclause names;escapeLikePatternmust escape for it. Absent means a backslash.streamIdleTimeoutMsclient option and the per-callidleTimeoutMsstream option (see the stream bullet under breaking changes). The client's ownidleTimeoutMsis still the pool's idle-connection timeout.InvariantError(E020), exported from the package root: Turbine caught a bug in itself before sending a statement it knew was wrong.invariantnames the rule ('sql-cache-lockstep','sql-cache-param-count','where-scope-reference','relation-payload-json', and on PowDB'powql-relation-filter-unresolved','powql-json-operator','powql-json-filter-empty'); it is not retryable, and the message names the query shape and no bound value. Two cheap SQL-cache checks now run in every mode, production,cacheCheck: 'off'andsqlCache: falseincluded: on a miss the parameters the cache collects must equal the build's, and on a hit their count must match the cached statement. The sampledcache-mismatchlog line is the error's message.- A method replaced on a table accessor (a spy, a mock, APM instrumentation) runs, even after the accessor was first used.
pipeline([...], options)returns a typed tuple, as the rest-argument form does.- Exported types:
RowLock,RowLockMode,PercentileMethod,PercentileSpec,CaseArm,ConflictTarget,ConflictUpdateData,CreateManyOnConflict,ExcludedRef,ExprOperand,NumericOperand,SqlExpr,SqlExprKind,ConnectionLostEvent,DisconnectOptions,DisconnectResult,PoolErrorEvent,PoolEvent,PoolEventOrigin,ReadOnlyReason,TimeoutOutcome,AggregateResultOf,DefaultInputTypes,GlobalFilter,NestedToOneCreateOp,NestedToOneUpdateOp,OptionalInput,RelationKind,TableInputTypes,SqlCommentOptions.
Tests
- An independent-oracle differential lane compares the library with raw SQL
or an in-test model rather than with itself: warm vs cold SQL cache with
undefinedholes, page walks, streams vsfindMany,createManyvscreate,pipelinevs$transaction, operand shapes, and global filters failing closed. Fast sizes run on every PR;TURBINE_ORACLE_FULL=1widens the search, and a failure prints its seed. - New live suites for transactions (closed handles, rolled-back commits, savepointed writes, batch options), belongsTo nested writes, MySQL pipelines, migrations (DDL on SQLite and MySQL, the CLI flags), and the generated row types on four read paths.
Measured
-
Bundle size grew with this release, and the published figures moved with it. Brotli,
pgexternal, measured bysize-limit(0.81.0 from the published tarball):Entry 0.81.0 0.90.0 turbine-orm, the whole barrel93.1 kB 145.5 kB import { TurbineClient }, tree-shaken74.0 kB 113.9 kB turbine-orm/serverless74.0 kB 114.1 kB turbine-orm/sqlite,/mysql,/mssql77.2 to 79.6 kB 121.4 to 123.1 kB turbine-orm/powdb96.0 kB 143.3 kB On the edge entry about 27 kB of the growth is new code (the checks behind the refusals above, update expressions,
onConflict, row locks, the transaction state machine, protocol-cancel timeouts, the logger) and about 10 kB is the text of the new error messages, net of a 5 kB saving from moving the catalog reader out of that graph. The CI gates and the published claims are now 147 kB (barrel) and 116 kB (edge). The main entry's gate was labelledimport { TurbineClient }while it measured the whole barrel; it is relabelled, and the tree-shaken import has its own gate.
0.81.0 (2026-09-27)
upsert now updates the row its where names. When where and create
disagree it used to run a statement that answered a different question, with
no error.
Fixed
-
upsertupdates the row you name. A singleINSERT ... ON CONFLICTcompares the conflict columns against the values it inserts, which arecreate's, and never readswhere's values. Core emitted that statement whatever the two said, so:where: { id: 5 }with acreatethat leavesidto its sequence inserted a new row on every call and never touched row 5;where: { email: a }withcreate: { email: b }updated or insertedb, and on MySQL returned a row re-read bya, which the statement never wrote.
When every
wherekey carries the same value increate, the upsert is still one atomic statement. Otherwise it looks the row up bywhere, then updates it or insertscreate, in one transaction (the caller's, inside$transaction). That is Prisma's contract, and prisma-compat already emulated it; calling core directly did not get it. One rule decides for every engine (upsertWhereMatchesCreateinquery/compound-unique.ts). -
PowDB's lookup uses
where's values. 0.80 routed filtered and non-key upserts through a lookup, but took the values fromcreate, so the same shapes missed the row there too. -
An empty
updateworks.update: {}reads as "create it if it is missing". It compiled toDO UPDATE SETwith nothing after it, a syntax error on every SQL engine, and E003 on PowDB. It now returns the row as it is, or insertscreate.
Added
- Nested writes inside
upsert. Relation operations increateorupdaterun through the nested-write engine, on every engine. They used to be refused. - MySQL and SQL Server run a globally filtered
upsert(). Their conflict statements cannot carry the filter, so 0.80 refused the call with E017. The lookup carries it instead: another tenant's row is invisible to it and is never updated.
Behaviour changes
- A lookup is two statements, not one. The lookup path emits a
SELECTthen anUPDATEor anINSERT, each with its own$on('query')event. Like Prisma's, it is not atomic against a concurrent insert of the same key: two callers can both miss, and the second insert fails withUniqueConstraintError(E008) whencreatecarries the key. - Both halves are validated first. A typo in
updateis refused whether or not the row exists, before any statement is sent. - A batched
buildUpsertrefuses what it cannot express. A pipeline or an array$transactionhas no round trip for a lookup, sobuildUpsertthrows E003 for awherethat differs fromcreateor an emptyupdate, and E017 for a filtered table on MySQL or SQL Server. Each message names the fix. - prisma-compat hands
upsertto core. Its own lookup emulation is gone. An array$transactionstill batches an upsert when core would run it as one statement, and runs the array inside a transaction otherwise.
Upgrading
- A call whose
whereandcreatecarry the same key values changes nothing. - A call where they differ was writing a different row than it named. Look for
rows it duplicated, typically from
where: { id }with a keylesscreate. - A batched
buildUpsertin that shape now throws. Callupsert()itself, or givecreatethewherevalues. - A workaround for 0.80's E017 on a filtered MySQL or SQL Server upsert can go.
Tests
upsert-where-lookup.integration.test.ts: twelve live PostgreSQL cases, including the statement sequence of each path, caller-transaction rollback, nested writes, and a tenant filter.- The cross-engine battery gained four cases, run on SQLite in every PR and on MySQL and SQL Server in CI. Verified locally against MySQL 9.7.
- Three live PowDB embedded cases for the
where-keyed lookup. - Every mechanism was mutation-tested: routing, the matching rule, up-front
validation, the empty-
updateshortcut, the batched refusal, and filter forwarding, on PostgreSQL, SQLite, MySQL and PowDB. - A byte-pinned parity test was pinning the wrong statement: an
INSERTwithoutidfollowed byON CONFLICT ("id"), which can never conflict. It now inserts the key it conflicts on.
Tooling / CI
- The site's
baseline-browser-mappingmoves from 2.10.17 to 2.11.26, which fixes a denial of service on invalid input. It is a build-time dependency of Next, not part of the library. - c8 moves from 11 to 12, so the coverage gates run on Node 26, where 11 crashed at startup. Measured on the same code under Node 22, all 50 files report identical figures under both versions (97.09% lines).
0.80.0 (2026-09-27)
A database that drops connections no longer takes the application down with
it, and a write can say which columns it wants back. Along the way, fixing a
test that could not fail turned up three upsert bugs, two of them able to
write across tenants.
Security
- A tenant-scoped
upsertcould update another tenant's row on PowDB, MySQL and SQL Server. A global filter guards the conflict update of an upsert, so a key that collides with a row the filter hides updates nothing. PostgreSQL and SQLite emit that guard (ON CONFLICT … DO UPDATE … WHERE). PowDB's nativeupsert … on conflictstatement took no predicate, and MySQL'sON DUPLICATE KEY UPDATEand SQL Server'sMERGEdropped it, silently, so the colliding row was updated. On PowDB the filtered reselect then reported the write it had just made asNotFoundError. PowDB now runs a filtered upsert as a lookup then an update or insert in one transaction, with the filter on both. MySQL and SQL Server refuse it withUnsupportedFeatureError(E017), naming the two ways forward:findUniquethenupdateorcreateinside$transaction, orskipGlobalFilters: UNSAFEwhen the upsert is meant to reach every row.
Fixed
- A dropped connection no longer exits the process. pg-pool listens for a
connection's
'error'only while it is idle, and stops at checkout. So whenever$transaction,transaction(), a nested write, a cursor stream, a pipeline or$listenheld a connection, a restart, failover, compute suspend orpg_terminate_backendemitted an'error'nobody heard, and Node exits on that. Every checkout now carries a listener for exactly as long as it is held, and pools Turbine creates keep one on every connection they open, sodb.pool.connect()in application code is covered too. The pending call rejects withConnectionError(E004) and the pool discards the connection. The CLI's own connections (migrate,push,doctor, Studio,observe, the MCP server) are guarded the same way. - Connection loss is always E004. pg reports a closed socket with messages
that carry no code ("Connection terminated unexpectedly", "Client has
encountered a connection error and is not queryable"), and
wrapPgErrorlooked only at the code, so the commonest symptom of a restart came back untyped. Those messages now map toConnectionError. When a transaction's connection dies between two statements, the error names the original cause (57P01, say) instead of the follow-on "not queryable". - The
pgrange matches what Turbine needs. It allowed^8.13.1, but the pipeline module readspg.utils, which pg first exports in 8.15.0, so importing Turbine threw at load on pg 8.13 and 8.14 ("Cannot destructure property 'prepareValue' of 'pg.utils'"). Measured on 8.13.1: six of the eight subpath entries failed to import. The range is now^8.15.0. - PowDB
upsertconflicts on the columnswherenames. On the SQL engines thewherekeys are the conflict target. PowDB always conflicted on the primary key, soupsert({ where: { email } })never found the row that had that email. A single-column primary key with no global filter still uses the native statement; everything else runs as the transaction above. - PowDB: composite primary keys.
powqlSchemaDDLrefused any table with a composite primary key, because introspected metadata lists the key's own<table>_pkeyindex and PowDB has no composite indexes. The key's columns are already declaredrequired, so that index is now skipped. Every other composite index, a composite unique included, is still refused with E017: PowDB cannot enforce it.
Added
select/omitoncreate,update,deleteandupsert. The same shape and rules as on a read, resolved by the same code. An unknown field, a relation name, an emptyselect, orselectwithomitis refused before anything is written; a PII field comes back only when selected by name; the result type narrows to match. The narrowing happens in the statement (theRETURNINGlist on PostgreSQL and SQLite, the re-select on MySQL,OUTPUTon SQL Server), not by trimming a row that already crossed the wire. Nested creates and updates apply it to the row they return. On PowDB, whosereturningtakes no column list, the row is narrowed after it arrives, with the same result. prisma-compat passes itsselect/omitdown to the write instead of trimming the whole row afterwards.$listenreconnects. A lost LISTEN connection is reopened with exponential backoff (100 ms, doubling to 30 s) and listens again.$listen(channel, handler, { reconnect, onError, onReconnect })configures it:reconnect: falseends the subscription at the first loss,onErrorreceives each loss and failed attempt (by default, one line on stderr), andonReconnectfires when the subscription is live again. NOTIFY is not durable, so anything sent during the gap is lost;onReconnectis the cue to resync.ListenOptionsandListenReconnectOptionsare exported.QueryEvent.retried, set on the event of a read attempt that failed on a dead connection and was sent again (below).
Behaviour changes
- Dead idle connections are not lent out after a freeze. pg-pool drops an idle connection when it reads the server's close, which needs the event loop. A serverless function frozen between invocations reads nothing, so a restart, pooler recycle or compute suspend in that window left every idle connection dead and still in the pool, and each caller after the thaw got one. Measured with five idle connections terminated while the loop was blocked: five concurrent queries, five failures (57P01). Pools Turbine creates now wait one event-loop turn before lending a connection that has been idle for a second or more, which reads those closes first. No round trip, and a pool in steady use never waits. Same measurement after: zero failures, reads and writes alike.
- A read on a dead connection is sent once more.
findMany,findFirst,findUnique, theOrThrowforms,count,aggregate,groupByand a stream's first query, outside a transaction, are retried once on a fresh connection when the one they went out on had already been closed (57P01, ECONNRESET, EPIPE, or pg's "terminated unexpectedly" and "not queryable"). The failed attempt is its own$on('query')event, markedretried: true. A connection that could not be opened is not retried. - So is the
BEGINof a transaction, for$transaction(both forms),transaction(), a nested write, andconnect()'s check: nothing has run yet. - Writes are never resent. A lost connection does not say whether the
statement committed first, so a
create,update,delete,upsertor*Manysent on a dead connection still fails with E004. The checkout wait above prevents that after a freeze of a second or more; inside a shorter one the caller decides. $listensurvives a lost connection by default. It used to exit the process. Passreconnect: falseto end the subscription instead.- PowDB
aggregate()runs several fields in one statement, on an engine probed at 0.20 or later, and validates every field before any statement is sent.
Performance
createwith a narrowselect. 3.1 KBjsonbpayload, concurrency 50, local PostgreSQL 17, two rounds: whole row 46,473 and 54,784 inserts/s;select: { id: true }62,688 and 64,675; a raw INSERT with noRETURNING75,642 and 78,513. What is left to the raw figure is per-call ORM work, not bytes.- PowDB
aggregate(), eight sums, median of 20: 2.5 ms to 2.1 ms at 50k rows embedded (1.2x), 42 to 59 ms to 32 ms at 500k rows embedded (1.3x to 1.8x), 3.5 ms to 2.3 ms networked on loopback (1.5x). Results are identical to the per-field path. Engines 0.13 to 0.19 keep per-field statements, where a per-fieldcountof a nullable column disagrees between the two forms, and an empty filtered set still takes the per-field path so its answer stays exact.
Known limits
- The checkout wait applies to pools Turbine creates. A pool you pass in
(
TurbineConfig.pool,turbineHttp) is not Turbine's to change; the read andBEGINretries still apply to it. - A cursor stream that loses its connection mid-drain, and a pipeline, fail with E004 rather than retry: rows or statements have already gone out.
- PowDB keeps its own opt-in
retryStaleReadsrather than the read retry above.
Notes
- The size claims move: the main entry is held under 93 kB brotli (was 91) and the edge entry under 74 kB (was 72). Against the published 0.79.1 tarball every entry carrying the client graph grew by about 1.7 kB, all of it this release's features; no new module outside that graph is reachable.
Upgrading
- On pg 8.13 or 8.14, upgrade pg; those versions could not import Turbine.
- An
upserton a table with a global filter now throws E017 on MySQL and SQL Server. See Security above for the two replacements. - A retry you wrap around reads for connection loss can go. Keep one around writes only if they are idempotent.
Tests
connection-loss.integration.test.tskills real backends under every holder ($transactionin flight and idle,transaction(), a stream mid-drain,db.pool.connect(),$listen) and asserts the call rejects, the process lives and the pool still serves. Each case exited the process before the fix, which fails the whole file.stale-connection.integration.test.tsfreezes the event loop for real: a worker thread terminates the client's idle backends while the main thread is parked inAtomics.wait. Each recovery mechanism is mutation-tested against it.- A source scan requires every
pool.connect()to be guarded and everynew pg.Client()to callguardConnection. - The PowDB option matrix compared each option's PowQL against a baseline, and
every
createrun bound a fresh random key, so everycreateoption passed whether or not it did anything. Generated keys are now normalized, a determinism check per operation keeps it that way, and options that shape the returned row rather than the statement are compared on the row. Fixing it is what exposed the PowDBupsertbugs above.
CI
npm run check:pg-floorinstalls the packed tarball with the lowestpgthe declared range allows and imports every subpath under ESM and CommonJS. It runs inprepublishOnlyand in the pack-smoke jobs of both workflows, and fails against the old range.
0.79.1 (2026-09-24)
Two fixes for applications that bundle turbine-orm, both invisible to code run
straight from node_modules.
Fixed
- Failed raw and batch statements reported as successes after minification.
SWC's minifier (the one
next buildruns over server bundles) rewrote the statement reporter'scatch (err) { error = wrap(err); throw error; }intothrow wrap(err), dropping the assignment itsfinallyblock then read. In any minified bundle, the$on('query')event of a failingdb.raw,db.sql,tx.raw/tx.rawQuery, prisma-compat$queryRawfamily call, or$transaction([...])slot arrived with noerror, so telemetry built on it counted the failure as a success. The reporter now emits the failure from inside thecatch. Model methods and pipelines were not affected. The workaround for 0.79.0 isserverExternalPackages: ['turbine-orm']innext.config. turbine-orm/serverlessbuilds for edge runtimes. The modules it shares with the main entry importedpgstatically, so a Vercel Edge or Next.js edge route failed to build (webpack:Can't resolve 'fs'; Turbopack:node:util/types) unlesspgwas aliased to a stub. They now import the driver as#pg, a packageimportsalias that ispgeverywhere except under theedge-lightandbrowserconditions, where it is a pg-free shim.workerdkeeps the real driver, and the CommonJS build, which edge bundlers never resolve, namespgdirectly. On the shim, constructing a client that would open its own connection throwsConnectionError(E004) namingturbineHttp(pool, schema), and a relation's JSON column is left as the wire text rather than run through a driver type parser.
Tests
minified-events.test.tscompiles every module with SWC (compress + mangle) and asserts each failure path's event still carries the caller's error, with a control proving the pinned SWC still performs the rewrite.
CI
npm run check:edgebundles the serverless entry under the edge conditions and fails on any pg import, any Node builtin other thannode:async_hooks, or any other package. A control bundle under Node conditions must reachpg, or the check refuses to pass. It runs inprepublishOnly, the CI build job and the release test job.- The
@types/pgdeclaration tripwire also refuses the#pgalias.
0.79.0 (2026-09-24)
$on('query') now sees every statement the client sends, and a query can say
which feature sent it. Until this release only model methods emitted events:
raw SQL, pipelines and the array form of $transaction ran unobserved, so the
observe engine and any telemetry listener built on $on reported a partial
picture for any application that leans on raw SQL.
Added
- Query events for raw SQL.
db.raw,db.sql(every execution:await,.one(),.scalar()), the transaction-scopedtx.rawandtx.rawQuery, and prisma-compat's$queryRaw,$executeRaw,$queryRawUnsafeand$executeRawUnsafeeach emit one event.modelis'$raw'(exported asRAW_QUERY_MODEL) andactionnames the entry point. A failing statement emits an event whoseerroris the error the caller receives. - Query events for batches. Each statement of
db.pipeline(...)and ofdb.$transaction([...])emits an event attributed to its own table and method, with the newbatchfield set to'pipeline'or'transaction'. A pipeline is one round trip, so its statements share the batch's wall time evenly (the sum is exact, the split is not); transaction-batch statements are timed individually. The pipeline path only does this work when a listener is registered. db.$tag(label, fn). Every event caused insidefncarriestag: label, including queries in awaited helpers, transactions, pipelines and raw SQL, and concurrent scopes stay separate. An inner$tagwins for its own duration. Labels are 1 to 128 characters (MAX_QUERY_TAG_LENGTH), E003 otherwise, checked beforefnruns. The tag is event metadata and never reaches SQL.QueryEventgains two optional fields,tagandbatch.
Behaviour changes
$observemetrics (and so_turbine_metricsandturbine observe) now include raw SQL under model'$raw', plus pipeline and batch-transaction statements under their tables.turbine doctortable heat reads table names only, so'$raw'rows do not affect it; pipeline and batch statements now count toward the heat of the tables they touch, which they always should have. A listener that assumedmodelis always a table name should skipRAW_QUERY_MODEL.
Fixed
- PowDB: a relation
limitis a per-parent bound on every loader path.with: { posts: { limit: 5 } }means at most five posts on each parent. The PowDB keyed loader, the native-join path and the manyToMany loader applied the limit to the whole flat child fetch instead, so five posts were shared across every parent in the chunk and most parents came back with none, with no error. Reachable withrelationLoadStrategy: 'batched'on any addon, and by default on addons below 0.18; nested projections (the default on 0.18+) were already correct. Children are now fetched withwhereandorderByonly and sliced per parent at stitch time, the rule the SQL batched loader has always used.limit: 0returns[]without a fetch, and a manyToMany parent keeps its top N by the targetorderBy.
0.78.0 (2026-09-06)
A full product review, a gold-standard audit and a three-database end-to-end QA run found five defects that all share one shape: the query compiled, the statement ran, the database returned rows, and the rows were wrong. No error, no warning, nothing in a log. The suite was 6,907 tests and green.
It missed them because it is exhaustively self-consistent. Every differential
fuzz compares the compiler against itself, so a rule the compiler applies
uniformly and wrongly agrees with itself perfectly. The write path was tested
one statement at a time rather than against the rule the documentation states,
so update() compiling an operator object correctly and upsert() compiling
the same object as a literal 200 lines away is two passing tests. The fixes are
below; the tests added with them assert against the stated rule, not against the
neighbouring implementation.
Breaking
-
update(),delete()andupsert()require awherethat identifies one row. All three accepted any filter.updateanddeletemutated every matching row and returned one of them, arbitrarily;upsert'swherebecomes itsON CONFLICTtarget, so a non-unique predicate emitted a conflict clause no constraint backs and PostgreSQL answered with a bareDatabase error 42P10naming nothing the caller wrote.findUniquehas refused that exact shape since 0.73 through a shared helper none of the three called; they call it now, and the refusal is E003 naming the unique keys the table actually has, with its own sentence for a table that declares none. UseupdateMany/deleteManyfor "every row matching a filter", which is what they are for.Two consequences worth naming.
update({ where: {}, optimisticLock })is refused: a version column is not unique, so that shape silently updated every row at that version and reported one, which is precisely the hazard. And the refusal message states the cost of its own escape hatch, becauseallowFullTableScan: UNSAFEgives up the empty-whereguard as well, so an all-undefinedwherethen matches every row instead of being refused. -
_sumand_avgoverbigintandnumericcolumns return exact strings. They were passed throughNumber(), which silently rounds past 2^53 and turns a money column into a float. The type is nownumber | string | nullonAggregateResultand on the groupBy result.int4and float columns still return JS numbers, and JSON-path aggregates are unchanged. The::floatcast is gone from the generated SQL for those columns too, which had also collapsed distinct averages into ties when ordering by_avg. -
A non-generated primary key is required in the generated
*Createtype. The generator marked every PK optional, so a plaintextPK or a composite PK of two ints typechecked when omitted and failed at the database. Optionality now follows the column: server-generated, defaulted or nullable. -
mode: 'insensitive'applies toequals,not,inandnotIn. It was documented as case-insensitive matching and silently ignored on every operator except the LIKE family, so{ email: { equals: 'A@B.com', mode: 'insensitive' } }ran a case-sensitive comparison. A non-string operand beside the mode now throws E003 rather than being ignored. -
Introspection refuses to generate when two columns of one table resolve to the same field. A table with both
"createdAt"andcreated_atemitted a duplicate interface member and mapped writes to whichever came last. It now throws E003 naming both columns, with--keep-column-namesas the escape hatch. -
turbine migrateandturbine seedpinsearch_pathfor every configured schema,publicincluded, and the pin EXTENDS the connection's own path. Two halves of one change, and neither is safe alone.publicused to be exempt, so a role whose ownsearch_pathled with another schema hadpushcreating tables inpublicwhilemigratecreated them, and_turbine_migrationswith them, wherever the role pointed. But the pin also REPLACED the path, and pinningpublicunder that rule turns a working migration into a broken one on the ordinary managed layout: measured, an unqualifiedCREATE TABLE t (email citext)succeeds unpinned and fails under a"public"-only pin, because the extension lives in anextensionsschema the pin discarded. So the pin extends. The target goes first, the connection's own inherited names follow, read fromSHOW search_pathon an unpinned probe because a startup parameter cannot read the path it overrides. Every inherited name passes the same validator the target does and is dropped otherwise, never escaped.What to expect on upgrade: with the default
schema: 'public',migratenow emits a connection parameter where it emitted none. On a project whose role path led elsewhere this MOVES where migrations and the tracking table land, intopublic, matching whatpushhas always done. That is the fix, and it is silent, so such a project may see its migrations look unapplied on the first run after upgrading. Not configuring a schema at all is still unpinned and unchanged.
Security
-
A destructive statement inside a single-quoted routine body passed the confirmation gate.
DO 'BEGIN DROP TABLE users; END'ran with a clean inventory, no prompt and no--allow-destructive, verified live on PostgreSQL 17. The tokenizer filled a statement's block list from the dollar-quote andBEGIN ATOMICbranches only, so a single-quoted body came back with no blocks and the scanner iterated nothing: every pass was skipped, the fail-closed "cannot classify" backstop included. Six shapes went through it,DOin three quoting forms plusCREATE FUNCTIONandCREATE PROCEDURE. The existing suites are entirely dollar-quoted, which is why a backstop that had never been exercised against this form was green. -
Two further shapes reached the same gate. Two adjacent string literals are ONE string in PostgreSQL, so a
DROP TABLEsplit across two quoted fragments read as destructive to the opener test while every rule that could have named it declined, and the skip for an already-handled verb was unconditional, so the statement was passed over on the strength of a pass that never spoke. AndCOPY (DELETE FROM t RETURNING *) TO STDOUTruns the DELETE through a wrapper the data-modifying-CTE rule never sees, because that rule fires only on a leadingWITH. -
And the opposite error, which failed ordinary migrations rather than passing destructive ones. The unclassifiable-
EXECUTEbackstop matched the word anywhere in a statement's code, literals included, soGRANT EXECUTE ON FUNCTION f() TO appandRAISE NOTICE 'EXECUTE the plan'both armed the gate, and undermigrate deploythere is no terminal to confirm at. Presence is decided against the literal-emptied view now, and the keyword must sit where plpgsql can begin a statement. A rewrite rule whose action is anUPDATEis judged the way a top-levelUPDATEis, destructive only without aWHERE, so the standard updatable-view idiom is silent again. Each sub-statement of a body is judged on its own and reported once.
Fixed
-
A multi-field
cursorcompiled to a conjunction, not a keyset.cursor: { score, id }emittedscore > $1 AND id > $2, which drops every row whose score ties the cursor's and whose id sorts below it. Paging a 600-row table with ties on the leading key visited 250 rows and skipped 350, silently, with every page individually well formed.It emits the expanded OR-of-ANDs seek now, ordered by
orderByprecedence and honouring each field's own direction. Deliberately not a row-value comparison ((a, b) > ($1, $2)): that form cannot express a mixed direction and does not exist on every engine we target. One authority builds the seek entries for the fingerprint, the build and the cache-hit collect, so they cannot drift, and the cache key carries the direction of each field. -
A relation filter on a self-relation correlated a row with itself. The
EXISTSsubquery did not alias its target, so when the target's rendered name equalled the parent's,where: { manager: { some: {} } }resolved both sides of the correlation to the same row and returned nothing. The same file aliases correctly inwithand inorderBy: { _count }, which is why it survived review. The alias now flows through the correlation, the scoped sub-where, the global-filter fragment and nested filters, and the junction gets its own. Non-self relations emit the same SQL as before, byte for byte. -
distinctwith a narrowed projection emitted SQL PostgreSQL rejects. Ordering by a column thatselect,omitor the PII rule had removed put that column in the ORDER BY of a derived table that did not select it. The ordering columns are projected into the inner table and the outer columns are named explicitly, so the extra column never reaches a row. -
upsert's update branch bound an atomic operator object as a literal value.update: { name: { set: 'Upserted' } }stored the TEXT{"set":"Upserted"}in a text column, and{ views: { increment: 1 } }failed 22P02 against an int one.update()compiles the same object correctly two hundred lines away, which is why two passing tests coexisted with the bug. The conflict-update SET now routes through the same builder, so the five operators mean the same thing everywhere and a misspelled one raises the same E003. -
upserton a table with a global filter failed 42702. The filter was compiled unqualified into the conflict clause, so the column reference was ambiguous on both the insert and the update path. It goes through the aliased where builder now. A conflict against a row the filter hides inserts nothing and updates nothing, and raises E001 naming the filter andskipGlobalFilters. -
A failing transactional
pipeline()released its connection while the backend was stillidle in transaction (aborted). The next caller to draw that connection from the pool got 25P02 on a query that had nothing to do with the failure. The transactional path now issues its own ROLLBACK after ReadyForQuery and waits for the status; a connection still inEorTis released with an error so the pool discards it. Non-transactional mode discards on a final status ofE. -
turbine migrateandturbine seedignored the configured schema. Push, generate, doctor and Studio all honouredconfig.schema; these two did not, so a code-first project onschema: 'app'pushed its tables intoapp, diffed againstapp, and applied the resulting migration intopublic, tracking table included. Both pinsearch_paththrough the connection parameter now, never a sessionSET. A schema that does not exist is refused up front with E006 naming CREATE SCHEMA, because PostgreSQL accepts a bogussearch_pathin silence and the eventual error names neither the schema nor the setting. -
Dynamically assembled destructive DDL passed the confirmation gate. A
DROP COLUMNbuilt inside a DO block, and eight further obfuscations of the same shape, ran live without the two-step prompt. Dynamic SQL whose payload the scanner cannot classify is now reported as unclassified rather than waved through, which is the honest answer: "cannot classify" reported as "clean" is the consent gate deciding in the author's favour on no evidence.CREATE RULE ... DO INSTEAD DELETEis flagged too. The controls hold: a dynamic ADD COLUMN, DROP CONSTRAINT or UPDATE with a WHERE stays silent. -
migrate down --stepaccepted-1,0andabc.-1reachedapplied.reverse().slice(0, -1), which is every migration except the oldest, and began tearing down the history newest first. The other two were silent no-ops that read as a successful rollback. All three are E003 now. -
The MCP server ran up to seven queries at once on one client and left its transport open after close. Queries are sequential and close is observable.
-
prisma-compat replaced a caller's array
orderBywith the primary key under a bare cursor. The documented E017 for a cursor whose field is not the sort key never fired, because the check read only the first element. It flattens the array now.OR: []compiles to a predicate matching nothing, and a barenullon a to-one relation key means{ is: null }instead of being read as a column name. -
instanceofon a Turbine error was false in the layout the README recommends. A project with"type": "commonjs"and an.mtsentry loads both the CJS and ESM copies of the package through the generated client, and each copy defines its own classes. Every error now carries aSymbol.forbrand andTurbineErrordefinesSymbol.hasInstance, keyed on the code, so an error thrown by one copy isinstanceofthe other copy's class and the right subclass. -
An unclassified driver error reached the caller verbatim under
errorMessages: 'safe'. The whole point of safe mode is that a value never reaches a log, andinvalid input syntax for type integer: "<the value>"is a value in a message. SQLSTATE class 22 and tsquery syntax errors map toValidationErrorwith the column and the SQLSTATE in the message and the driver text on.detail.OptimisticLockErrormoves the expected value to.detailfor the same reason. No new error code. -
Safe mode redacted two driver fields and PostgreSQL puts row values in five.
hint,whereandinternalQuerypassed through verbatim, on the returned error and on.causealike, withutil.inspectrendering them. Reproduced live: a plpgsqlRAISE ... USING HINTcarried an email address, anEXECUTE format(...)put a bound value ininternalQuery, and a cast failure renderedunnamed portal parameter $1 = '...'intowhere. The last two leak through a CLASSIFIED error too, so this was never confined to the unclassified path; one list and one helper drive both clone paths now, on every SQLSTATE.The scrub was too broad in the other direction as well.
Database error 42P01deletedrelation "orders" does not exist, the single most useful sentence a first-run user sees after forgetting to migrate, kept it nowhere including.cause, and bought no privacy, because a class-42 message is a grammar over schema object names. The driver's message survives now for the classes whose grammar cannot hold a row value (08, 3D, 3F, 42, 53, 57, 58), with 42601 carved out because it quotes a token of the statement and a token can be a literal.P0001and class 55 still scrub: that text is written by the function author. When the text is withheld the message says so and names the setting that shows it, and the error gains.sqlstatebeside its raw.code. It stays a plain driver error rather than becoming typed, deliberately: verbose mode returns the raw error and always has, so minting aTurbineErrorunder safe mode would make the same database failure a different class depending on a log-redaction setting. -
mode: 'insensitive'onin/notInfolded two different alphabets. The column went through the engine'sLOWERand the list elements through JavaScript'stoLowerCase(). SQLite'sLOWERis ASCII-only, so over one row spelled with an accented capital and one with an accented lowercase letter, the same operand matched one row throughequalsand the other throughin. PostgreSQL folds both sides insideunnestunconditionally; every other dialect refuses the operator with E017, naming the engine and the branch form that works. Restoring it there needs a dialect hook with one verified implementation per engine, because each unpacks a bound IN-list in a subquery its own dialect writes, and the portable alternative of one placeholder per element would make the statement text a function of the list length, which the SQL-template cache keys on.equals,notand the LIKE operators are unaffected: they fold a single operand the engine can reach. -
prisma-compat's empty-
ORsentinel was dropped, or dropped its neighbour, depending on key order. The sentinel is keyed on a real column of the table and was merged withObject.assign, so{ OR: [], id: 5 }keptid = 5and returned the row Prisma excludes, while{ id: 5, OR: [] }kept the sentinel and discarded the caller's own predicate. Same query, same meaning, opposite result. It is conjoined now, and the wrapper is branded internal so an emptyORdoes not quietly take a compat query off named prepared statements. A table whose metadata lists no column throws E003 rather than compiling to an emptywhere, which is not "no rows" but its exact opposite. -
A
globalFiltersentry that was itself a relation filter failed 42P01 on two paths.aliasWhereScopetakes a bare alias (t0) and quotes it for a nested relation filter's correlation parent; the batched_countfollow-up and theupsertconflict clause both handed it an already-rendered table reference, so the EXISTS body named the table three times over. UnderrelationLoadStrategy: 'batched'the count therefore failed while the join plan answered the same query correctly. A parameter with two meanings is the defect, so both callers go through one rendered-reference seam now and the bare-alias scope refuses a quoted alias by name. The existing strategy-agreement suite could not have caught it: every global filter it tested was a plain column filter, whose sub-where never needs a correlation parent, and_countwas not covered because the batched plan answers it with a grouped COUNT rather than through the child'sfindMany. -
migrate --allow-driftrewrote stored checksums and reported a no-op. With nothing pending,upsaid "All migrations are up to date" while rewriting a row,deploysaid "0 applied", anddownsaid "Aborted, nothing was rolled back" having already rewritten the history it then refused to roll back. The no-op early return skipped the report, so the one command whose whole purpose is to change a checksum was the one that never said which.downvalidates before its gates and re-baselines after the last thing that can refuse, so a refused rollback now leaves every stored checksum exactly as it found it. -
Three dry runs green-lit a batch the real command refuses.
up --dry-run,deploy --dry-runanddown --step N --dry-runeach exited 0 on input the real run rejects with E006, because each preview reimplemented the plan rather than asking for it. They run the same assertions now, and the tests compare the preview's exit code with the real one rather than with a fixed number. Keeping the preview read-only meant splitting the checksum comparison out of the validation, since validating creates the tracking table and writes legacy-hash upgrades: a dry run against a database with no tracking table still creates none. -
turbine seedran without the schema guardmigratehas. A misspelled schema failed withrelation "..." does not exist, naming neither the schema nor the setting that caused it. One helper serves both, asked of the catalog rather than of the connection's resolvedcurrent_schema(), which is what lets it check a pinned migration client and an unpinned seed probe alike. -
A bad
--schemaprinted a connection-failure banner.Cannot pin search_path to "bad name"is an E003 about the name, and it arrived under "Could not connect to database" with three firewall hints. A real ECONNREFUSED still gets the banner. -
migrate down's drift refusal offered the command that had just failed, and numbered its remedies 1, blank, 3 when the middle one did not apply.--allow-driftwas described three different ways across three help texts; all three now say the same true thing.--steptook a different error path from every other flag, so a bad value skipped the banner and the hints. And--schemawas undocumented oninit,migrateandseed, which the "every long flag is documented" guard could not see because it is a global flag. -
SQL Server reported every unique index as non-unique, so the engine had no unique keys at all.
sys.indexes.is_uniqueis abitand tedious parses TDS bits with!!value, so the driver sends a boolean; the introspector compared it to the number 1.uniqueColumnswas therefore empty on every introspected table,IndexMetadata.uniquewas false on every unique index, and no compound-unique selector could derive. Invisible until a rule asked the metadata a question: since 0.73findUniqueon a genuinely unique non-PK column has been refused on SQL Server as not identifying a single row, and this release gaveupsertthe same rule. The unit lane could not see it because the introspector mock's index-row builder typed itsis_uniqueparameternumber, so the fixture only ever produced the spelling the code already handled.
Added
RelationOrderByis a depth-bounded chain of to-one hops, matching what the builder has always compiled. Two- and three-hop ordering typechecks; the 12th hop is refused at the type level as it already was at runtime.UpsertArgs.updateaccepts the atomic operator objectsupdate()accepts, so the type matches the conflict-update compiler.$primary()returnsthis, so the primary-only view keeps the generated table accessors, and the generated client gains a typed$withSessionoverload mirroring its typed$transaction.OrderBySpecandRelationOrderByChainare exported from the package root and the query barrel.RelationOrderBybecame public this release and is written in terms of both, so annotating a variable by hand needed an import the package did not offer.--keep-column-namesonturbine generate, for a schema that genuinely has colliding column spellings.turbine init --schemawrites the schema into the generated config.migrate up --allow-driftre-baselines a checksum a reviewed edit changed, printing one line per file.downrefuses drift in its own words rather than borrowingup's.migrate down --dry-runandmigrate deploy --dry-runreport through the runner's own planner instead of a second copy of the rule. The old dry run built its list frommigrate status, which sorts by filename and drops applied migrations whose file is missing, while the real run walks the tracking table newest-applied first and stops at the first file it cannot read. With a deleted file the two named different migrations, and a dry run that names the wrong migration is not a dry run.- A migration batch containing an empty UP section is refused before anything
is applied, and a
.sqlfile that does not match the timestamp naming is warned about rather than ignored.
CI
release.ymlandnightly.ymlgrantcontents: readat the workflow level; only the publish jobs holdcontents: writeandid-token: write. All 65 action references are pinned to full commit SHAs with the version in a trailing comment, and the existing Dependabot ecosystem keeps them current.- A tag push whose version is already on npm fails the run instead of skipping
silently. A
workflow_dispatchstill skips. check-release-tests.mjsbypasses the local gate only underGITHUB_ACTIONS=true. A bareCI=1no longer does.lint-stagedruns the same flags asnpm run lint, so the pre-commit hook and CI agree about what a warning is.prepareruns husky when it resolves and exits 0 otherwise, so a fresh clone can install the examples.
Testing
-
ci-ok-needs-sync.test.tsasserts what WORKFLOW.md already claimed: everyci.ymljob is inci-ok'sneeds, and the anti-vacuous literal equals that count. -
docs-claims-sync.test.tsreads CLAUDE.md against.c8rc.json,package.jsonand the source tree. Five sentences were stale and are corrected once. -
docs-snippet-imports.test.tsrefuses a docs snippet that imports a relative module with no extension. Twelve had to be fixed to make it pass. -
verify-skillcreates a bigint and numeric fixture, compares aggregate output against the database's own text rendering, and drops the table afterwards. -
The SQLite declared-boolean assertions now ask the same capability question the engine asks. They expected
true/falseunconditionally while the engine deliberately keeps 1 / 0 wheneverStatementSync.columns()is absent, so the file was green only on the part of the supported Node range that has the capability and red on the rest. Measured rather than read off a release note:columns()is absent on Node 22.13.1 and present on 24.18.0. -
deriveLockIdhas a test. It is the advisory-lock id the migration runner derives from the database name so sibling databases do not contend, and two properties are load-bearing and invisible from the path that uses it: stability across processes, and a value inside the positive int4 rangepg_advisory_lock's one-argument form accepts. -
src/query/types.tsis no longer excluded from coverage. It was on the type-only list, which is for files that compile toexport {};, and it has carried theUNSAFEsentinel and the privilege-option guards since those moved in. Measured both ways over the same collected coverage: counting it raises the project figure from 96.98 to 97.09, because the file itself reads 99.67. The exclusion was hiding well-tested runtime code, not denominator noise. -
check-changelog-headings.mjsgained the checkdocs/releases/README.mdalready told readers existed. No such gate existed, and a document that invents a mechanism is worse than one that states a convention, because the next reader stops looking. Whether a change is breaking is a judgement no regex makes, so the gate does the mechanical part: every###heading in the entry being released must be one of the sanctioned names. It checks the current entry only, because ninety-odd published entries use a wider vocabulary and rewriting them to satisfy a rule invented afterwards would make the log disagree with the releases it records. -
ci-ok-needs-sync.test.tscould not see two legal job shapes, so it failed open. Its job parser rejected an uppercase letter in a job id and a key carrying a trailing comment; with either shape the job sat outsideci-ok'sneedsand all four assertions stayed green. The parser fails CLOSED now: any two-space key underjobs:that the job pattern did not claim is itself a failure, naming the lines. Verified against four mutations. -
size-claim-sync.test.tsread README.md, one of six places these numbers appear, so the re-baseline below left two false figures on the site's comparison table. It walks every tracked README, STABILITY, site and release-docs file now and requires each claim to equal a gate, with a floor on how many it found so the sweep cannot pass by matching nothing. -
docs-snippet-imports.test.tsscannedsite/appand stopped there, so four extensionless imports sat indocs/USING-TURBINE-ORM.md, on the two snippets a new user reaches first. It walksdocs/too. -
Two of the CLI round's new guards were VACUOUS on first writing and were caught by breaking them, which is the only way that is ever caught:
/--schema\b/is satisfied by--schema-file, because\bmatches between a letter and a dash, and a donor-schema test whose donor was never on the inherited path stayed green with its fix removed. Both are rewritten and both now go red. -
assertSchemaExistsandgetCurrentDatabaseNamehave unit tests with fake clients, and both new CLI test files joinedcoverage:cli:collect. With only the collect-list change the aggregate function floor was met at exactly 89.0, which is a gate sitting on its floor; it is 89.5 now.getCurrentDatabaseNameis the input toderiveLockId, which got its own test earlier this release, and an untested input to a tested function is an untested pair.
Measured
- The three size budgets moved: main 87 kB to 91 kB, serverless 69 kB to 72 kB,
prisma-compat 14 kB to 16 kB, all brotli, in two steps as the review round
landed. Checked against the esbuild metafile before each step and the answer
was the same both times: 41 modules, the only
cli/entries the one sanctioned pair, no engine module reachable. The growth is this release's own code inside modules already in the graph, and every published claim moved with the budgets, in eight places across the README, the site homepage and three docs pages.
Docs
- The queries page carries the keyset cursor, the exact
_sum/_avgrendering, the two-hop relationorderByand its depth bound, themoderow, theOR: []semantics, and which warnings survive production. - The errors page leads with
err.code, documents the class-22 and tsquery mappings and the safe-mode scrub, and drops the old "returned unchanged" wording. The optimistic-locking page says the expected value lives on.detail. - The migrate-from-prisma page notes that int8 and numeric totals arrive as
exact strings where Prisma hands back a
Decimal, and constructs the client with both arguments. - README gains a pre-1.0 note linking STABILITY.md,
Experimentallabels on the engines that are, the"type": "module"step, the relation names introspection actually produces, and the@types/noderequirement. The fuzz sentence is limited to the three strategies it covers. - The zod page still described the primary-key optionality rule this release
replaced, on the only page that ever documented it. The queries page claimed
_maxof a bigint is a string; it is a number until the value leaves the safe integer range, because_min/_maxread one stored cell through the row rule while_sum/_avgfollow the column type. That page also offered a bare-stringmodeform that does not exist, did not mention that folding both sides withLOWERputs a plain btree index out of reach, and had no prose at all about the new identity refusal onupdate/updateMany. - The errors page says which failures are
TurbineErrors and which stay plain driver errors, and what safe mode actually withholds. site/lib/changelog.generated.tswas stale at 0.76.0 and is regenerated.
0.77.1 (2026-08-23)
No package changes. The published tarball is functionally identical to
0.77.0; .github/ is not in files. This release exists because the thing it
fixes lives in the release workflow, and a release workflow can only be verified
by running one.
Fixed
-
The post-publish smoke test could not have succeeded, for any release. It went red on 0.73.1, the retry budget was raised from 12 x 10s to 30 x 10s, and it went red again on 0.77.0. Both versions published correctly, with provenance. Raising the number twice is how we found out the number was never the problem.
The mechanism is npm's client-side cache, not registry propagation.
registry.npmjs.orgserves packuments withCache-Control: max-age=300, and npm'sprefer-onlinedefaults tofalse, so for 300 seconds npm answers from its local cache without revalidating. The first attempt runs moments after publish and caches a packument that does not yet list the new version; all 29 later attempts read that cached copy and never reach the network.30 x 10sis exactly300s, so the loop expired at the precise moment its own cache would have.--prefer-onlineforces revalidation per attempt, which is what makes a retry loop a retry loop. The budget stays at five minutes and is now a bound on genuine propagation lag rather than a race against a TTL. The failure message no longer reads as though the release failed, either: the step runs after an irreversible publish, so by the time it can fail the package is on the registry and the GitHub Release step still runs.This is the same lesson as the rest of 0.77.0, arriving one release later and at our own expense: the earlier fix changed a number and left the mechanism unexamined, so it came back.
0.77.0 (2026-08-23)
0.76.0 published from a commit whose CI was red. Nothing was wrong with the
gates: the failing job was unit-tests (20), and the release went out from a
laptop, which never runs the Node 20 leg. The gates were real, thorough, and
sitting beside the road rather than on it.
That is the release. Most of what follows moves an existing check onto the path that actually reaches npm, and the two user-visible bug fixes are both cases of an error that could not be caught by the code written to catch it.
Security
-
A polynomial regular expression on the PowDB type mapper.
tsType.replace(/\s*\|\s*null$/i, '')strips a trailing| nullfrom a generated TypeScript type.\s*can begin matching at every position, so an input of N whitespace characters with no|costs O(N^2). Measured on Node 24: 10,000 spaces took 39.6 ms, 20,000 took 150.3 ms, 40,000 took 617.5 ms.It was written eight times across
powdb.tsandpowql.ts. All eight now call one linearbaseTsType, whose equivalence to the regex it replaces is asserted against the real old regex over a corpus and 4,000 generated inputs, not against a description of it. The same input that cost the regex 352 ms at 30,000 characters costs the replacement 0.13 ms at 200,000.Reachability, stated plainly: this was not remotely exploitable.
tsTypecomes from a generatedmetadata.tsor from adefineSchemacall, so it is authored by the developer or derived from their own database catalog, never from request input. It is fixed because eight hand-copied spellings of one predicate is the drift shape this codebase keeps paying for, and because the linear version is not harder to read.
Fixed
-
A pipeline timeout rejected with a bare
Error, so it could not be retried.catch (e) { if (e instanceof TimeoutError) retry() }silently did not retry, because the timeout branch inpipeline-submittable.tsnever constructed aTimeoutError. It does now, carrying codeTURBINE_E002like every other timeout in the package.executePipelineinpipeline.tswas also missing itscatchentirely, so a raw pg driver error escaped unwrapped pastwrapPgErrorand arrived as a driver object rather than a typed Turbine error. A unique-constraint violation inside a pipeline now surfaces asUniqueConstraintError(E008), matching every other execution path. -
A relation payload that would not parse was returned as a raw string.
parseNestedRowcaught a JSON parse failure, logged, and assigned the unparsed text to the relation field. A caller who asked forwith: { posts: true }and expected an array received a string, and the failure surfaced later and elsewhere, as a type error with no visible connection to its cause.This is a behaviour change. It now throws a
ValidationError(E003) naming the relation, with a once-only dev warning. If you were relying on the raw value reaching your code, you were relying on a bug, but the failure mode does move from silent to loud. -
Every error message carried two prefixes.
formatErrorMessageprepends[TURBINE_EXXX], and 433 call sites also hand-wrote a[turbine]prefix into the message they passed in, so the shipped text read[TURBINE_E003] [turbine] Unknown column "titel" on table "posts".Two display-strips in the CLI existed only to undo the doubling at print time.Messages now carry one prefix. The 73 remaining occurrences are diagnostics that no code tag ever touches (
console.*,process.stderr, Studio's HTTP bodies), where the prefix is the only thing marking the line as ours.check:error-prefixkeeps the two populations apart by resolving the enclosing callee, because the same literal is correct in one and wrong in the other and no context-free rule can tell them apart.Error message text is not part of the stable contract (see
STABILITY.md), but this is visible in every log line, so it is worth calling out.
Changed
-
mainis now protected and pull-request only.ci-okis the single required status check andenforce_adminsis on.ci-okaggregates all 19 CI jobs, so a new job is covered the moment it joins theneeds:list, where a hand-listed set of required contexts silently was not: the previous configuration listed eleven and left ten jobs unrequired, including every non-Postgres engine and the blocking security audit. -
Releases are triggered by a tag.
release.ymlruns the gate chain, then publishes with--provenance, creates the GitHub Release, and runs a post-publish registry smoke test. A localnpm publishstill works as a fallback but now refuses unless CI is green onHEAD, asked of GitHub rather than inferred from a local run, and failing closed on a missing run, an unfinished run, or an unusablegh. -
docs/WORKFLOW.mdis new and tracked: branches, pull requests, releases, test requirements, the guard set, and the public-repo security rules, in one place.CONTRIBUTING.mdandCLAUDE.mdpoint at it.
Internal
None of this changes the published API. It is listed because it changes what can reach the registry.
-
Five checks moved onto the publish path.
check:private-terms,check:cycles,check:error-prefixandcheck:skip-gatesnow run inrelease.ymland inprepublishOnly, andTURBINE_REQUIRE_ENGINEis set on both ofrelease.yml's database jobs.ci.ymldoes not gate a tag push andprepublishOnlydoes not run when the tag path publishes, so on the one route that reaches npm they ran nowhere. -
The shipped agent skill is verified by CI, not by habit.
README.mdstates that every factual claim in it is executed against a live database before release; the 417-line verifier was referenced by no workflow and no script. It is a required job now, 46 of 46 claims hold, and the verifier asserts its own coverage, because the claim list is hand-written and a sentence added toSKILL.mdwould otherwise become an unverified claim in silence. -
Import-cycle detection finds real cycles. The previous check answered one question, whether
query/orcli/statically importsclient.ts, and was blind to every other cycle. It now runs Tarjan over the whole value-import graph, seesexport ... fromre-export edges, excludes type-only edges, and carries two self-tests plus a zero-file refusal.It immediately found one:
powdb.tsre-exportedPowqlInterfaceandintrospectPowdbDatabasewhile both of those modules imported values back from it, a three-node cycle in the largest engine entry. Broken by hoisting the shared primitives intopowdb-shared.ts. Theturbine-orm/powdbsurface is byte-identical, verified across CJS, ESM and.d.ts. -
cli/index.ts,introspect.tsandgenerate.tsare under coverage ratchets for the first time.cli/index.tsis the largest file in the repo at 5,945 lines and was in no gate at all. It is deliberately kept out of the CLI aggregate: one file that size at 33% drags the aggregate's line and function floors down about 25 points, which would let the other eight files shed over a thousand covered lines with the gate still green. -
Documented numbers that keep drifting are asserted from source. The coverage floors, error-code range, module count and fixture row counts quoted in
STABILITY.mdandCONTRIBUTING.mdwere stale again, three weeks after the last time they were corrected by hand.docs-claims-sync.test.tsreads each one from.c8rc.json,package.json, theTurbineErrorCodeunion,seed.sqland the directory listing, so the next drift fails a test instead of surviving to the next audit. -
Test-integrity fixes. Five PII relation assertions were inside loops that did not execute on an empty result set, so they passed while testing nothing; they now assert a non-empty result first. A skip-on-unsupported branch used a bare
return, reporting a pass; it callst.skip()now.
0.76.0 (2026-08-22)
An outside review of 0.75.0 went looking for a SQL injection in the query builder, did not find one, and found nine real defects in everything around it: a code generator that trusts the database catalog, a PII guard that resolves a name differently from the compiler it guards, a relation global filter that emitted a placeholder and bound nothing, and a set of transport perimeters with no ceiling on their input.
That shape is the release. The invariant everyone worries about held; the tooling built on top of it did not. Two of these are remotely triggered, both were reproduced by execution rather than inferred from reading, and both are fixed here with the mechanism that makes the class hard to reintroduce rather than the instance easy to close.
Security
-
turbine generategave a hostile database catalog arbitrary code execution in the developer's environment. Table, relation and enum names were interpolated raw into JavaScript object-key position in the emittedmetadata.ts/types.ts/index.ts. A Postgres enum named[Function('…')()+'x']emitted a COMPUTED key, and a computed key inside an object literal is evaluated when the module is imported, which the generated client is, by every subsequent build. Reproduced end to end against a live database, including the payload executing on import.Two boundaries now, not one.
src/introspect.tsrefuses a catalog whose names carry characters no identifier can contain, so the payload never reaches the generator; and the generator emits every key through an ANCHORED identifier match, falling back to a quoted string literal, so a name that is merely unusual still round-trips and a name that is hostile cannot become syntax. Output for every ordinary schema is byte-identical.This is a behaviour change:
turbine generatenow fails with aValidationError(E003) naming the offending object, where it previously emitted a file. If you have a table or column whose name contains a control character, a line separator, or a quote, generation will now stop. -
A relation's global filter emitted
$Nand bound no parameter. With a value-bearingglobalFiltersentry on a relation TARGET,with: { rel: true }built the filter into the correlated subquery and the collect path skipped it, so the driver refused the statement outright (bind message supplies 1 parameters, but prepared statement requires 2). On the join plan this is the flagship single-query nested read, and on a multi-tenant schema it is every query that touches a filtered table. Live since 0.28.0, about 48 releases.Two things kept it hidden, and both are worth knowing. The default
autostrategy falls back to the batched plan on an unindexed correlation column, and the batched plan was correct, so adding the covering indexturbine doctortells you to create is what turned a working query into an error. And the wholeglobal-filters.test.tsfixture was built ondeletedAt: null, which binds nothing, so every assertion in the file was blind to a collect-path that pushed nothing. The fixture is now value-bearing and the parameterless shape keeps its own separate case.The fix is not a patched branch: six independent walkers each decided for themselves what a
withentry's options were, andtrue/{}/{ … }are now one shape (relationOptions()) for all of them. -
SQL Server dropped a relation target's global filter entirely. Found while fixing the above, and worse than it:
mssqlDialectoverridesbuildRelationSubquerywholesale forFOR JSON PATHand returned before the filter could be applied, while the collect path still pushed its value. So on SQL Server the predicate simply vanished and the subquery returned every tenant's rows, with an orphan parameter riding along. There was no error.The filter is applied in the
buildWhereclosure the core hands to a dialect override, not in the override, so every present and future dialect override inherits it rather than each having to remember.RelationSubqueryContextdeliberately carries no builder context, which is why it could not have been fixed insrc/mssql.ts. -
Two PII guard bypasses, one root cause. A relation written in its
snake_caseDDL spelling (blog_postsfor a declaredblogPosts) walked straight past the guard's exact-match lookup and into the compiler, which since 0.72 accepts both spellings, so Studio and the MCP server filtered and sorted on hidden columns they had refused under the other spelling. Separately,distinct: [["email"]]slipped through a check that assumed the array's elements were strings, and emitted byte-identical SQL todistinct: ["email"].Both are the guard disagreeing with the compiler about what a name is. It now resolves names through the same
resolveRelation/resolveColumnNamethe builder uses, and refuses any non-string element in a field list.src/test/pii-guard-symmetry.test.tsasserts the two agree on ACCEPTANCE rather than checking a list of spellings, so the next spelling rule is covered the day it is written. -
The destructive-migration scanner missed
concat().EXECUTE 'DROP TABLE ' || 'users'was flagged andEXECUTE concat('DROP TABLE ', 'users')was not, so a migration assembling aDROPthat way applied it with no prompt and no--allow-destructive. The scanner now knowsconcat/concat_wsas proximity forms andarray_to_string/replace/regexp_replaceas open-call-wrapping verbs, gated onEXECUTE. -
Three transport perimeters had no ceiling on their input. The MCP stdio reader buffered an unframed line without limit, so a peer that never sent a newline grew it until the process died; it is now bounded at 8 MiB and ENDS the session rather than truncating, because there is no correct way to continue a stream whose framing has been lost. The shared rate limiter's key map never evicted, so under
--allow-remoteit held one permanent entry per source address ever seen; it now sweeps expired windows and is capped, evicting oldest first so it can only ever forgive a caller early. Andcompile_query's fail-closed "does this query name a column" test read the top level only, sowith: { posts: { where: { secretNote: … } } }was reported as safe to compile when the PII tag file could not be read; it now walks every depth and errs toward refusing. -
sample_rowspromised more than it enforced on an untagged schema. The tool description said PII columns are never fetched without saying thatpiiis a code-first tag loaded from generated metadata, so on a project that has not runturbine generatea 13-word name denylist was the whole protection andredactedColumns: []read as "checked, holds no PII". The guarantee is unchanged and still stated; the description now names what decides the SET of hidden columns, points at thepiiTagSourcefield, and says plainly that returned rows are untrusted database content rather than instructions.
Fixed
-
turbinesilently discarded unknown flags.push --dry-runnexecuted for real,doctor --fixxexited 0 and wrote nothing, andmigrate create x --autooreported success and wrote an empty template. On the product whose sixth selling point is that dangerous operations ask first, a typo in a safety flag turned a dry run into a real one. The flag surface is now data, an unknown flag is an error with a nearest-name suggestion and the command's real flags, and an unknown COMMAND suggests too. -
CLI failures went to stdout. 633
console.logagainst 5console.error, soturbine generate > build.logswallowed the error and CI capturing stderr saw nothing. Failures now write to stderr and a pure failure writes NOTHING to stdout, verified by measuring both streams separately.doctor --jsonandskill --printremain byte-clean on stdout. -
--schema ./schema.tswas accepted and ignored on six commands.--schemais the Postgres namespace; the file path belongs to the new--schema-file. The refusal that already existed ongeneratenow coverspush,status,doctor,studio,mcpandmigrate create. -
turbine initagainst an empty database told you to query a table you do not have. It scaffolded a fully commented-out schema, found 0 tables, and then printeddb.users.findMany(). It now writes a real two-table starter schema when the database is empty, and the next-steps text names a table the generated client actually has. -
The documented import path did not compile.
import { turbine } from './generated/turbine'is a hardTS2834under NodeNext, the resolution this package itself ships. The CLI now derives the specifier from the generator's own extension resolution, and the README, JSDoc and docs snippets carry the form that compiles. -
SQLite logic errors were untyped.
no such table/no such column/ syntax errors have no SQLSTATE forwrapPgErrorto borrow, so they surfaced as a bareErrorwithcode: 'ERR_SQLITE_ERROR', noTURBINE_E0NNand nodocsUrl, which made the typed-errors promise Postgres-only in practice. They are nowValidationError(E003) with the driver's text kept verbatim and the driver error attached ascause.ValidationErroraccepts acausefor the first time; the base class already redacts one undererrorMessages: 'safe', so this goes through the redaction rather than around it. -
compile_queryunder-reported a relation written insnake_case. The same exact-match bug as the PII guard, in the MCP tool: the relation was silently dropped from the report, so the statement count was low and the correlation probes were skipped for exactly the relation the caller asked about. -
Seven of eight examples failed at step one. They pinned
turbine-orm: ^0.7.x, which on a0.xversion resolves a release 68 versions old, passed a file path to--schema, and constructed a client the generator has not emitted in a long time. All eight now install from the workspace, run their own setup, and typecheck. Three were also missingtsx, which their owndb:pushneeds, and one importedpgwith no@types/pg; both were found by the new CI job rather than by reading. -
A dangling
Available:on a table with no relations. Fourteen error sites interpolated a joined key list directly, so on an empty list the message ended at the colon. One sharedavailableClause()now renders the list or a sentence saying there is none.
Changed
-
PowDB refuses
distinctwithUnsupportedFeatureError(E017) instead of compiling PowQL that silently ignored it. Verified across all four dialects before choosing where the refusal goes: PostgreSQL supports the feature and so raises E003 for an unknown column name, while SQLite, MySQL and SQL Server all raise E017 for the feature. E003-for-an-unknown-name is therefore not a shared contract, and PowDB refuses before resolving names, like its peers. -
pipeline()takes the caller's dialect, so a batch on SQL Server emits SQL Server transaction keywords rather than PostgreSQL's. -
Writes on a PowDB table with a PII-tagged primary key keep the key. The client-side strip removed it along with the other tagged columns, leaving a row nothing could address.
-
jsonEncoding, the MCP tool count, and the generated-SQL showcase are corrected across the site.relationssaidjsonEncodingdefaults to'object', false since 0.71, with the knock-on thatflattensilently no-ops on PostgreSQL under the real default. The landing page advertised eleven MCP tools and listed ten, and its "Generated SQL" panel showedjson_build_object, which PostgreSQL no longer emits. -
The bundle claim, the size gate and the site now carry one number. The README said "under 85 kB brotli, enforced by size-limit in CI" while the entry measured 85.22 kB and the gate was 90 kB: the published figure was exceeded and the gate cited as enforcing it was 4.78 kB looser. Main is now 87 kB and the edge entry 69 kB, the same numbers in
.size-limit.js, the README and the site, with a test that fails if they diverge. The re-baseline from 86/68 is this release's own growth in the shared client/query graph, checked against an esbuild metafile first to confirm no engine or CLI module had leaked in. -
The streaming benchmark is re-measured and the claim reversed. The site said in three places that Drizzle takes streaming by 22%, from a 0.71.0 run, while the same page carried a 0.72.0 table showing Turbine ahead. Three fresh runs of fifteen interleaved rounds:
findManyStreamBatches36.70 ms,findManyStream40.69 ms, Drizzle 1.0.0-rc.4 37.10 ms, Drizzle 0.45.2 44.20 ms. Against the published Drizzle the scenario has reversed on both Turbine spellings; against the release candidate it is a tie, not a win (the paired delta spanned -4.9% to +3.1% while the negative control spanned -3.7% to +3.2%), and the per-row API is a real 5-11% loss to rc.4. A new harness,benchmarks/bench-stream-parity.ts, runs both Turbine spellings in one rotation, because the old single-arm harness compared a per-row API against four arms consuming arrays and reported the API difference as a speed difference. -
The competitor table said Drizzle's Gateway is paid. It is free to self-host; the claim was true of the retired Drizzle Studio subscription. The cell now links the vendor's own page, and the table's footnote states the rule: a cell asserting someone else's pricing carries a link, because that is the cell that goes stale silently.
-
The README documents the code-first SQLite path. The section said "SQLite needs nothing else" and then imported
./generated/turbine/metadata.js, which onlyturbine generateproduces andgenerateis Postgres-only. ThedefineSchema->schemaToSQL(schema, { dialect: sqliteDialect })->schemaDefToMetadataroute works and was verified end to end; it simply was not written down. Same correction in the shipped JSDoc. -
The prepared-statement memory channel is documented where a reader looks. A named statement is never deallocated, and 0.66 closed the largest request-controlled case by sending variable-arity combinators unnamed. The residual channel is a caller-chosen
select/omit/withsubset, which the builder cannot distinguish from a hand-written one. It was a code comment; it is now in the queries docs and in thepreparedStatementsJSDoc, with the mitigation.
Testing
src/test/generate-injection.test.tsdrives the real generator with hostile catalog names and executes the emitted module in anode:vmto prove nothing runs on import.src/test/pii-guard-symmetry.test.tsasserts the guard and the compiler agree on which queries are ACCEPTED, over both spellings and both field-list shapes.src/test/with-spec-shorthand-drift.test.tssweepstrue/{}/ options across four dialects, two encodings and every nesting shape, asserting the SQL is identical and every placeholder is backed. SQL Server is in that sweep now; the block that pinned its broken behaviour has been replaced by one asserting the fix.src/test/global-filter-relation-strategies.integration.test.tsruns every relation-load strategy against a live database and compares rows, with an explicit precondition check on the covering index so a missing fixture index reads as a fixture problem rather than a product defect.src/test/mcp-perimeter-bounds.test.tsexercises each transport bound at the scale that made it a defect: 12 MiB unframed, 50,000 distinct rate-limit keys, and a 40-level nested args object.src/test/cli-arg-safety.test.ts,cli-first-run.paths.test.ts,sqlite-error-classification.test.ts,size-claim-sync.test.ts,destructive-dynamic-assembly.test.ts,pipeline-dialect-tx.test.ts,powdb-dialect-contract.test.ts.- Nine vacuous assertions removed.
if (posts.length > 0) { … }on a fixture that guarantees posts is an assertion that passes when the relation comes back empty, which is the failure it was written to catch. They assert now. OnecreateMany({ skipDuplicates })case hedged with "or none" against a column that carries a unique index, and now asserts the exact count. - A shared-fixture leak.
auto-compound-integration.test.tsdropsidx_posts_user_idin setup and did not put it back, so every later suite in the run planned differently. Since the index's absence is precisely what makesautofall back to batched, which is what hid the relation global-filter bug for 48 releases, this is the one index whose leak is most likely to hide a defect. It is restored in teardown.
CI
examples-smoke: every example, installed from the packed tarball against a real Postgres, push -> generate ->tsc --noEmit. Nothing ran the examples before, which is how seven of eight stayed broken.ci-ok: one aggregate job that fails unless all eighteen others succeeded, intended as the single required check. Branch protection can only require job names it already knows, so a newly added job was not required until someone remembered to add it in a settings page; now forgetting is a visible diff in the workflow file. It runs withif: always(), because a skipped required check is reported as neutral rather than as a failure.
0.75.0 (2026-08-16)
0.74.0 fixed five options PowDB accepted and ignored, and verified the fix
against a recording pool. Running the same checks against a live engine
found that the headline fix never reached the public factory: turbinePowDB
hand-listed the four config keys it forwarded, so a globalFilters passed to it
was dropped one level above the code that had just been taught to honour it.
The lesson is the one this release series keeps paying for, one level up from where it was last applied: a fix verified through an internal seam is not verified. Asserting the emitted statement proves the option reaches the builder; only executing the query proves it reaches the caller.
Fixed
-
turbinePowDBdropped almost every client option, includingglobalFilters. The factory forwardedlogging,defaultLimit,warnOnUnlimitedandrelationLoadStrategyand nothing else, soglobalFilters,stableRelationOrder,errorMessages,logQueryParams,sqlCacheand everything else added toTurbineConfigsince PowDB landed were accepted by the type and discarded beforeTurbineClientsaw them.This is the same allowlist that
engine-config.tswas written to kill for SQLite / MySQL / SQL Server; PowDB was never converted. It now extendsEngineClientConfiglike they do, so a new client option is engine-wide the day it lands and only a deliberate exclusion can take it away.This changes what PowDB queries return for anyone already passing these options: as of 0.74.0 the options worked, and as of this release they arrive. If you configure
globalFiltersagainst PowDB, your queries are filtered now. -
groupBy({ _count: { ... } })returned no_countat all on PowDB. The record form (_count: { _all: true, email: true }) fell through both branches of the aggregate selection, so PowDB answered with the group keys and no_countkey on any row, while every SQL engine returned the counts. Readinggroup._count.emailoff that row threw. The scalar form (_count: true, and the default) was unaffected._allis the row count and a named field is that column's NON-NULL count, matching the SQL builder andaggregate(), which already had this right. A per-field count of a nullable column is gated on the same engine versionaggregate()gates on, for the same reason.
Changed
turbineHttp(theturbine-orm/serverlesssubpath) accepts every client option.TurbineHttpOptionswas a three-keyPick, so passingglobalFiltersto a Neon / Vercel / Hyperdrive client was an excess-property error on an option the runtime would have honoured. It is nowOmit<TurbineConfig, 'pool'>, which is exactly what the function already spread through. Type-level only: no runtime behaviour changes, and nothing that compiled before stops compiling.
Testing
-
src/test/option-behavior.integration.test.tsandsrc/test/powdb-option-behavior.integration.test.ts: the behavioural half of the option matrix, on PostgreSQL and on a live embedded PowDB. Same inventory asoption-observability.test.ts(ALL_OPTION_TABLES), opposite question: not "does this option change the statement" but "does it change the answer". 105 assertions, every one of them reading or writing rows.Both were mutation-tested, and the fixtures are shaped by what escaped. A per-field
_countwas first asserted over the primary key, where the non-null count and the row count are the same number for every possible input, so an implementation that emittedCOUNT(*)for both passed; both fixtures now carry a nullable column seeded with real NULLs, which is the one place the two answers differ.
0.74.0 (2026-08-17)
The 0.73.1 rule, asked of the second engine: every option on every operation
must change the answer, or be refused. PowqlInterface is a parallel
implementation of the same public surface, so a rule enforced on QueryInterface
alone is not a rule. Asking PowDB the same question found five options it
accepted and ignored, two of them load-bearing.
Fixed
-
globalFiltersdid nothing on PowDB. A client configured for multi-tenancy or soft-delete applied its predicate on every SQL engine and NONE on PowDB: the same application code returned one tenant's rows on Postgres and every tenant's rows on PowDB, with no error anywhere.skipGlobalFilterswas equally inert, so the opt-OUT appeared to work too.If you run PowDB with
globalFiltersconfigured, this release changes what your queries return, which is the point, but it is a result change and not only a bug fix. The filter now applies tofindMany,findFirst,findUnique,count,aggregate,groupBy,update,updateMany,deleteanddeleteMany, and to relation loads (each level resolves its own target table's filter).The rule is SHARED with the SQL engines (
resolveGlobalFilterFrom) rather than restated, because two copies of a rule this specific is how the engines drifted before. The ordering is a contract in its own right: the empty-whereguard still sees the USER predicate alone, so a configured global filter can never quietly turn a refused mass mutation into an accepted one. -
optimisticLockdid nothing on PowDB. The version check never happened, so the update applied unconditionally: a concurrent writer's change was overwritten by a caller who believed they held the lock, and no error was raised. It now adds the version predicate, bumps the column, and raisesOptimisticLockError(E015) when the row moved on, matching the SQL engines. -
stableRelationOrderdid nothing on PowDB, so relation rows came back in whatever order the engine produced, from the option whose entire purpose is that they do not. The transform moved toquery/relation-names.tsand both engines now run it. -
allowFullTableScanwas refused on PowDB'supdate/deleteeven when passed, while every SQL engine accepted it. -
jsonEncodingis now refused on PowDB withUnsupportedFeatureError(E017) instead of being dropped. It selects between PostgreSQL'sjson_build_objectandjson_build_arrayrelation encodings, and PowQL emits no JSON row encoding at all, so there was nothing for it to select.
Internal
src/test/powdb-option-observability.test.ts, the 0.73.1 matrix asked of PowQL, with one addition that is the whole reason to ask: an option PowDB cannot honour must throw E017, never be silently ignored. 90 assertions.- The SQL matrix now runs over TWO schema shapes (single-column and composite primary key), since several options are key-shaped and one fixture only ever proves an option is observable in the shape it happens to have. 180 assertions.
$retry,$observeandpipelineSupportedwere the three untested public client methods; the first two are thin delegations, which is exactly why they went uncovered, and what a delegation leaves unproven is the wiring.
0.73.1 (2026-08-17)
A correction to the 0.73.0 notes, and the gate that release should have shipped
with: every option on every operation must change the answer, or be refused.
0.73.0 fixed three arguments that were accepted and did nothing. It did not close
the class, and the suite could not have caught them: take appears in dozens of
tests while skip was not a key at all, so "is this option mentioned in a test"
was already true for the half that worked, and line coverage cannot tell "read
and used" from "read and dropped".
Fixed
-
migrate statuscould crash on a fresh database when several ran at once.CREATE TABLE IF NOT EXISTSis not atomic, so the losers of the tracking-table create race are retried once. The set of Postgres codes recognised as that race was written from what ONE measured run happened to produce,23505and42P07, and creating a table also creates its composite row TYPE, so a loser can instead report42710(duplicate_object) and fall through to the rethrow.migrate up/downhold the migration lock and were never affected;migrate statusand the deploy inspector deliberately do not.The same shape as the bug in the notes below: an enumeration written from observation rather than from the rule.
Internal
None of this changes a shipped code path; it is recorded because it is what the release is mostly made of.
-
A gate for the whole option surface.
src/test/option-observability.test.tscompiles every operation x every non-internal option, twice, with and without that one option, and requires the two to differ in a declared way: the SQL or params differ, or the option is refused, or it unblocks a refusal, or it is declared observable only at execution and NAMES the test that proves it. 91 assertions, driven from the sameALL_OPTION_TABLESinventory the compiler already binds, so a new option fails to compile until it is classified and then fails this file until someone says where it is observable. Neither step can be satisfied by an option that does nothing. -
The per-query
timeouthad no core test. It sits on all thirteen arg interfaces, and every assertion that it actually aborts a query ran throughturbine-orm/prisma-compat, so the core client's behaviour rested on an adapter's test. Now covered per call site:findMany,findFirst,count,aggregate,groupByand the writes each passargs.timeoutseparately. -
pipelineSupportedwas untested at both levels, and each of its failure modes is silent: a wrongfalsecosts the 1-RTT batching with no error, and a leaked probe connection drains the pool somewhere else entirely. -
The tracking-table race test reported
["[object Object]"](it mappedStringover the settled-result wrappers rather than over.reason), so the release it eventually blocked named no error code. It also only reproduces the race about 12% of the time, which is a smoke test and not a gate; the rule it covers is now asserted deterministically, one case per accepted code. -
A claim in the 0.73.0 notes overstated how far two of its three bugs reached. The notes said the Prisma pair
{ take: 20, skip: 40 }"type-checked". Written the way the sentence shows it, a plain object literal, it did not: excess property checking rejects it (TS2353), and the same is true ofinclude.What is true is narrower, and it is the reason a cross-model eval found these and code review had not. Both keys reached runtime only through the calling styles that check does not cover, all of which compile clean: a spread such as
findMany({ ...req.query }), plain JavaScript, and any JSON-shaped caller, which includes the MCP tools, theturbine-orm/prisma-compatdelegates (their args areRecord<string, unknown>, so there every key genuinely did type-check) and the eval harness itself.The third bug is the one that was invisible to the type system:
findUniquetakes the fullWhereClause, so a non-uniquewherecompiled for every caller. The notes now draw that distinction instead of flattening all three into one sentence. -
The packaged skill (
skills/turbine-orm/SKILL.md, installed bynpx turbine skill) andllms.txtdescribedincludeas simply ignored, without saying that a plain literal is a compile error. Both now say which paths reach the runtime behaviour. The instruction itself, relations go inwith, was correct and is unchanged, andnpm run verify:skillholds at 46/46.
0.73.0 (2026-08-16)
The same rule as 0.72.0, one step further: an argument the caller wrote must change the answer, or be refused. 0.72.0 was about names. This one is about three arguments that were accepted and then did nothing, or did something other than what they said, and it ships the agent skill they were found by.
All three produced a plausible result, which is why they lasted. A thrown error gets fixed the same day.
Breaking
-
findUniquenow requires awherethat identifies a single row. A primary key, a single-column unique, or every column of a compound unique; extra predicates alongside the key are still fine, since they can only narrow a set that already holds at most one row. Anything else throwsValidationError(TURBINE_E003) naming the keys that would have worked.Before,
findUnique({ where: { status: 'active' } })emittedWHERE status = $1 LIMIT 1with noORDER BYand returned an arbitrary one of the rows that matched, differently between two calls with the same argument. Unlike the two option bugs below, this one was invisible to the type system:whereis typed as the fullWhereClause, so every TypeScript caller compiled clean. The caller who wrotefindUniqueasked for the row, and thenullbranch they wrote reads as "no such row" when it meant "none matched this filter". This extends the empty-whereguard added in 0.61, which already refused the degenerate case for exactly this reason.findFirstis untouched: "the first row matching an optional filter" is its whole contract. Give it anorderByif which row matters. Anullvalue never satisfies the rule even on a unique column, because PostgreSQL permits any number of NULLs in a unique index. -
takeandlimit(orskipandoffset) with different values now throw instead of silently preferringtake. Equal values are accepted.
Fixed
-
skipdid nothing.takewas a recognized alias forlimitandskipwas not a Turbine key at all, so the Prisma pair{ take: 20, skip: 40 }ran and returned the FIRST page however far the caller thought they had paged.skipis now the alias foroffsetthattakeis forlimit. Half a recognized pair is worse than neither half: an unknown key is inert on its own, but a half-recognized one changes the answer.How far this reached, stated precisely. Written as a plain object literal,
findMany({ take: 20, skip: 40 })was a COMPILE ERROR on 0.72.0 and earlier (TS2353, excess property), so a TypeScript caller writing it that way was told. It reached runtime through the calling styles where TypeScript's excess-property check does not apply and which compile clean: a spread (findMany({ ...req.query })), plain JavaScript, and any JSON-shaped caller, which includes the MCP tools and anything an agent submits. That is also why a cross-model eval found it and code review had not.Both aliases are now folded ONCE, before anything reads them, so
limitandoffsetare the single authority below that line and the two spellings share one SQL-cache entry.takeused to be handled by six separateargs?.take ?? args?.limitreads, one of which is the cache FINGERPRINT; addingskipthe same way would have meant six more places to keep in step, and a miss in a fingerprint is not a missing feature, it is two different pages sharing one cached statement.
Added
-
npx turbine skillinstalls a query-writing skill into the project (.claude/skills/turbine-orm/SKILL.mdby default).--printwrites it to stdout,--agentsprints a short instructions block forAGENTS.md/CLAUDE.md,--dir <path>installs under a different skills root. The skill ships in the package, so what an agent reads is what the repository tests: every factual claim in it is executed against a live database before release, by a checker that names the sentence to change when an answer moves. -
An unknown query option now warns outside production, once per
table.operation.key, naming the key it thinks you meant.includegets its own message pointing atwith, because that is the single largest source of confidently-wrong queries against Turbine: an unrecognized option is ignored, so the query runs, returns rows, and the relation is absent from every one of them. A warning rather than a refusal, deliberately, sincefindMany({ ...someOptionsBag })is ordinary code and the option surface grows between minors.It is driven by the same
query/option-surface.tstables that already bind the compiler in both directions, so a new option cannot be added without the diagnostic learning about it.
Measured
The skill was shipped against a number rather than a hope. On a held-out schema
across 240 scored attempts (evals/AGENT-EVAL-0.73.0.md), the skill ALONE, with
no docs and no MCP connection, took first-try pass rate from 73% to 97% on
claude-sonnet-5 and 60% to 97% on claude-haiku-4-5, against a measured
run-to-run noise band of about 7 points. On both models it matched or beat a
live turbine mcp connection. Twelve of the twenty cold failures were one
mistake, a relation named inside select, which the skill's fourth section
addresses and which drops to zero wherever the skill or the tools are present.
0.72.0 (2026-08-16)
A correctness release about one rule: a column and a relation each have two legal spellings, and which argument the name appears in must not change the answer.
Turbine has always accepted the snake_case column name in where, select,
omit, distinct and cursor, because camelToSnake is idempotent on an
already-snake string. It rejected the same name in orderBy, groupBy's by,
and _min / _max / _sum / _avg, because those sites tested
key in meta.columnMap, which knows only the field spelling. Same column, same
table, same query: five positions accepted it and three refused it.
That asymmetry was not only an error message. Chasing the rule to every site it should already have reached turned up four wrong answers, listed below. The worst returned the wrong page of a cursor-paginated query with no error at all.
The relation half is the same rule one level up, and it was found by measurement
rather than review. A relation carries one declared name (blogPosts) while the
DDL anyone reads carries only the table name (blog_posts), so writing back what
the schema shows failed, on names the error text was already computing correctly
("Did you mean ...?"). A system that can name the intended relation can accept
it.
Both halves are widenings: every name that resolved before resolves to the same thing, an exact declared name always wins, and an unknown name is still refused.
Fixed
-
A cursor whose field was spelled the other way silently returned the wrong page.
cursor: { created_at: … }againstorderBy: { createdAt: 'desc' }failed to match up the two spellings, defaulted to ascending, and emitted"created_at" > $nunderORDER BY "created_at" DESC. No error, plausible rows, wrong ones. Cursor fields and orderBy entries are now matched by the column they resolve to. -
The batched relation loader deleted a column the caller had explicitly selected. With
select: { user_id: true }and auserIdcorrelation key, the key looked unprojected, so it was force-added and marked stitch-only, and the strip pass removed the very column that was asked for. The mirror case (omit: { user_id: true }) failed to un-omit and raised an internal "bug in turbine" error on a legal query. -
groupByreturnedundefinedfor every group when abykey used the column spelling. Rows are read through the field-named parser, so a result bucket keyed by the caller's spelling never matched. Group keys are now canonical, matching how_count/_sum/_minalready mapped back. -
A compound-unique selector accepted its own name and then refused its members. The selector is registered under both spellings, so
{ org_id_user_id: { org_id, user_id } }matched the selector and was rejected for its contents. Members are matched by resolved column now, and two spellings of one member are refused rather than silently picking one. -
orderByspelled the column way silently disabled the batched loader's per-parentLIMITpushdown, so it fetched every matching child row and sliced client-side: same rows, unbounded bytes over the wire. -
A unique column spelled the column way drew a spurious unlimited-read warning, because the "is this query pinned to one row?" check did not count it as pinning.
-
Relation names were rejected in their snake_case spelling in
with(E005), relation filters (E003),orderBy(E005),_count, and nestedwithat any depth. Accepted now on the SQL engines and on PowDB alike.
Behaviour changes
Two of the fixes above change results for code that ran without error before, so they are called out separately:
- A cursor-paginated query mixing the two spellings returned the wrong page and now returns the right one.
select/omitnaming a correlation key by its column spelling underrelationLoadStrategy: 'batched'dropped that column from the rows and now returns it.
Both are Stable-surface behaviour changes, so per STABILITY.md the 1.0 Stable-surface freeze clock does not start with this release.
Performance
Streaming was Turbine's one remaining loss to Drizzle. It is now a win.
The zone-less and offset temporal read parsers gained a fast path for the
canonical ISO wire shape, scanning it directly instead of running a regex and
assembling through setUTCFullYear. Anything that is not exactly that shape,
a five-digit year, a two-digit year, BC, infinity, a colon-less offset, is
declined and delegated to the parser it replaced, so no value changes
meaning. That agreement is a differential test against the general parser as
running code, not a transcription of it.
Measured in one process on one build with the arms rotating inside every round,
because the change lives entirely in pg.types and can be swapped between two
calls. A negative control (the same profile measured twice under two names)
landed 1.9% apart, which is what makes the paired deltas reportable. Full
method and controls in benchmarks/RESULTS-0.72.0.md.
| 50K-row drain | before | after |
|---|---|---|
findManyStream (yields rows) |
52.44 ms | 37.06 ms (+28.6%) |
findManyStreamBatches (yields T[]) |
47.43 ms | 31.65 ms (+34.9%) |
| Drizzle 0.45.2, same rotation | 42.09 ms |
Absolute numbers are not comparable to RESULTS-0.71.0.md, which is why the
Drizzle arm is re-measured here rather than read off that file. No other
scenario was re-run, so the rest of the 0.71.0 figures stand as published,
caveats included.
Internal
- The coverage gate was measuring an artifact, and had been for a long time.
c8 merges every test process's V8 coverage into one blob before converting to
istanbul, and that merge is not monotonic: adding a process's coverage can
lower the merged result. The proof is that a 448-test subset of this suite
reported
query/relations.tsat 91.88% while the full 5,819-test superset reported 41.78%, and a superset cannot cover less than its subset. Merging at the istanbul level instead, where the merge is additive, real coverage is 96.11% lines / 91.70% branches / 81.88% functions, against floors that had been set at 75/85/59. Floors re-baselined to 93/89/78 with the measurement and the reason recorded in.c8rc.json. Every past note blamingrelations.ts/builder.ts/where.tsfor low coverage was the artifact talking; they read 95.84 / 97.29 / 98.37.
0.71.0 (2026-08-15)
A performance release, and the honest version of that sentence is that the work was mostly finding out where the time was not going.
Turbine's nested reads have lost to Drizzle for several releases. The
assumption was that the row parser was slow, and the fix everyone expected was
a JIT-compiled mapper like the one Drizzle ships in its 1.0 release candidate.
Profiling says the opposite: Turbine's mapping is already the faster of the
two, 0.369 ms against 0.664 ms on the L2 shape. The gap was 59% server time,
and almost all of that was PostgreSQL building JSON. json_build_object
repeats every key on every row and json_build_array does not.
Turbine has shipped that encoding since 0.26 as jsonEncoding: 'positional'.
It is now the PostgreSQL default. Against Drizzle 0.45.2, Turbine goes from
seven of ten scenarios to nine of ten, and geometric mean from 1.079x to
1.025x. No row changes.
Three behaviour changes to Stable surfaces are below. Per STABILITY.md, the 1.0 Stable-surface freeze clock therefore does not start with this release.
Behaviour changes
-
jsonEncodingnow defaults to'positional'on PostgreSQL (it was'object', and stays'object'on every other engine).TurbineConfigfields are a Stable surface, so a changed default belongs here even though no API moved.What actually changes for you: the emitted SQL. Relation subqueries build
json_build_arrayinstead ofjson_build_object. Rows are byte-identical, verified byJSON.stringifyequality including key order, and the value-fidelity::textcasts are untouched because only the JSON keys are dropped, never the expressions. If you log SQL or read raw JSON in a debugger, it is now positional and less readable. PassjsonEncoding: 'object'per query, or set it on the client, to get the old shape back.The default is derived from
dialect.name === 'postgresql'rather than from a capability flag, deliberately. Every engine dialect is built by spreadingpostgresDialect, so testing whether thebuildJsonArrayhook exists hands positional to all of them.buildSelectWithRelationsalready refuses positional on exactly that dialect predicate, so deriving the default from the same predicate makes it structurally impossible for the default to select an encoding the builder then rejects. -
relationLoadStrategy: 'flatten'now falls back to the default correlated subquery on PostgreSQL unless you also passjsonEncoding: 'object'. A flattened relation emits no JSON at all, so the two are not composed in this version. This is a plan change with no error: rows are identical, and a once-only dev warning names the encoding as the cause and tells you what to pass.'auto'never selectedflatten, so this only affects callers who asked for it by name. -
turbine doctorrefuses to run through a connection pooler, before it opens a connection.doctoris a Stable CLI command, so if you point it at a PgBouncer, a Neon-poolerendpoint, or Supabase's pooler in CI, that job now exits 1. Pass--allow-poolerto restore the old behaviour, or better, use the direct endpoint.The reason is not stylistic. A transaction pooler multiplexes many clients onto a few shared server backends and reuses one the moment a transaction ends, so session state set by one client can be left in force for another.
doctoralso readspg_prepared_statementsto confirm a cached plan, and through a pooler that view describes whichever backend answered rather than your application. Detection matches whole hostname tokens (pooler,pgbouncer, numbered variants) and ports 6543 and 6432, so a database namedpoolersor a hostspooler.internalis not caught.
Added
-
findManyStreamBatches, a streaming method that yieldsT[]instead ofT.findManyStreamyields one row at a time, which over a 50,000 row drain is 50,000 promise resolutions and microtask turns, measured at 7.4 ms, about 139 ns per row. Both share one parser-selection site and one database path, so their flatten, encoding and PII decisions cannot diverge.findManyStreamis unchanged, including its laziness: it deliberately does not delegate to the batch method, because that would parse a whole batch ahead of the consumer and make an earlybreakpay for rows nobody asked for. -
jsonEncodingas a per-query argument onfindMany,findUnique,findFirstand the stream methods, so a single query can opt back to object encoding for debuggability or to useflatten. An unrecognized value throwsTURBINE_E003rather than falling back, because a silently ignored encoding option is invisible: you would get correct rows, no saving, and no signal. -
compile_query, an eleventh read-only MCP tool. It compiles a read query to the exact SQL it would send without executing it, which is possible because Turbine already separates build from execute: everybuild*()returns aDeferredQueryand nothing runs until it is executed. It reports the bound parameters, how many statements the query costs, the relation load strategy it takes, whether it is bounded by aLIMITor reads every row, and relation probes no index serves. A query that fails to compile is a successful answer carryingTURBINE_E003/TURBINE_E005and how to fix it, which is the most useful outcome when an agent is still writing the query. Read operations only.
Fixed
-
turbine doctorissued a session-levelSET statement_timeouton a pool it built from a connection string, in bothindex-stats.tsandplan-flip-probe.ts. Through a pooler that attaches to a shared backend that is not reset on release. The timeout is now a connection startup parameter in one case and transaction-localset_config(..., true)in the other. (SET LOCAL x = $1is a Postgres syntax error, which is whyset_configis the parameterizable form.) -
findManyStreamBatchesran on the primary with read replicas configured, becauseREAD_OPERATIONSis a hand-maintained list and a missing entry fails silently: the query works, it just does not use the replica. It also had noPowqlInterfacestub, so on PowDB it wasundefinedand callers gotTypeError: not a functioninstead ofTURBINE_E017. Both lists are now checked mechanically againstQueryInterface's read surface. -
src/query/builder.tscontained a literal NUL byte as a cache-key delimiter, which madegrepclassify the repo's largest file as binary and report zero matches for terms that were present. Written as an escape now. Two benchmark files had picked up the same habit.
Performance
Measured on PostgreSQL 17.9 over a Unix socket, 200 rounds, arm order rotated
per round, median of three runs, every run gated on an idle machine. Full
numbers and method in benchmarks/RESULTS-0.71.0.md.
| vs fastest ORM | record | |
|---|---|---|
| Turbine 0.71.0 | 1.025x | 10/10 vs Prisma, 9/10 vs Drizzle |
| Drizzle 0.45.2 | 1.498x | |
| Prisma 7.9.1 | 2.072x |
Two caveats stated where they are quoted. The L2 and L3 wins are contested:
their margins sit inside the harness drift floor, and the contiguous
cross-check disagreed with itself across runs of the same configuration. And
the overhead figure over raw pg is published as 1.08x, not the 1.00x the
harness records unadjusted, because the raw control still uses
json_build_object while Turbine no longer does, which is worth 1.83x on its
own. Streaming remains Turbine's one loss to Drizzle.
0.70.0 (2026-08-15)
An agent-first release. Turbine's MCP server grows from six read-only tools to
ten, aimed at the thing an agent actually struggles with on an unfamiliar
schema: working out how two tables connect. dependencies becomes literally one
entry. And the index-definition parser that feeds turbine generate was rebuilt
after it turned out to be silently dropping whole classes of index.
Two of those are breaking, and both are type-level or generation-level rather than runtime. Nothing about how a query executes changes in this release.
Breaking
-
@types/pgis no longer a dependency, anddb.poolis typed by Turbine's own driver contract instead of bypg.Pool. Rootdependenciesis now exactly{ pg }, one entry, which is what "one dependency" has always meant here and what it now also literally says.The types package could not simply be moved. It sat in
dependenciesbecause the published declarations namedpg.Pool/pg.PoolClient/pg.QueryResult, andpgships no declarations of its own, so those names made a types-only package a hard requirement for everyone compiling understrict. Moving it without clearing the surface first IS the v0.28.1 regression, which passed every gate in this repo and broke strangers anyway, because this repo typechecks against its owndevDependenciesand they do not. The order is one-way: clear the declaration surface, then move the dependency.src/pg-types.tsis the cleared surface, a zero-import leaf declaring thePgCompat*interfaces natively.Those interfaces are not new.
PgCompatPool/PgCompatPoolClient/PgCompatQueryResulthave backed the external-pool seam since the serverless binding shipped, and were already exported from the package root. What changed is thatTurbineClient.poolnow says so. It was declaredpg.Pooland assignedconfig.pool as unknown as pg.Pool, a cast that was simply false whenever an external pool (Neon, Vercel, Cloudflare Hyperdrive) or a non-Postgres engine was behind it: the property claimed members the object did not have, and the compiler had been told not to look. The new type is the truth, and the truth is narrower, so five call shapes that used to compile no longer do.What breaks Why One-line fix db.pool.on('error', h)on?is optional; an HTTP driver has no event emitterdb.pool.on?.('error', h)db.pool.totalCountnow number | undefined; HTTP pools expose no statsdb.pool.totalCount ?? 0result.fields[0].namefields?is optional; only describing drivers report itresult.fields?.[0]?.nameresult.commandnow string | undefined; it is a pg-family command tagresult.command ?? ''pool.query({ text, values })the object form is not on the contract pool.query(text, values)The same narrowing applies to the
transaction()callback parameter, socopyFrom/copyTo, cursors,escapeIdentifier/escapeLiteralandsetTypeParser/getTypeParserare no longer on it. If you genuinely hold a realpg.Pooland want all of it back, assert once where you take it rather than at each use:import type { Pool } from 'pg'; const pool = db.pool as unknown as Pool; pool.on('error', handler);That assertion is now yours to make, which is the point: it is only sound when you know the pool is a real
pg.Pool, and Turbine cannot know that. An application that never touchesdb.pooland never reads.fieldsor.commandoff a raw result compiles unchanged.pgremains a real runtime dependency; this is the type surface only, anddist/pg-types.jsisexport {};so no emitted JavaScript changed.One second-order effect worth naming, because it is invisible until it bites:
@types/pgdepends on@types/node, so while it was a runtime dependency, installing Turbine put@types/nodeinto yournode_modules/@typesas a side effect. That no longer happens. Any TypeScript project already declares@types/nodeitself, so in practice this changes nothing, but if you were leaning on the accident, add it as a devDependency. Our own CJS consumer fixture was leaning on it, which is how we found out: it failed on the first real tag push, before publishing.Guarded so it cannot regress:
check:package-typesscans every published.d.tsfor a reference to the wholepgfamily (including deep specifiers likepg/lib/resultandpg-protocol), self-tests its own matcher before scanning, and refuses to pass on a zero-file scan. It runs inprepublishOnly, in CI, and in the release workflow, alongside a job that installs the real tarball into a project withpgbut without@types/pgand typechecks it understrictwithskipLibCheck: false. -
A junction table whose only two-column UNIQUE index is PARTIAL no longer produces an automatic
manyToMany.UNIQUE (post_id, tag_id) WHERE deleted_at IS NULLconstrains the rows matching the predicate and nothing else, so the pair can repeat freely across the table, and a relation derived from it can return the same child twice. The hasOne path already refused partial indexes for exactly this reason; the junction path was the one place that did not read the flag, so the two cardinality paths disagreed about whether the same index proved anything.If you relied on such a relation, declare it explicitly in a code-first schema (
defineSchema), which is the supported way to describe a junction Turbine cannot verify for itself. A junction with a real primary key, a table-wide unique constraint, or a full unique index is unaffected.
Added
- Four new read-only MCP tools, taking
npx turbine mcpfrom six to ten. Every one of them runs inside the sameBEGIN READ ONLYtransaction as the rest, so an agent still cannot mutate anything through this server, and PII-tagged values are still redacted before they reach a model.relation_graphreturns the whole relation graph, or one table's subtree, with cardinality, keys, and the junction table for many-to-many. Tables past the depth limit are named inomittedBeyondDepth, so "not expanded" cannot be misread as "nothing there".find_join_pathanswers "how do I get fromcommentstoorgs" with the relation chain and thewithclause to write. It returns every equal-shortest path rather than picking one, treats a many-to-many as a single hop with the junction reported but never in the emitted query, and answersfound: falseinstead of throwing when there is no path.table_statsreturns the planner's row estimate, on-disk size, and indexes. A never-analyzed table reportsestimatedRows: nullwithanalyzed: false, never0, because "unknown" and "empty" are different facts and an agent acts differently on each.explain_errormaps a Turbine error code to its cause, fix, and docs link. It opens no connection at all, so it still answers while the database is unreachable, which is the usual situation for aTURBINE_E004.
Fixed
-
turbine mcpcould disclose a stored value through an index's reported columns. The column extractor used a greedy match that ran from the key list's opening parenthesis to the last closing one in the definition, so on a partial index it swallowed the predicate:CREATE INDEX ... (name) WHERE (email = '...')reported a "column" containing the literal. The definition itself was already being withheld, so this was the one field carrying the value past the redaction. Reported columns are now cut at the key list, and a definition the parser cannot read is withheld rather than passed through. -
The MCP server and
turbine generatedisagreed about which relations a schema has.cli/mcp.tscarried its own weaker copy of the index-key parser: onUSING btree (id) INCLUDE (email)the copy answered['id) INCLUDE (email']where introspection answered['id']. Those columns feed junction detection, so an index could be visible to one surface and invisible to the other, and an agent reading the schema over MCP was told about a different set of relations than the generated client has. Both callers now use one parser. -
Index keys carrying a modifier were dropped entirely. A
pg_indexeskey entry is{ column | (expression) } [COLLATE c] [opclass] [ASC|DESC] [NULLS FIRST|LAST], and the old parser stripped exactly one of those, a trailingASCorDESC. Everything else was kept, soemail COLLATE "C" text_pattern_ops,email text_pattern_opsandid NULLS FIRSTwere all returned verbatim as column names. No table has a column called that, so the index was silently invisible to every consumer of index metadata. The parser is now a scanner rather than a regex, tracking paren depth, quoted identifiers and string literals, so a comma insidecoalesce(a, b)is not a key boundary and an apostrophe inside a column named"it's"does not swallow the rest of the definition. An unterminated key list returns nothing rather than everything: the one input it cannot read must not be the one it forwards.This does not change relation cardinality. hasOne-vs-hasMany is decided by a separate function that this release does not touch, and its output was verified unchanged across every index shape. What can change is where the newly-visible index was previously missing:
turbine doctorretires a missing-index finding for a column an opclass'd index already covers (and--fixstops proposing a duplicate), and a compound-unique selector that previously came out with spaces in its name, unusable by any caller, now comes out correct.
Internal
npm run lintnow fails on warnings rather than exiting 0 with them.TURBINE_REQUIRE_ENGINEmakes an engine test suite's skip gate throw instead of skip, and is set on each engine's CI job. A container that came up but rejected credentials, or a mistypedMYSQL_URL, previously left the job green with the entire suite silently skipped.check:skip-gatesfails the build if a skip reason matches no known engine pattern, so rewording one cannot quietly disarm the guard.check-private-termsgained an--allmode and now runs in CI. It read the staged diff, so in a CI checkout it would have scanned zero files and passed vacuously. Both it andcheck-no-pg-typesnow refuse to pass on a zero-file scan.- Studio's embedded UI had an unused
htmlproperty on its DOM helper that assignedinnerHTML. No caller used it; it is gone rather than left as the one unescaped path in a UI that renders database contents.
0.67.0 (2026-08-10)
A streaming and row-decoding release, plus the benchmark suite that found the
work. Two of the three named parts of the streaming gap are closed, parseRow
stops recomputing the same answer once per row, and the suite that measured all
of it grew from 10 scenarios to 24 with a latency axis attached. The additions
paid for themselves immediately: they surfaced a loss the old set could not see
(deep pagination hitting PostgreSQL's generic-plan cliff) and inverted a
strategy recommendation that was only true on a Unix socket.
Fixed
findManyStreamgot slower asbatchSizegot bigger. It opens with a speculativeLIMIT batchSize + 1so a drain that fits in one batch never pays for BEGIN / DECLARE / CLOSE / COMMIT. When the drain does NOT fit, those rows cannot be reused: they were read outside the cursor's transaction, so yielding them and continuing from the cursor would splice two snapshots together, and resuming past them would need anORDER BYthe caller never asked for. So the cursor re-read from row one and the speculative fetch had costbatchSize + 1rows for nothing. The waste was therefore proportional to the one number a caller raises when they expect MORE rows, and streaming was the only operation in the benchmark suite that got slower as its batch size went up: measured over 50,000 rows, 56.98 ms atbatchSize: 1000and 59.55 ms at 5000, while every other arm got faster. The speculation is now bounded BYbatchSizerather than scaled by it. At or below the default (1000) nothing changes; above it, a caller asking for large batches has said not to expect a one-batch result, and the cursor is used directly. The cost of being wrong about that is four round trips on a drain that would have fit, never any transferred rows.
Performance
parseRowresolves its column mapping once per result set instead of once per row. It was recomputing the same answers for every row: a reverse-map lookup and two Set membership tests per column, plus a Map lookup per row for the camelCase date-field set. Every row of one result set has the same columns in the same order, so this is now a cached per-(table, column-shape) plan. The plan is VERIFIED against each row's key list rather than assumed, column by column, because applying a plan to a column list it does not describe would drop one column from the output and return another asundefined, silently; the comparison is a pointer compare per column against the driver's own interned column names, which is cheaper than the work it replaces. Measured ~25% offparseRowin isolation. It is on every read path, so it shows up wherever a result set is large: combined with the streaming fix above, a 50,000-rowfindManyStreamdrops from 56.98 ms to 52.49 ms atbatchSize: 1000and from 59.55 ms to 50.25 ms at 5000, against a hand-writtenDECLARE CURSOR/FETCHcontrol measuring 43.37 ms and 40.53 ms in the same runs. Output is unchanged, including each row's own key order.
Benchmarks
- The suite went from 10 scenarios to 24, and the additions changed two
published conclusions. The old set was skewed in a way worth naming: three
of its ten scenarios were
findUnique, its largest non-stream result was 100 rows, and it measured no write other than one atomic increment. That flatters small hot reads, which is not where applications spend their time. The newbenchmarks/bench-extended.tsadds large reads (5,000 and 50,000 rows, and a deliberately 39-column table), writes (createManyat 1,000 rows, update, upsert, delete), to-one and many-to-many relations, relation_count, relation filters,groupBywithhaving, and pagination at both a shallow and a deep offset. Turbine leads 17 of the 24. Both harnesses now share one extractedbench-harness.ts, so the interleaving, arm rotation, median handling and drift probe cannot drift apart between them. - A loss the old suite could not see: deep pagination. A filtered, sorted
20-row page costs 0.212 ms at offset 40 and 0.549 ms at offset 9,000, where
Prisma costs 0.318 and Drizzle 0.341. The cause is not Turbine's SQL: it is
that Turbine NAMES its prepared statements, which is what wins the repeated
hot reads, and PostgreSQL promotes a named statement to a value-blind generic
plan on its sixth execution, at which point a bound
OFFSETis invisible to the planner. The raw-pgcontrol settles it, since the same hand-written query costs 0.541 ms named and 0.272 ms unnamed.forceCustomPlan: true(shipped in 0.56.0) takes that query to 0.292 ms. Documented on the benchmarks page and in theplanCacheModesection of the queries docs. - A cross-latency axis, which inverted a recommendation the socket-only
numbers would have produced.
benchmarks/bench-latency.tsforwards to the same PostgreSQL socket through an in-process TCP proxy that delays each chunk by RTT/2, and reruns a subset at 0, 1, 5 and 25 ms. On a socketrelationLoadStrategy: 'batched'is the fastest nested strategy by 1.34x; by 1 ms of round-trip time it is already slower than the join, and at 25 ms it is 1.83x slower, because it buys its win with an extra round trip per relation. That is the behaviour'auto'already had, andautoRoundTripMsis the dial for it, but the published numbers had no way to show it. In the other direction,pipeline's advantage over a transaction grows from 2.7x to 6.9x as the link lengthens, and every point read converges to within 0.3 ms of rawpgat 25 ms. The proxy models latency only, not bandwidth or jitter. - The nested-read gap is root-caused. Roughly 34% of an L2
json_aggpayload is repeated JSON key strings (153.8 KB against 102.1 KB withjsonEncoding: 'positional'), and with encoding held equal the server-side and client-side costs both favour Turbine. Positional encoding wins both nested scenarios. It stays opt-in for now, for the reason stated on the benchmarks page.
0.66.0 (2026-08-09)
The output of a full audit of 0.65.0 across security, performance, migrations,
multi-engine parity and code quality. Almost every defect it found has one
shape: a rule that lives in two places and drifted, or a rule that is
documented and lives in none. The destructive-migration guard had its own SQL
lexer that disagreed with the runner's. DISTINCT ON was gated in one of the
two places that emit it. Vector validation ran on the build path and not the
cache-hit path. Four PowDB relation paths disagreed about whether a projection
keeps the primary key. Each fix below removes a copy rather than syncing one.
Fixed
-
A nested block comment turned off the destructive-migration guard. PostgreSQL nests
/* */; the guard's private lexer ended a block comment at the first*/, and it split statements on a bare;while the runner used a correct tokenizer. Both bugs fail OPEN. The worst case was not a total miss but a PARTIAL inventory: for a file holding a commented-out block that itself contained a comment, aDROP TABLEand aDELETE, the guard listed only theDELETE, so the operator confirmed what they were shown and the unlistedDROP TABLEran under that confirmation. A semicolon inside a quoted identifier (DROP TABLE "we;ird") hid a statement the same way. There is now exactly one tokenizer (src/cli/sql-statements.ts, a pure leaf) and both callers consume it, returning per statement both the verbatim source that executes and the comment-stripped source the rules match against, from a single walk. Three more fail-opens in the same guard were found by scanning for the CLASS rather than re-checking the reported instance, and each was confirmed by executing it:$is legal INSIDE a PostgreSQL identifier, soSELECT x$y$ FROM t;was read as opening a dollar-quoted block and swallowed the rest of the file (the guard reported nothing and the followingDROP TABLEran); the procedural path that exists to catch dynamic SQL was still stripping comments with its own regex, so a'x --'literal earlier in a one-lineDOblock hid every destructive statement after it, reachable by accident rather than by intent; andCREATE FUNCTION ... BEGIN ATOMIC DELETE FROM users; END;split on the wrong semicolons. The$case also defeated the new embedded-transaction refusal, which reads the same tokenizer. -
Destructive SQL assembled at run time was reported as nothing. The rules need a parseable object name and dynamic SQL has none until it runs, so
DO $$ BEGIN EXECUTE format('DROP TABLE %I', 'users'); END $$;(and the'DROP TABLE ' || quote_ident(...)and'DROP ' || 'TABLE users'spellings) dropped the table live while the operator was shown a clean inventory. They are now reported with an explicit unknown target. The trigger is evidence of runtime assembly (||,format(,quote_*(,%I), which is complete rather than a heuristic: a destructive statement whose object name is written out literally already matches a rule, so being dynamic requires concatenating or formatting.RAISE NOTICE 'DROP the mic'and a dynamicSELECTstay silent, because a guard that fires on prose teaches operators to confirm without reading. -
migrate downskipped a migration it could not reverse and kept rolling back the ones underneath it. Both failure branches continued where the SQL failure path correctly stopped. A--step 3where the middle migration had noDOWNsection rolled back the third and then the FIRST, dropping a table whose rows the skipped migration had seeded, and left the tracking table claiming only that migration was applied. Rollback is now strictly LIFO with no gaps. -
Query SHAPE could exhaust the database server. Every distinct SQL shape was parsed as a NAMED prepared statement and never deallocated, so a caller who controlled only the ARITY of a boolean combinator (a
where.ORarray built from a UI multi-select) grew the backend without limit: 600 distinct shapes held 600 statements and 20.9 MB of cached plans on one connection, none of it reclaimed by reuse, and each pooled connection accumulated its own. A where clause containing a caller-writtenAND/ORarray now compiles to an UNNAMED statement, which the driver re-parses per execution and never accumulates. Turbine's own synthesized wrappers stay named, so a table with a global filter is unaffected.in: [...]was never affected: it binds one parameter regardless of list length. -
findMany({ distinct })emitted PostgreSQL-onlyDISTINCT ONon every engine. The other spelling of the same feature,groupBy({ distinctOn }), was gated correctly, so this path had no dialect check at all and reached the driver as a raw syntax error carrying no Turbine code. It now throwsUnsupportedFeatureError(E017) on SQLite, MySQL and SQL Server. -
Composite foreign keys introspected as a cartesian product, and constraint names were treated as globally unique. The FK query joined
key_column_usagetoconstraint_column_usageon constraint name alone, which is an N-by-N cross join, so a two-column foreign key became four AND-ed correlations with two of them mispaired and the relation returned nothing, with no error and no warning. Separately, PostgreSQL only requires constraint-name uniqueness per table, so two tables sharing a name merged: one lost its foreign key entirely and the other pointed at the wrong table. A name collision in a different schema silently deleted relations from the generated client. Both are one query now, readingpg_constraintand pairing columns positionally withunnest(conkey) WITH ORDINALITY, keyed on the constraint OID. Referential actions rode the same collision and are fixed with it. Generated output is byte-identical for schemas that were already correct. -
On MySQL, a
jsoncolumn's runtime type changed when you added alimit. The join strategy embeds the column inJSON_OBJECTso it arrives parsed, while the batched and flatten plans read it directly so it arrives as text, and the defaultautostrategy picks between them.row.org.meta.tierworked on one query and threw on the same query one pagination argument later.jsonnow has a wire rule like the other divergent types, so all four read paths agree. -
The CockroachDB and YugabyteDB migration lock was released by the first migration's
COMMIT. The adapters hold a row lock and deliberately leave its transaction open, but the runner applied every migration on the same connection and committed per file, so migrations two onward ran unprotected and a concurrentturbine migratecould take the lock and replay them. The failure was silent, and a second face of it made a-- turbine:no-transactionmigration fail or succeed depending on its position in the batch. The lock is now held on a dedicated connection. Serialization failures are treated as contention rather than crashing with a raw driver error. The regression test that previously passed BECAUSE of the bug has been rewritten. -
Studio and the MCP server leaked the ordering of redacted columns. The PII predicate guard recursed into
AND/OR/NOTand relation keys, but a pick-row relation ordering puts column names two levels deeper underpick.orderBy,pick.whereandby, none of which were visited, so a read-only session with redaction on could recover a hidden column's full ordering and run a character-by-characterstartsWithprobe against it. A column reference ({ gt: { col: 'secret' } }) was a second channel through the operand rather than the key. Studio and MCP now share one guard (src/cli/pii-predicate-guard.ts) that FAILS CLOSED on any unrecognized structured shape, so the next ordering form is refused rather than waved through. -
A per-relation
limitunder the batched strategy fetched every matching child. Measured at 200 parents with about 505 children each, the batched plan pulled 101,000 rows over the wire to keep 600, at 52.9 MB of peak heap against the join plan's 0.5 MB. Reachable without opting in, becauseautoroutes provably unindexed relations to batched. PostgreSQL now bounds it server-side withROW_NUMBER() OVER (PARTITION BY ...); other engines keep the client-side slice. The unlimited path, where batched legitimately beats join, emits byte-identical SQL. -
Vector distance thresholds were validated on the build path only, so a warm SQL cache under
NODE_ENV=productionacceptedNaN, a string, or an object. PostgreSQL sorts NaN above everything, sodistance < NaNmatched every row: the opposite of the intent, silently, and only in production. -
turbine migrate create <name>wrote raw argv into the migration body. The name was sanitized for the filename only, and the header sits above the-- UPmarker, so a newline-bearing name injected executable migration SQL and could set-- turbine:no-transactionto defeat the transaction wrapper. -
Connection strings with passwords were echoed in errors on MySQL, SQL Server and PowDB, contradicting
SECURITY.md. The trigger was a MALFORMED DSN, which is exactly when someone pastes an error into a bug report. -
errorMessages: 'safe'still leaked row values on MySQL and SQL Server, because redaction hooked on adetailfield those drivers never set while they put the value inmessage,sqlMessageandstack. The mode was also a process-global that the last constructed client won, so a second client silently downgraded the first. It is now per-client. -
The where and having walkers had no depth cap, so a 64 KB request body (under
express.json()'s default limit) raised aRangeErrorrather than a typed error, bypassing the error surface entirely. -
ALTER COLUMN ... TYPEemitted an explicitUSINGcast for narrowing conversions, converting PostgreSQL's own refusal into silent truncation: a 25-character value became 10. Same-family narrowing now emits the plainALTER, so PostgreSQL's assignment cast decides. For a shortenedvarcharthat means the migration is refused rather than truncating. Numeric and temporal narrowing still lose precision, because PostgreSQL's assignment cast rounds and truncates rather than refusing; the destructive gate now names the specific loss for each instead of the generic "cast may truncate or fail". -
schemaDiffhardcoded thepublicschema in all eight catalog reads while every other command honored--schema, so against a dedicated schema it reported every table missing and emitted duplicates intopublic. Worse, with a legacy copy inpublicit wouldALTERandDROP COLUMNthe wrong table.schemaPushnow also pins a transaction-localsearch_path. -
Column length and numeric precision changes were invisible to the diff, which compared
udt_namealone, sopushreported "already in sync" for a schema that genuinely differed. -
doctor --fixwrote an index migration that could never succeed.CREATE INDEX CONCURRENTLY IF NOT EXISTSmatches on name, not validity, so over the INVALID index left by a failed concurrent build it silently no-oped while recording the migration as applied. -
Prisma
@map(name: "...")was ignored on fields, though the model-level handler accepted both forms. The column name was discarded and the field name used instead, which in a legacy database is frequently a real column, so every read returned another column's data and every write landed in the wrong one, with a clean report. A@relation(fields: [...])matching no database foreign key also fell through to "take the only candidate" instead of reporting disagreement. -
SQLite's JSON
containsnever matched anything. The reported symptom was that object operands degraded; measurement showed scalars failed too, because the parameter is bound as JSON text whilejson_each.valueyields the decoded value. It is now refused with E017 rather than silently returning fewer rows. -
A
BOOLEANcolumn typedbooleanreturned1/0on SQLite and MySQL, including a value Turbine itself had written astrue._min/_maxon a date column returned a raw string on SQLite whilefindManyandgroupByreturned aDate. SQL Server'sLIKEtreats[as a wildcard, socontains: '[draft]'matched any title containing d, r, a, f or t. -
PowDB returned the primary key through
omit: { id: true }, and its four relation paths disagreed about whether a projection keeps it. Also fixed: unqualified PowQL column references were emitted unquoted (narrowly, so keyword columns stay bare for pre-0.10 compatibility). -
PipelineError(E014) was defined, documented, and never constructed, soerr instanceof PipelineErrorwas permanently false and the documented example could not work. The non-transactional path also now runs every query and reports per slot, as its own option already promised. -
Migration files containing their own
BEGIN;/COMMIT;broke the runner's wrapper and left the migration half-applied and unrecorded; they are now refused, and the shipped--recipe backfillscaffold no longer teaches the pattern. Section markers accept--DOWNand other loose spellings instead of folding the rollback into the UP. The tracking table tolerates the concurrent-create race that crashed 11 of 12 parallelmigrate statuscalls.schemaPush's bareROLLBACKno longer replaces the error it is unwinding. The destructive rule set gainedDROP TYPE|DOMAIN|EXTENSION|SEQUENCE|FUNCTION ... CASCADE,DETACH PARTITION,EXPLAIN ANALYZE <DML>(which executes) and arenamekind, and two quoted-identifier false positives are gone.
Added
src/cli/sql-statements.tsandsrc/cli/pii-predicate-guard.ts, both pure leaves shared by two callers, each with its own coverage floor.check-error-codesnow asserts the REVERSE direction: every error code must be constructed somewhere outside the tests. That is what found E014.- Redundant sort terms are dropped with a dev warning, and long sort lists lose
their prepared-statement name, which closes the same unbounded-shape vector
through
orderBy,distinctanddistinctOn. - CI's PostgreSQL services now use
pgvector/pgvector, so the pgvector suite executes for the first time instead of skipping itself, and the fixture carries GIN expression indexes so full-text search has live coverage.
Changed
-
Non-transactional pipelines reject with
PipelineError(E014) rather than the raw driver error..resultskeeps its shape and the driver error is.cause. -
migrate upnow asks for confirmation on rename-only migrations, and refuses a migration file with no recognized-- UPmarker. -
Studio and MCP refuse some previously-accepted structured query shapes. Over-refusing is the intended direction for a redaction boundary.
-
The CockroachDB and YugabyteDB rows in the compatibility matrix now read Experimental, matching
STABILITY.md. Their inherited PostgreSQL capability flags are documented for the first time. -
The main entry's import graph grew by roughly 5 kB brotli, and
.size-limit.jswas re-baselined to match, with the measurement and the reasoning recorded in the file. The growth is the new shared leaves plus the widened guards; it is not a new dependency, and the runtime dependency set is unchanged (pg). -
Benchmarks re-run and republished. The README and site had been quoting a 2026-07-25 measurement of 0.50.0, sixteen releases stale.
RESULTS-0.66.0.mdis a fresh three-run measurement on the same hardware, PostgreSQL and fixture, with the competitor versions deliberately pinned to the previous run's so the two are comparable. Turbine runs at 1.09x hand-writtenpg(Drizzle 1.49x, Prisma 1.86x) and is 1.82x faster than Prisma 7.9 / 1.32x faster than Drizzle 0.45 by geometric mean. Two things are now disclosed that were not before: the two harnesses disagree on two scenarios rather than one, so L2 nested reads and atomic increment are recorded as contested rather than as wins; and the L2 gap to Drizzle widened since the 0.50.0 run (1.21x to 1.32x) for reasons this release does not explain. A direct interleaved A/B of 0.65.0 against 0.66.0 on the same database rules out this release as the cause: 0.66.0 measured equal or marginally faster on every shape, including the two the prepared-statement and projection changes could plausibly have slowed.
Upgrading
Most of this release is a bug fix you do not have to act on. Four changes can be noticed, and all four are noticed at BUILD or DEPLOY time rather than in production, which is deliberate.
A migration file may no longer contain its own transaction control. If any
.sql migration in your repo contains BEGIN, START TRANSACTION, COMMIT,
ROLLBACK or ABORT outside a comment or a string, turbine migrate now
refuses it before running anything. This is the fix, not an inconvenience: the
runner already wraps each file in exactly one transaction, so an embedded
COMMIT ended that wrapper mid-file, made everything before it durable, ran
everything after it unprotected, and recorded the migration nowhere, which left
reruns failing forever on "already exists". Delete your own BEGIN/COMMIT
(the statements between them are still atomic, the runner supplies the
transaction), or put -- turbine:no-transaction in the file header if the
migration genuinely cannot run inside one, such as CREATE INDEX CONCURRENTLY.
ROLLBACK TO SAVEPOINT is still allowed: it leaves the wrapping transaction
open.
A rename-only migration now prompts. RENAME COLUMN and RENAME TO joined
the destructive rule set, so a migration containing nothing but renames now
requires the two-step typed confirmation. If such a migration runs unattended
in CI, it will block until you add --allow-destructive (or use
turbine migrate deploy, which is the no-prompt form). Worth checking before
your next pipeline run rather than during it.
Relation names are now derived in a deterministic order. Foreign keys used
to be walked in creation order (pg_constraint.oid), so a database restored
from a dump and one built from migrations could produce different relation
names and even different CARDINALITIES for the same logical schema, which meant
with: { profile: true } could read a different table depending on how the
database was built. The walk is now ordered by (source table, constraint name).
Two consequences on your next turbine generate: the relation key order in
metadata.ts may shift once, a cosmetic one-time diff, and in the rare case
where two relations competed for the same name, the winner can change. Read the
generated diff rather than skimming it. Partition clones no longer produce
phantom relations either, so one declared foreign key to a partitioned table now
yields one relation instead of one per partition.
A migration that builds destructive SQL at run time now prompts. If a
DO block or function body assembles a DROP / TRUNCATE / DELETE by
concatenation or format(), it appears in the confirmation inventory as
<name assembled at run time> rather than not appearing at all. Same
operational consequence as the rename change above: add --allow-destructive
or use turbine migrate deploy if such a migration runs unattended. Statements
with a literal object name are unaffected and still name their target exactly.
A select projection emits its columns in the table's own order. The
column list is now canonical rather than the order the caller wrote the keys in,
so the KEY INSERTION ORDER of a projected row object changed. Nothing reads a
projection positionally, and omit already worked this way, which is exactly
why omit never had the bug: caller-chosen key ORDER minted a distinct,
permanently cached, server-side prepared statement per permutation (measured at
5,040 statements and 39 MB from one seven-column table, reachable from an
ordinary PATCH body with no arrays and no opt-in). Only code that depends on
Object.keys(row) ordering, or that hashes JSON.stringify(row), will notice.
Write data was canonicalized for the same reason and is not observable at all.
Some previously-accepted query shapes are now refused. findMany({ distinct })
throws UnsupportedFeatureError (TURBINE_E017) on SQLite, MySQL and SQL
Server instead of emitting DISTINCT ON for the driver to reject with an
untyped parse error. Studio and MCP refuse more structured argument shapes than
before. In both cases the refusal replaces something that was already failing,
just less legibly.
0.65.0 (2026-08-02)
A hardening release built around one new instrument: a seeded differential fuzz suite that runs the same randomly generated query through the join and batched relation strategies and demands they agree, both about the rows and about whether the query is valid at all. It found three real bugs in its first minutes of existence; all three are fixed below. The release also adds error-docs links to every error, closes the last unguarded release path, and tightens CI.
Fixed
-
offsetwithoutlimitwas a syntax error on SQLite and MySQL. The shared SQL path emits the Postgres shape, a bareOFFSET $n, but SQLite's grammar only allowsOFFSETafter aLIMITand MySQL has no bareOFFSETat all, sofindMany({ offset: 10 })threw a driver syntax error on both engines. Each dialect now supplies its documented idiom (LIMIT -1on SQLite,LIMIT 18446744073709551615on MySQL); every other pagination shape emits byte-identically to before. Found by the fuzz suite on its first run. -
A query's validity could depend on how many rows it matched. The batched relation loader returned early when the base query matched nothing, BEFORE validating the
withtree, so an unknown relation or a misspelled relationselectthrew on populated data and passed silently on empty data. The join strategy validates the whole statement at compile time regardless, andrelationLoadStrategy: 'auto'picks between the two on index coverage and table size, so the same program could throw in production and pass in a fresh test database. The loader now walks the wholewithtree even with zero parents: each level compiles its child query (one SQL string build, nothing executes), so acceptance is decided by the query alone. The same rule now holds on afindUniquemiss. One disclosed consequence: a composite-key relation under an explicitrelationLoadStrategy: 'batched'now throws its documented E017 even when the base query matches no rows (it already threw on any non-empty result;'auto'never demotes composite-key relations, so the default is unaffected). -
An unknown relation in
withthrew E003 under the batched strategy and E005 under join. E005 (RelationError) is the documented code for an unknown relation; the batched loader now throws it too, with the same message. Before this, an error handler catchingRelationErrorworked or missed depending on which plan the'auto'heuristic picked. -
selectandomittogether are now refused (E003) instead of silently half-applied. When both were passed, the SQL engines ignored theomithalf entirely, without even validating its names, so a typo there passed while the same typo alone threw; PowDB meanwhile APPLIED select-minus-omit, so one query projected different columns per backend. The pair is ambiguous (a narrowed projection minus fields), Prisma refuses it, and prisma-compat here already refused it; now every engine refuses it with the same message. Studio's builder chips enforce the rule live (picking aselectchip switches modes and clearsomit, and vice versa), and a saved query from before the rule loads withselectwinning.A
selectthat names no fields (empty, or every valuefalse) is refused the same way. It used to resolve to an empty column list, which emitted invalid SQL at the top level and quietly returned[{}]rows inside a relation. Both refusals are decided on the args as written, before the batched loader's internal key-forcing adjustments, so the verdict cannot differ between load strategies (an adversarial review of this release caught exactly that flip in the first version of the check, and the fuzz generator now keeps these shapes in its domain permanently).
Added
-
Every error links to its documentation.
TurbineErrorgains adocsUrlproperty (https://turbineorm.dev/errors#e003-style), and every error message is suffixed with that link once, idempotently, so a wrapped and re-wrapped error never accumulates duplicates. The errors page has an anchor per code, and a new sync test fails the build if a code is added without a docs row, or a docs row outlives its code: a missing row is no longer a docs gap but a broken link printed into the user's own logs. -
Differential strategy fuzz suite (
src/test/strategy-fuzz.test.ts). Seeded (mulberry32), runs in the unit lane on fixed seeds against in-memory SQLite with a deliberately skewed fixture, and asserts two properties per generated query: the join and batched strategies accept or reject identically (with the same error code), and when they accept, the rows are deeply equal. About 15% of cases plant one invalid name and assert both strategies refuse it. A nightly job runs 20,000 cases on a date-derived seed so the explored space moves; every failure message carries the seed, case index and full args for local reproduction viaTURBINE_FUZZ_SEED/TURBINE_FUZZ_CASES. -
The release path now runs the DB-backed suite.
prepublishOnlyends with a new gate that, outside CI, runs the fullnpm testagainstDATABASE_URLand refuses to publish when no database is reachable (TURBINE_PUBLISH_WITHOUT_DB=1is the loud emergency escape). A manual local publish is the normal release flow here and was the one path that never executed the generated SQL against a real Postgres; in CI the gate self-skips because the release workflow runs that suite as its own job. -
The module-graph acyclicity rule is now a build failure.
scripts/check-import-cycles.mjsrefuses any static value import ofclient.tsfromsrc/query/**orsrc/cli/**(type-only and dynamic imports stay sanctioned), self-tests its own matcher before scanning, and runs in CI's lint job and inprepublishOnly. The rule previously lived only as prose in the contributor docs. -
Size budgets for the last two unmeasured subpaths.
turbine-orm/cli(1.71 kB) andturbine-orm/adapters(1.13 kB) join.size-limit.js; both budgets guard the same property as prisma-compat's, staying an order of magnitude below the core-graph entries.
Changed
-
Releases create their GitHub Release automatically. The publish job now extracts the version's CHANGELOG section and creates the GitHub Release from it, idempotently (an existing release is left alone, and a re-run can backfill one without republishing). Release runs also wait for each other instead of racing, and a running release is never cancelled (
concurrencywith a single group; GitHub holds at most one further run pending, which fits the one-tag-at-a-time flow here). -
CI hardening. Every job in every workflow now carries an explicit
timeout-minutes(the GitHub default is six hours, so one hung container could previously burn a day of runner budget); a CodeQL workflow scans pushes, PRs and a weekly schedule with the security-extended query pack;npm auditis clean again (brace-expansion, dev-only, lockfile-only bump). -
SECURITY.md rewritten to describe the product that ships. The old file said Studio "is read-only" (write mode shipped in 0.36) and pinned a supported-versions table that had drifted 36 minors; the new file states the actual perimeter (write-mode routes, full-PK-only predicates, Origin checks, PII redaction) and the real support rule: fixes land on the latest minor. STABILITY.md's version examples were re-stamped the same way, and
.docsUrljoined its stable structured fields.
0.64.1 (2026-07-29)
Maintenance. No API change, and the emitted JavaScript and type declarations are byte-identical to 0.64.0 (verified by diffing the whole build across the config change below).
Fixed
-
The
@nextprerelease channel has been broken for nine releases. The nightly workflow sets the version to<current>-next.<sha>and publishes, but the CHANGELOG-heading guard inprepublishOnlydemanded a## <version>heading for exactly that string, which by construction can never exist. So every nightly publish failed and thenextdist-tag sat months behindlatest: anyone who installedturbine-orm@nextgot something far older than they had reason to expect, which is worse than having no prerelease channel at all. A prerelease is now resolved to its base version, which is the entry that actually describes it.The failure was invisible for so long because the only symptom was a red workflow. The guard now has its own regression cases, including one that fails if stripping the suffix ever turns the check off entirely.
-
A plan-cache measurement could fail under a loaded test run. Its buffer counters come from
pg_statio_user_tables, andpg_stat_force_next_flush()flushes only the calling backend, so counts from a neighbouring connection could land inside a measurement window and inflate it. The baseline now waits for the counter to go quiet, and skips with a reason if it never does: a number measured on a moving counter proves nothing, and reporting it as a product failure is how a suite teaches people to ignore its own red.
Changed
- Removed a dev-only warning for an unknown
orderByfield. It printedUnknown orderBy field "x" for table "y". This will cause a runtime error.and then let compilation continue into the code that throws for the same key with a better message, one that names the table, suggests the closest column and lists the valid relations. Every unknown-key shape was measured (plain direction,OrderBySpec, JSON path, both relation-shaped values, array form) and all of them warn and then throw, so nothing loses a signal. What it cost was noise in dev logs and a second copy of the key-resolution rules that could drift from the real one. The refusal itself is now pinned by tests.
Internal
-
TypeScript 7 readiness. The CJS build declared
moduleResolution: "Node"(node10), which TypeScript 6 deprecated and 7 removed outright: on 7 it fails the whole build with TS5108 before reading a file. It now usesbundler, the only resolver that pairs withmodule: CommonJS(the base config'sNodeNextcannot be inherited here, since it reads the root package.json and would emit ESM intodist/cjs). Resolution mode is compile-time only, and the emitted tree is byte-for-byte unchanged.One test also imported the TypeScript compiler API to check that generated code parses. TypeScript 7 moved that API (the package root is now a version stub and the compiler lives under
unstable/*), so the import pinned the repo to TypeScript 6. It drives thetscbinary instead, which is what the sibling generated-output test already did. That check turned out to be passing vacuously even before the switch, since running it from the repo root madetscrefuse the arguments and check nothing; it now asserts thattscactually reached the source.
0.64.0 (2026-07-29)
Breaking, and deliberately so. A select or omit naming a field that does
not exist now throws instead of being quietly ignored. Previously that was true
only at the top level of a query; one level down, inside a relation, the same
key was filtered out and the query ran anyway.
This is the same class as 0.63.0's silent relation, seen from the other side: a projection is a list of NAMES a human typed, and a name that does not resolve has exactly two honest outcomes. It had a third.
Fixed
-
A typo inside a relation's
selectoromitwas silently ignored. Theselectform returned{}rows; theomitform returned the column it was asked to hide.// before 0.64, no error either way with: { posts: { select: { titel: true } } } // -> posts: [{}, {}] with: { posts: { omit: { titel: true } } } // -> title comes backThe
omitdirection is the one worth staring at: a typo in the clause whose entire job is suppression returned the value it was meant to suppress. It is the idiom for a sensitive-but-untagged column (passwordHash,resetToken), so the caller asked for the column to be withheld, got no error, and shipped a response they believed was filtered. -
The two relation-load strategies disagreed about whether such a query was valid at all. The batched loader runs each relation as a real query against the target table, so it went through the strict resolver and threw; the join plan went through the silent one and returned rows. Under the
'auto'default, which plan runs is decided by a cost heuristic reading index coverage and table size, so the same code threw on one table and quietly returned the wrong shape on another, and adding an index could flip it. Same failure mode as the nested_countdisagreement fixed in 0.63.0, and both are now closed the same way: the strategies must never disagree about validity.
Changed
-
A relation named in
selectoromitgets its own error, naming the fix rather than hunting for a misspelling that is not there:[turbine] "comments" is a relation on table "post", not a column, so it cannot be named in `select`. Load it with `with: { comments: true }`, which is a sibling of `select`, not a member of it.This is a habit, not a typo: Prisma nests relations inside
select, so it is the natural first guess coming from there. The generic unknown-field text degraded intoDid you mean "comments" (a relation)?for a name spelled exactly right, which answers a question nobody asked. Applies at every depth and on every engine, PowDB included.
Internal
-
The two projection resolvers are now one function. They existed as
resolveColumns(the query's own table) andresolveTargetColumns(a relation target), doing the same job against different metadata, and kept in step by whoever remembered. That is what produced the split above. Merged into a singleresolveProjectionthat both call, which is the same movewalkWhererepresents for WHERE compilation after the top-level and relation-scoped walkers drifted twice. Two functions that must agree are a standing liability; one function cannot disagree with itself. -
Audited every other place a caller-supplied name is resolved:
where,orderBy(including relation and SQL Server paths),distinct,groupBybykeys, aggregate targets, and create/updatedatakeys. All of them already threw. Projections were the only silent one, and there is now no site in the codebase that filters an unresolvable name out instead of rejecting it.
Upgrading
Nothing to do unless a query names a field that does not exist, in which case
it was already not doing what it said. If an upgrade surfaces a throw, the key
was being ignored before: check whether the intended column is spelled
differently, or whether it is a relation that belongs in with. The error names
both possibilities.
Turbine-native code is usually protected before this ever runs, since a
generated client types select / omit against the model. The paths this
catches are the untyped ones: a projection assembled from request parameters, a
compat layer, or plain JavaScript.
0.63.0 (2026-07-28)
Two silent data-corruption bugs, both of the same shape: a projection narrowed for one purpose removed a column another purpose depended on, and the code that depended on it could not tell "absent" from "legitimately null". Neither threw, neither logged, and both returned a well-formed answer that was wrong.
One was reported against a 119-model application; the other was found while fixing it and is more serious. Both are pinned by regression tests verified to fail against the old code, and a 227-case matrix now enforces that the two relation-load strategies agree on every projection, cardinality and depth.
Fixed
-
A relation nested inside a
select-narrowed relation came backnullor[]under the batched strategy. The batched loader stitches parents to children in JS, so every level needs its correlation key in the rows it was handed. The root call sites resolved that key set from the wholewithclause; the two NESTED call sites passed only the single key that stitches their own level, so a child projection narrowed byselect(or by anomitnaming the FK) dropped the key the next level down was about to correlate on. The loader then saw zero keys and took its legitimate "no parent points anywhere" branch.A reporting endpoint that summed a money column across such a relation returned 0 for a large fraction of a page, with an HTTP 200 and nothing in the payload, the logs or the status to distinguish it from the truth.
It needed three things at once, which is why it survived so long: the batched strategy (which
'auto'selects on its own for an unindexed correlation column, so nobody has to ask for it), aselectoromiton a to-many, and another relation inside it.includeprojects every scalar so the key is there, andjoincorrelates in SQL and never reads a key off a row, so the two shapes people reach for first are both clean. -
A PII-tagged column that is part of the primary key made rows unaddressable, and writing one back mutated every row that shared the rest of the key. Found while fixing the above; worse, because it corrupts on the write path. The PK exemption ("tag sensitive data, not keys, the returned row must stay addressable") was stated in the docs and implemented at exactly one site, the write
RETURNINGlist. Every read projection missed it, andparseWriteRowdeleted the column theRETURNINGlist had deliberately kept.So a row came back without part of its own key. Round-tripping it into an
updateproduced a PARTIAL predicate: the missing member isundefined, which the where compiler drops, and the empty-where guard does not fire because the other member is present. Measured on a composite(org_id, email)PK: three rows rewritten where one was asked for, no error. The exemption now lives inside the two PII helpers, so every projection inherits it and no call site can miss it again. -
PowDB:
omitcould remove the primary key, emptying every many-to-many relation. The same class in the PowDB engine.projectedColumnsforce-adds the PK underselect, then appliedomitafterwards and unconditionally, undoing it. The m2m loader keys its target map on that PK, so every target collapsed onto the string"undefined", no parent matched, and the relation came back[]for every row. A PII-tagged PK was dropped there too, with the same consequence as above.
Changed
-
A PII-tagged primary-key column is now returned by default reads. Visible behaviour change, and the deliberate half of the write-path fix above: a row that cannot address itself is a silent-corruption hazard, and this is the policy the project already documented. Tag sensitive data, not keys.
-
Nested
_countnow throws on every strategy instead of only some. BREAKING for anyone pinned to'batched'and using it._countinside a nestedwithwas refused by the join builder (E005) and accepted by the batched loader, so one query with one set of args either threw or returned populated counts depending purely on which plan ran, and under the'auto'default that is decided by a cost heuristic reading index coverage and table size. The same code therefore worked on a small table and threw on a large one. Both strategies now refuse it, which is the behaviour the codebase already documented in two places. Teaching the join path nested_countis the right end state and is tracked separately: it means the fourjson_build_objectemission sites, the positional encoding, SQL Server'sFOR JSONoverride and PowDB's nested projections all learning it together.
Added
-
The batched loader refuses a relation whose correlation key is missing from every parent row, instead of returning an empty one (E017, naming the relation and the workaround). The two states are distinguishable and were not being distinguished: an unprojected column is ABSENT from the parsed entity, while a selected column holding SQL NULL is PRESENT with the value
null. This is the durable guard, because it fires on the class rather than on any one shape. Both loaders check it, on both the parent and the child side. -
A 227-case strategy-equivalence matrix (
select/omit/ bare x nested to-one / to-many /_countx depth 1-3 x every cardinality x root projection), asserting the join and batched strategies agree on both value and key ORDER, with a per-case precondition that the fixture actually carries data so a case cannot pass vacuously. The documented "byte-for-byte identical output" contract had never been enforced across shapes, which is the reason the first bug reached an application at all.
0.62.1 (2026-07-28)
Fixed
-
SQL Server: a
BIGINTchild key came back as a string under the join strategy. Silently breaking for anyone who was reading that string. Turbine narrows a safeBIGINTto a number on the driver's own rows, so a top-level read and the batched loader both return1. Since 0.51 the join strategy castsBIGINTto text soFOR JSON PATHcannot round it through an IEEE double, and that rule kept the text unconditionally, reproducing the raw driver value rather than the value Turbine hands back. So awithunder the join strategy was the one route returning'1', which means switchingrelationLoadStrategyfor performance silently changed the caller's value types. The decode now re-applies the same safe-integer narrowing, the policy the mysql and sqlite dialects already use for their own 64-bit types; above 2^53 all three keep the string, which is why the text is carried at all.Found by a new cross-engine battery that runs the identical fixture and the identical assertions on every engine, plus a tiebreaker case that compares a child row loaded through a relation against the same row read directly. "The two strategies disagree" does not say which is wrong; the driver's own value for a direct read does.
0.62.0 (2026-07-28)
A hardening sprint off the back of a full product review. The theme is one class of bug: an option the caller set that quietly did nothing, and its mirror, a value the caller asked to be hidden that came back anyway. Nine of these shipped in previous versions. None threw, none logged, and most returned a plausible answer, which is why the suite did not have them.
Every fix here was reproduced against the old code first, and every one is pinned by a regression test that was checked to FAIL before the fix and pass after. Two rounds of adversarial review ran over the sprint's own output; the first round refuted all seven tracks, and the defects that round found are in this release too, not deferred.
Security
-
skipGlobalFilters,includePiiandallowFullTableScanare now unlocked by a symbol, not bytrue. BREAKING. All three were ordinary boolean siblings ofwhereon the query-args object, sofindMany({ ...req.body })turned a request field namedincludePiiinto a real privilege escalation: the tenant filter dropped, or the PII columns returned, from a body the attacker wrote. Typing thembooleanand documenting the risk is not a boundary. They now take the exportedUNSAFEsentinel (includePii: UNSAFE), whichJSON.parsecannot produce at any depth, so mass assignment is structurally unable to set them.trueTHROWS rather than being ignored, so a call site that had the privilege keeps it only after a human edits it. The sentinel is registered withSymbol.for, so the ESM and CJS halves of a dual-package install agree on it. -
turbine init --urlno longer writes a password intoturbine.config.ts. The scaffold inlined whatever--urlcarried, andturbine.config.tsis a tracked file, so the first command a new user runs committed their database password. A password-bearing URL now goes to.envasDATABASE_URL, the config readsprocess.env.DATABASE_URL, and.envis appended to.gitignorewhen the file does not already ignore it (parsed line by line, honoring negations and anchors, not by substring). An existingDATABASE_URLin.envis never overwritten. -
The MCP server no longer leaks index literals or opens a row-count oracle.
explain_queryrefused a predicate only on a code-firstpiitag, whilesample_rowsalso refuses to fetch a secret-NAMED column, so the exact columnsample_rowswould not show was extractable one character at a time through the planner's row estimate.sanitizeIndexnow withholds an expression index's key list in both the partial and non-partial branch, finds the predicate boundary OUTSIDE string literals (a key list containing the text' WHERE 'used to split mid-literal and ship the literal), and withholds a definition it cannot parse instead of passing it through. The secret-name rule is anchored to identifier segments, sosecretary_idand other ordinary columns are no longer hard-refused. -
PII redaction fails closed when it cannot read the tags. Studio and the MCP server load PII tags out of the generated
metadata.ts. A truncated file (an interruptedturbine generate, a disk-full write, a merge conflict) used to scan as "this schema tags nothing", which is byte-identical to success and served every tagged column. The scanner is now a real structural tokenizer that reports whether the object closed, an unreadable file redacts EVERY column rather than none, and both surfaces say so at startup. -
The driver error attached as
.causeis redacted undererrorMessages: 'safe'. Postgres puts the CONFLICTING ROW VALUES in a constraint error'sdetailfield and nowhere else (Key (email)=(alice@example.com) already exists.). 'safe' mode kept those out of Turbine's own message and then attached the raw driver error verbatim, so the values still reached everywhere an error is rendered whole: Node's error printer walks the cause chain, and Sentry and similar sinks serialize each link. The cause is now a shallow clone withdetailreplaced, cloned rather than mutated so the driver's own object is untouched, and constructed so it stays a real native error with itscode, prototype and stack intact.
Fixed
-
orderBy: { x: 'DESC' }sorted the opposite way on some engines. Every direction consumer was spelledString(v).toLowerCase() === 'desc'in one place andv === 'desc'in another, so an uppercase or misspelled direction fell through to the default in some paths and was honored in others, giving one query two sort orders across engines. OneassertOrderDirectionnow validates every site on every engine: any casing is accepted, anything else throws E003 instead of silently sorting the other way. -
omitwas dropped on prisma-compat writes, and its keys were never validated.omitis the idiom for a sensitive-but-untagged column, and it demonstrably works onfindMany, so a caller had positive evidence for assuming it works oncreate. It was accepted and ignored. It now applies, and a misspelled key throws instead of silently returning the column: this projection is applied client-side and never reaches core, so core's E003 on an unknown projection key could not fire. -
Studio's saved queries lost clauses on reload. The builder pane holds database COLUMN names and a Turbine query addresses a FIELD, and the loader translated in neither direction, so on any snake_case schema a reloaded saved query returned more rows than it did when it was saved, with no toast and no error. Separately, two clauses on the SAME column under
ANDwere merged withObject.assign, so a range likegte 18ANDlte 65becamelte 65alone: the pane showed two filters and the query applied one. -
A connection failure escaped as a raw driver error.
pool.connect()was wrapped everywhere except the pipeline path, so a wrong password or refused connection there arrived as a pgDatabaseErrorwhose.codeholds a SQLSTATE, the same property Turbine putsTURBINE_E0NNin. Connection-class failures now come back as typedConnectionError(E004) carrying the driver code and an actionable next step. -
An unusable connection string is refused at construction. A typo'd or truncated string was handed to pg, which resolved the missing parts from libpq defaults and connected somewhere else entirely. The check replays the parse pg itself performs rather than pattern-matching a scheme, so Unix-socket and Cloud SQL strings that pg accepts are still accepted.
Added
-
A CLI coverage gate with per-file floors.
cli/studio.ts(write mode and PII redaction),cli/migrate.ts(destructive DDL) andcli/destructive.ts(the scanner that decides what counts as destructive) were excluded from coverage entirely, so there was no ratchet on the highest-consequence code in the package.npm run test:coverage:cligates them with their own thresholds, per file rather than aggregate-only, and runs in CI and inprepublishOnly. -
check:packageand the missing subpath smoke tests on the release path.publint --strictandattw --packnow run before publish, catching a broken exports map or CJS declarations that resolve to ESM. The release workflow import-smokes themysql,mssql,powdbandprisma-compatsubpaths with their optional peers absent; it previously checked fewer published entry points than CI did.
0.61.0 (2026-07-28)
Five defects found by a full product review of 0.60.1. Every one of them failed SILENTLY, and four of the five returned a plausible answer, which is why the suite did not have them: a test that asserts the happy path cannot see any of these. Each was reproduced before it was fixed and is pinned by a regression test in the direction that would have caught it.
Fixed
-
findUniquereturned an arbitrary row when itswherehad no predicate. BREAKING, and a security fix.{ id: undefined }is what{ id: req.params.id }becomes when the parameter is missing or misspelled. Undefined keys are dropped downstream, so the emitted SQL wasSELECT … FROM t LIMIT 1: no predicate at all, and a caller that would have handlednullsilently received someone else's row.findUniqueOrThrowwas worse, promising to throw when nothing matched and returning a stranger's row instead. Both now throwValidationError(E003). The check runs against the USER'swhere, before global filters merge, deliberately: a tenant filter is not a unique selector, and letting it satisfy the check would still return an arbitrary row from inside the tenant.findFirstis unchanged, since "the first row matching an optional filter" is its contract. -
An unrecognized
isolationLevelsilently downgraded the transaction. BREAKING. The level was resolved by indexing a plain object, and a miss producedundefined, which renders as a bareBEGIN. SoisolationLevel: 'serializable'(wrong case) asked for SERIALIZABLE and got READ COMMITTED: the caller holds a guarantee it does not have, and the workload that needed it produces wrong data with no error. The TypeScript union never prevented this for a JavaScript consumer, a value from config or an environment variable, or anything crossing anas. Now throwsValidationErrorlisting the accepted values, before a pool connection is taken. The map is also null-prototype now:isolationLevel: 'constructor'previously emittedBEGIN ISOLATION LEVEL function Object() { [native code] }. -
COMMIT-time database errors escaped the typed-error system. BEGIN and COMMIT were the only statements issued without
wrapPgError. Postgres reports DEFERRABLE constraint violations, and a good share of SERIALIZABLE conflicts, at COMMIT rather than at the statement that caused them, so those surfaced as a raw pgDatabaseErrorcarrying a SQLSTATE in.code, the SAME property Turbine putsTURBINE_E0NNin, with no.cause. A check forTURBINE_E008silently missed them, and a retry loop keyed onSerializationFailureError.isRetryablewould never fire for exactly the commit-time conflicts that are the main reason to run SERIALIZABLE. -
A throw in the pipeline send path wedged a pooled connection permanently.
valueMapper: prepareValueruns synchronously insidebind, so a parameter with a throwingtoPostgres/toJSON(or a circular reference) escaped mid-sequence with Parse/Bind bytes corked and noSyncever sent. The connection was then returned to the pool, which reported it idle and healthy; the next borrower's first query hung forever, with nothing pointing back at the pipeline call that caused it.uncork()now runs in afinallyand the socket is destroyed rather than returned. -
turbine migrate up --dry-runandmigrate down --dry-runAPPLIED the migrations. The flag was parsed and read bypushanddeployonly. Onmigrate upit was inert: accepted, no warning, migrations applied. A flag whose entire purpose is "show me what this would do to the database", pointed at a production URL, ran it. Both now print the exact SQL, parsed through the sameparseMigrationContentthe executor uses, and execute nothing.
Changed
- Corrected claims in the README and site that were wrong or stale. The
bundle-size figure had drifted about 12% low over ten releases while naming a
version and date, so it read as precise; prose now states the CI-enforced
ceiling from
.size-limit.js(under 77 kB main, under 61 kB edge) rather than a measurement that goes stale silently. "Prisma Studio is proprietary" was false,@prisma/studio-coreis Apache-2.0; the accurate claim is that it has no read-only mode. The note on Kysely rested on a linked issue whose state had changed and now states the runtime behaviour directly. The comparison table credited Prisma with single-query nested relations by default, which its own Preview flag contradicts.
0.60.1 (2026-07-28)
Changed
- Corrected what the Prisma schema fingerprint is documented to normalize. No behaviour change. 0.60.0 said "trailing whitespace", which reads as per-line. The rule is end-of-FILE only: whitespace and blank lines after the final newline are ignored, trailing whitespace on an individual line is hashed and does count as a change. That is the intended behaviour, since nothing in a checkout puts trailing spaces on a line, so it is an edit like any other. The 0.60.0 entry, the source comment and the migration page now say so, the site page carries the full table, and five tests pin the boundary in both directions so a later tidy-up of the normalizer cannot quietly widen it.
0.60.0 (2026-07-28)
Added
-
prisma-compat warns when its name map is stale. Nothing re-runs
turbine migrate-from-prisma. When the Prisma schema changes and the map does not, the adapter keeps translating the names it has: a model added last week is simply not on the compat client, a renamed field is quietly absent from results. Both read as adapter bugs, and neither produces any signal at all. The command now writes asource: { path, hash }into the emitted map, andcreatePrismaCompatClientcompares that hash against the file on disk once per process and warns, naming the file and the command that fixes it.Subordinate to not breaking a working app, in every direction: skipped entirely when
NODE_ENV=production; silent when the file is missing, since not shippingprisma/to production is normal and is not evidence of drift; silent in a bundled runtime with nonode:fs; asynchronous and unawaited, so client construction never waits on a file read; and once per process per path. A map generated before this release, or written by hand, carries nosourceand is skipped. The fingerprint is FNV-1a over the file with line endings, a leading byte-order mark and end-of-FILE whitespace normalized away, so a Windows checkout of an unchanged file is not reported as drift. Nothing else is normalized. That end-of-file rule is not a per-line one: trailing whitespace on an individual line is hashed and does count as a change, as does an edited comment, because nothing in a checkout introduces either and an unnecessary regeneration costs less than a missed one. -
turbine migrate-from-prisma --if-db, so the command can live inpostinstallnext toprisma generaterather than depending on someone remembering to run it. When no connection string resolves, it prints one line saying nothing was regenerated and exits 0, instead of failing. Annpm ciinside a build image legitimately has no database, and without the flag that would turn a missingDATABASE_URLinto a failed install. Existing artifacts are left untouched. Documented on the CLI and migration pages, along with the CI check (--no-timestampthengit diff --exit-code) for projects that want the map's freshness enforced rather than nudged.
Changed
- Test-case names and provenance wording. Test and fixture names that carried
opaque external-report labels now state what they assert.
scripts/check-private-terms.mjsgained a tracked set of patterns for wording that attributes a change to who reported it or identifies a system a measurement was taken on, so the check runs in CI and in every clone rather than only where a local blocklist exists. No code behaviour changes.
0.59.2 (2026-07-28)
Changed
- Example and fixture naming, no code behaviour changes. Doctor remediation
examples, source comments, unit-test fixtures and two benchmark seeds used
table and column names carried over from schemas Turbine was validated
against rather than synthetic ones. All are now generic (
documents/document_versions,ledger_entries/ledger_lines,product/category,user_session/tenant_id). Test and benchmark identifiers are synthetic by policy, and the regression shapes they pin are unchanged.
0.59.1 (2026-07-28)
Changed
- Documentation wording only, no code behaviour changes. Several notes in the changelog, README, site and source comments described a measurement by the environment it was taken in rather than by what was measured. The numbers are unchanged; only the framing is. Turbine's docs describe what the software does and what was measured, never where a report came from.
0.59.0 (2026-07-27)
Fixed
-
The plan-flip probe missed every low-estimate column served by any usable index. 0.58.0 refuted a finding when the generic plan was a
Seq Scanof the target table. That is one of two ways a plan can fail to be the ordered index walk a finding claims, and it is the wrong one to pick alone: a column whose generic estimate is below the flip boundary plans asLimit > Sort > Bitmap Heap Scanwhenever an index is available, never reaching a seq scan, so it survived as a false positive.The case that surfaced it carried
btree (col) WHERE col IS NOT NULL. An equality predicate implies not-null, so that partial index is fully usable. Reproduced as a pair at the same estimate, differing only in whether the index exists:partial index, est 1.9 Limit > Sort > Bitmap Heap Scan 0.58.0 kept this no index, est 1.9 Limit > Sort > Seq Scan 0.58.0 refuted this either, est 500 Limit > Index Scan (no Sort) both keep, correctlyThe refutation is now stated as the question actually being asked, "is the generic plan the ordered index walk", with two independent grounds: a
Sortabove the target's scan (which bounds the cost by the match count rather than by how far the walk travels, whatever access feeds it), or aSeq Scanat the target itself. The second is kept as its own ground so a hypothetical ordered seq scan still refutes. -
This is not "exclude columns with a partial index". A partial index whose predicate is NOT implied by the equality cannot serve the query, and such a column produces a genuine finding: one was measured at 19,961x. The property that matters is whether the planner could use the index, which is a proof obligation over predicates, and the plan the probe already fetches carries the answer. Reading it is cheaper than re-deriving it and cannot drift from Postgres's own implication rules.
-
Incremental Sortdeliberately does not refute. It means the index supplies a prefix of the ordering, so the walk is still partly ordered and closer to the catastrophic shape than to the bounded one. Over-refuting deletes real findings invisibly; over-keeping only costs noise. -
A
Sortelsewhere in the plan does not refute. Only one above the target table's own scan counts, so a sorted branch of a join cannot silently drop a finding.
0.58.0 (2026-07-27)
Fixed
-
turbine doctor'sunindexed-filterfindings are now put to the planner before being reported, which removes most of them. The branch shipped in 0.57.0 answering "IF this cached plan flips, how bad is it" without answering "CAN it flip at all". In validation against a large schema it emitted 39 findings, and a measured sample of 13 of them held up only 6 times. Every false positive had the same signature: the generic plan keeps the same sequential scan the good plan chose, so the amplification the finding printed described a plan the planner would never pick.Each such finding now costs one
EXPLAINwithoutANALYZE, which plans and discards, executes nothing, and returns in microseconds:PREPARE p AS SELECT * FROM t WHERE col = $1 ORDER BY ord LIMIT $2; SET LOCAL plan_cache_mode = force_generic_plan; EXPLAIN (FORMAT JSON) EXECUTE p(NULL, 20);Only the generic plan is needed: the finding's whole claim is that a promoted plan abandons the seq scan, so a generic plan that IS a seq scan refutes it regardless of the custom plan. That also removes the need for a representative rare value, which statistics do not carry.
NULLis safe precisely because the plan is generic, and the limit stays bound as$2because that is the shape Turbine emits.doctor --jsongainedplanDivergenceScored.flipProbedand.flipRefuted, so a consumer can tell a verified list from an unverified one instead of inferring it from the count, and the human report states which it is. -
A probe that fails keeps its finding. Errors, timeouts and unparseable plans all yield
unknown, the finding survives, and a notice says so. A diagnostic that deletes findings when the database is uncooperative would fail invisibly in exactly the environments (restricted roles, non-Postgres engines) where a human is least able to notice. Each probe is savepointed, so one unprobeable column does not cost the ones after it.
Changed
-
The arithmetic gate this replaces was measured wrong and is NOT shipped. The natural rule is to require the generic row estimate (
rows / n_distinct) to exceed the assumedLIMIT, reasoning that Postgres discounts an ordered index walk bymin(1, limit / estimate)so an estimate at or below the limit earns no discount. Measured on a 247-page fixture, the flip boundary sits between estimates 3 and 4, not at the limit of 20:generic estimate 2 3 4 10 20 500 generic plan seq seq index index index index buffers 250 250 20,074 20,074 19,071 765 Estimates 4 through 20 are full-table walks that such a gate would discard. The real limit fraction for a BOUND limit is
ceil(0.1 x estimate) / estimate, which pins to 0.1 at estimates of 10 or more but rises to1/estimatebelow that; the plan is then chosen by comparing that fraction of the full index scan against a seq scan plus sort. Because it is a cost comparison the boundary also moves with the table, landing in a different place on a 1976-page table and on an 89-page narrow one. A closed-form gate was attempted, failed its own out-of-sample prediction, and was dropped in favour of asking the planner.
0.57.0 (2026-07-27)
Fixed
-
turbine-orm/prisma-compatsilently dropped every Turbine-native query option, so 0.56.0'sforceCustomPlandid nothing on that client. The option shipped, was documented, was measured at the wire on the core client, andturbine doctor's own remediation text told readers to reach for it. Through the compat adapter it was accepted by the type-checker and dropped on the floor. Measured before the fix on one pooled connection (poolSize: 1), the same read executed 3 warmup + 12 measured times per row, readingpg_prepared_statementson that same session afterwards:core, default prepared: t_5109978894a0ccdd(g=10,c=5) core, forceCustomPlan: true prepared: NONE compat, default prepared: t_5109978894a0ccdd(g=10,c=5) compat, forceCustomPlan: true prepared: t_5109978894a0ccdd(g=10,c=5) NO EFFECTAfter the fix the compat row reads
prepared: NONElike the core one. Theg=10in the default rows is real generic-plan promotion, so theNONEis the option taking effect and not an artifact of a quiet fixture.It was never only
forceCustomPlan. The translator built a fresh Turbine args object and copied a hand-written allowlist of keys, so every Turbine-only option added since that list was written was stranded the same way:warnOnUnlimited,skipGlobalFilters,allowFullTableScan,timeouton every write exceptcreate,optimisticLock,stableRelationOrder, anddistinctOnongroupBy. Each is now forwarded and each is covered by a live test that asserts an observable consequence rather than the shape of an args object (optimisticLockraisingTURBINE_E015on a stale version,allowFullTableScangetting awhere: {}past the empty-where guard,skipGlobalFiltersreturning rows a configured global filter removes, and so on). -
The drift that caused it is now a build failure.
src/query/option-surface.tsholds oneRecord<keyof SomeArgs<Row>, OptionKind>table per query-arg interface, the same mechanismTURBINE_CONFIG_KEYSalready used for the client config. Adding an option to a core arg interface stops that file compiling until a human classifies the new key, and listing a key that is not on the interface fails as an excess property. It does not make one edit sufficient, deliberately: two options carry field names in their values (optimisticLock.field,distinctOn.columns), and a passthrough-by-default translator would forward the Prisma spelling into core, which is correct on a schema whose names coincide and broken on one that renames a column. What it does guarantee is that the second edit can no longer be forgotten in silence. -
Unknown query-level options warn in compat instead of vanishing.
compat.User.findMany({ thisOptionDoesNotExist: true })used to be accepted and dropped as a class. It now logs one dev-only line permodel.operation.key, in the same spirit as the unknown-client-config warning added in 0.53, with the nearest real option suggested:[turbine] prisma-compat: unknown option "customPlan" in User.findMany(), it is ignored. Did you mean "forceCustomPlan"? [turbine] prisma-compat: "limit" is Turbine's spelling and is ignored here; prisma-compat takes Prisma's "take". (User.findMany)It warns, never throws: a stray key must not turn a working app into a failing one on upgrade. Legitimate Prisma keys never warn (verified silent across 28 realistic Prisma call shapes), and neither do the Turbine-only options the adapter hand-translates.
-
turbine doctor's plan-divergence remediation no longer assumes which client you are holding. Step 3 previously told every reader to scope the fix withforceCustomPlan, which was a no-op advice for a compat integration, and step 4 said "do NOT setplanCacheModeon the client to fix this", which names a Turbine-specific option to a reader who may not have it. Step 3 now names the option first and prints both call shapes (compat's using Prisma'stake, not Turbine'slimit), states which release the compat passthrough needs, and asks for the samepg_prepared_statementsconfirmation rather than assuming the option took effect. Step 4 now names the database-wideplan_cache_modemechanism and both ways of reaching it. -
The core-client snippet doctor prints named an accessor that does not exist. It printed
db.<raw table name>, so on any snake_case schema the suggested code wasdb.user_session.findMany(...), which isundefined. BothTurbineClientand the code generator define table accessors throughsnakeToCamel, and the snippet now does too. -
src/cli/index.tscontained four literal NUL bytes, from a map key written as a raw0x00byte rather than a\u0000escape. Behaviour was correct, butgrepclassified the largest file in the CLI as binary and skipped it, and neither lint nor typecheck noticed.
Added
-
turbine doctorscores unindexed filter columns for cached-plan divergence, a THIRD mechanism. Until now the check dropped every column with no index on the filter column before it even counted it as considered, so that population was outside the findings and outside the "not scored" notices alike. It is a distinct mechanism from the two already discussed: not the sparse-value direction the existing rule models, and not the dense-clustering direction that was written, measured and removed in 0.56.0 for predicting the wrong sign. Here the good plan is a sequential scan that the generic plan will not choose. With no index on the filter column the custom planner takes a seq scan plus a top-N sort, bounded by the table's pages; a promoted generic plan cannot see the value is rare, keeps the ordered primary-key walk, and fetches nearly every tuple before it fills theLIMIT.Fixture, printed so every number below is checkable (PostgreSQL 16, warm cache,
synchronize_seqscans,max_parallel_workers_per_gatherandjitoff):CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL); INSERT INTO t SELECT g, <bucket(g)>, repeat('p', 60) FROM generate_series(1, 20000) g ORDER BY (g * 2654435761::bigint) % 1000003; -- 247 relpages, buckets 10,000 / 6,000 / 3,998 / 2 -- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, rarest value, limit 20force_custom_planreads 250 buffers (Seq Scan),force_generic_planreads 20,074 (Index Scan on the primary key), and underautoseven executions of the rare value reportgeneric_plans 2, custom_plans 5, so the promotion is real and not hypothetical. The branch's gates are its own: the rarest value must hold fewer rows than the assumed limit, and the promoted plan must walk at least 10,000 tuples. It carries no generic-side gate, for a measured reason recorded in the source.A finding on an unindexed column has a different first remedy, so the report renders it as evidence attached to the missing-index finding the same run already produced, never as a second entry, and it never suggests
forceCustomPlanthere. The leftover case, a column served only by a partial or expression index, keeps its own entry. -
Findings carry the ordering column's correlation, and disclose the known false positive. The size of an unindexed-filter flip is decided by how closely the heap tracks the column the generic plan walks, not by the filter column's own correlation. Six fixtures identical except for INSERT ordering, custom plan 250 buffers in all of them:
heap order pg_stats.correlationonidgeneric buffers ratio exact idorder1.00000 303 1.2x shuffled within ~1 page 0.99998 783 3.1x shuffled within ~2 pages 0.99993 10,303 41x shuffled within ~4 pages 0.99974 15,148 61x shuffled within ~20 pages 0.99372 19,046 76x hash order -0.00065 20,074 80x The plan flips in all six; only the magnitude differs, and an append-only table with a serial primary key sits on the top row.
PlanDivergenceFindingnow carriesorderColumnCorrelationand aheapNearlyOrderedboolean, both in the human report and indoctor --json, and the rendered text states the condition alongside the ratio instead of printing one number for both cases. Nothing is suppressed on it: the boundary sits between two adjacent sampled values, so it qualifies a finding and never decides one. -
A column served only by a
brin/gin/gistindex is reported as not scored, with the reason, rather than being described by a model that does not fit it. A hash index now counts as an equality path and routes the column to the sparse-value rule: measured, a hash index gives the custom plan a 7-buffer Bitmap Heap Scan, so calling that column unindexed printed a 247-page seq scan as the good plan. -
doctor --jsongainedplanDivergenceScored(considered/indexed/unindexed), so a consumer can tell "considered and clean" from "never looked at", and the divergence section of the human report prints the same split.
Changed
limitonupdateMany/deleteManythrough prisma-compat now throwsUnsupportedFeatureError(TURBINE_E017). It was previously accepted and ignored, which silently dropped a safety bound on a mass mutation. This is the one place the release throws rather than warns, deliberately.relationLoadStrategy: 'query'through prisma-compat now maps to Turbine's'batched'. It used to be forwarded verbatim into a resolver with no'query'branch, so a caller asking for the per-relation plan silently got the join. Statement counts change for anyone who passed it.skipGlobalFiltersthrough prisma-compat goes from silently ignored to honoured. That is the correct behaviour and matches the core client, but it is a real behaviour change on upgrade for a compat app that had the key present and inert.- Plan-divergence findings are sorted by estimated extra buffer accesses
rather than by
approxAmplification, so existing sparse-value findings can change order. Both branches now report in the same units; that is not the same as equal conservatism, and the source says so. PlanDivergenceFinding.crossoverRows,crossoverRowsWide,valuesBelowCrossover,walkPages,walkFractionandapproxAmplificationare now optional and are absent on anunindexed-filterfinding rather than zero-filled. A--jsonconsumer reading them unconditionally must branch onbranch.- Several calibration numbers in the plan-divergence source were re-measured and corrected, including a page ladder that was about 8% high and a claim that one page of local heap disorder already costs 25x (measured 3.1x; the transition is between a one-page and a two-page window, because an index scan holds its heap pin).
Known limits
- The divergence check's population is still relation-probe columns and leading index columns. A filter column that is neither an FK nor indexed is invisible to it in either branch, and feeding the index advisor's recommendations in does not change that: both derive from the same relation topology.
- The unindexed-filter branch's first gate is a constant (rarest bucket below the assumed limit), so it declines divergences whose absolute damage is larger than the ones it reports. Measured on the same fixture shape at 200,000 rows with a rarest bucket of 60: 2,473 buffers custom against 200,547 generic, an 81x flip and 198,074 extra buffer accesses, declined. Deriving the boundary from pages, rows and the cost constants is the honest fix and has not been done.
0.56.0 (2026-07-27)
Added
-
forceCustomPlan, a per-query lever for the generic-plan cliff. 0.55.0 added the client-levelplanCacheMode, which is a connection parameter and therefore cannot say "custom here,autothere".forceCustomPlan?: booleannow sits onFindManyArgs,FindUniqueArgs,CountArgs,AggregateArgsandGroupByArgs(sofindMany/findUnique/findFirst/ theOrThrowforms /count/aggregate/groupBy, the streaming read, and the batched strategy's relation follow-ups):db.orders.findMany({ where: { tenantId }, orderBy: { id: 'asc' }, limit: 20, forceCustomPlan: true, });truesends that one statement UNNAMED. State the mechanism carefully, because the tempting one-liner is wrong: PostgreSQL does NOT treat an unnamed statement as a one-shot plan that never enters the plan cache. It builds and saves aCachedPlanSourcefor it too. The option works one level up, in the driver: node-postgres only skipsParsefor a statement it has already parsed BY NAME, so an unnamed statement is re-parsed on every execution, eachParsereplaces the unnamed cached plan source with a fresh one whose custom-plan counter is zero, and the five-execution threshold that precedes promotion is never reached. No GUC, noSET LOCAL, no transaction, no extra round trip.Measured on the SQL Turbine itself emits (
LIMIT $nincluded), 12 executions over one pooled connection: by default the statement is cached and promoted (generic_plans = 7, custom_plans = 5) and reads 26,802 buffers on a sparse tenant; with the option there is no entry inpg_prepared_statementsat all and it reads 132.The fixture, so those two numbers can be checked rather than taken on trust (
synchronize_seqscansandmax_parallel_workers_per_gatheroff, as with every plan comparison here):CREATE TABLE t (id serial PRIMARY KEY, tenant_id int NOT NULL, pad text); -- 60 tenants over 200,000 rows, and the sparse tenant inserted LAST so its -- rows sit at the end of the heap: the ordered primary-key walk the generic -- plan chooses has to cross the whole table before it finds one. INSERT INTO t (tenant_id, pad) SELECT 2 + (g % 59), repeat('x', 180) FROM generate_series(1, 199900) g; INSERT INTO t (tenant_id, pad) SELECT 1, repeat('x', 180) FROM generate_series(1, 100) g; CREATE INDEX ON t (tenant_id); ANALYZE t; -- then, at LIMIT 100: -- SELECT * FROM t WHERE tenant_id = $1 ORDER BY id LIMIT $2Precedence, stated exactly because one of the four cases is the opposite of what the mechanism suggests. Client unset or
'auto': honoured, and this is what the option is for. Client'force_custom_plan': redundant, harmless. Client'force_generic_plan': REFUSED,ValidationError(E003) naming both settings. That was measured, not reasoned about: five executions of one unnamed statement read 19,107 buffers with that setting in force and 55 with the same connection set back toauto, so withholding the name buys nothing against it and accepting the flag would report a guarantee the next execution breaks. Omitted orfalseis byte-identical to 0.55.0 and does not opt out of a client-level setting. WithpreparedStatements: falseevery statement is already unnamed, so it is a no-op for plan choice. SQLite / MySQL / SQL Server / PowDB throwUnsupportedFeatureError(E017): an engine with no PostgreSQL plan cache cannot make this guarantee.There is deliberately no per-query
planCacheModethree-value enum.force_generic_planis a property of a CACHED plan and the only per-query lever is keeping a statement out of the cache, which can only ever mean custom, so an enum would promise a direction the mechanism cannot deliver.Not covered, and stated rather than left to be discovered: writes (
updateMany/deleteManycan hit the same cliff) do not take it. It is a read arg. -
turbine doctordetects the distribution that admits the flip. A new finding-only section (skip with--no-plan-divergence;planDivergenceandplanDivergenceNoticesin--json) scores every column doctor already knows about, relation probe columns and leading index columns, againstpg_stats.The shape it models is narrow on purpose:
WHERE col = $1 ORDER BY <other indexed column> LIMIT $n, whererows / n_distinct(what a generic plan assumes an equality matches) sits ABOVE the plan boundary while some real values sit far below it. The boundary issqrt(limit x relpages), where an ordered index scan'slimit / matchingshare of the pages equals a bitmap scan's own; measured flip points track it. Each finding prints the statistics behind it and HOW MANY PAGES the wrong plan walks, plus a copy-pasteable diagnostic block whose FIRST step isSELECT generic_plans, custom_plans FROM pg_prepared_statements, because a finding describes exposure and not an incident:autopromotes only when the generic plan is not estimated to cost more than the average custom plan, and on many of these shapes it is, so nothing is ever promoted. The block ends by resettingplan_cache_mode,synchronize_seqscansandmax_parallel_workers_per_gatherso a paste does not leave a session pinned.It deliberately prints no amplification multiplier, and it deliberately does not model the opposite direction (a physically clustered dominant value). A rule for that direction was written and then REMOVED after measurement: on live fixtures it was wrong more often than right and twice it was wrong with the SIGN INVERTED, predicting "at least 16,032x" and "at least 2,675x" on columns where the generic plan was in fact 10x and 105x BETTER, so acting on it would have made those reads dramatically slower. The reason is structural rather than a bad constant: whether that flip helps or hurts turns on WHERE in the heap the dominant band sits, and
pg_stats.correlationis identical whether it sits at the head or the tail. The same blind spot bounds what remains, so a clean report is stated as not being evidence of immunity.There is no
--fix. The remedy is application code (forceCustomPlanon the affected reads), and the index that looks like a fix is measured not to be one: a composite index on(col, order_col)makes the GOOD plan better without stopping the generic plan from choosing the other one.
Corrected
-
The 0.55.0
ORDER BYcorrection overcorrected. 0.55.0 refuted "anORDER BYis the necessary co-factor" with a 430x case that has no ordering and no limit, and that refutation stands. But it was published on its own, and read alone it says ordering does not matter, which misleads in the other direction. Both halves are true. In a table-by-table sweep of a multi-tenant schema, EVERY divergent shape measured wasWHERE tenant = $1 ORDER BY id ASC LIMIT $2, and every shape without an ordering measured 1.00x. The mechanism is plain: anORDER BYon a DIFFERENT indexed column hands the planner a second plan it can run away with, and a generic estimate on the wrong side of that boundary is what makes it take it. So: not necessary in general (do not conclude your unordered reads are safe), and still the strongest single predictor in practice, which is why the newdoctorcheck models exactly that shape. The 0.55.0 entry is left as published; this is the correction to it. -
A custom plan is not automatically the better plan, with the fixture. Nothing shipped previously said otherwise, but
planCacheMode: 'force_custom_plan'read as strictly safe, and it is not. Reproduced on PostgreSQL 16.14,synchronize_seqscansoff, parallelism off:CREATE TABLE ev (id bigserial PRIMARY KEY, tenant_id int NOT NULL, pad text); -- head of the heap: 320,000 rows over 799 tenants in RANDOM physical order INSERT INTO ev (tenant_id, pad) SELECT t, repeat('x', 60) FROM (SELECT ((g % 800) + 1) AS t FROM generate_series(1, 320000) g ORDER BY random()) s WHERE t <> 400; -- tail of the heap: the dense tenant's 80,000 rows, inserted LAST INSERT INTO ev (tenant_id, pad) SELECT 400, repeat('x', 60) FROM generate_series(1, 80000) g; CREATE INDEX ev_tenant_idx ON ev (tenant_id); ANALYZE ev; -- relpages 5334, n_distinct 800, correlation 0.004 PREPARE q(int, int) AS SELECT * FROM ev WHERE tenant_id = $1 LIMIT $2;plan_cache_modeplan buffers force_custom_planSeq Scan 4,262 force_generic_planBitmap Heap Scan 71 60x, with no
ORDER BYanywhere. The custom planner knows tenant 400 is 20% of the table, so withLIMIT 20it prices a sequential scan as nearly free on the assumption it stops almost immediately. It is right about HOW MANY rows match and wrong about WHERE they are: they are all at the end of the heap, so it reads 319,600 non-matching rows first. Re-insert the identical rows in random physical order and the effect vanishes and reverses (custom 2 buffers, generic in the seventies, the exact figure moving with the index leaf-page count on each rebuild): the variable is physical CLUSTERING, not selectivity.Read the comparison carefully, because the loose version of this claim is itself wrong. That is 60x against
force_generic_plan, NOT against the default. On that fixtureplan_cache_mode = autonever promotes (after nine executionsgeneric_plans = 0, custom_plans = 9, because the generic plan's estimated cost 157 is far above the average custom cost 2.59 andautoonly promotes when generic is not worse), and its plan is byte-identical toforce_custom_plan's. The honest claim is "there exists a shape whereforce_generic_planis 60x better than both the default and a forced custom plan", not "forcing a custom plan is a 60x regression". -
The parser-overwrite warning now fires under
NODE_ENV=productiontoo. It was dev-only.temporalInfinity's warning already fires in production deliberately, because production is where the destructive write commits, and the same argument applies here with more force: a parser overwrite is decided by which module callssetTypeParserLAST, and evaluation order is precisely what differs between a dev process and a bundled or lazily imported production one. A process can be clean in dev and wrong in production purely from import order, which made the case where it matters most the case where it was silent. Cost is bounded: once per OID per process, at client construction, only when a third party's non-default parser is actually being replaced. The message no longer claims to be dev-only and states why it fires.
Fixed
- The
forceCustomPlanintegration suite took itsbefore/afterhooks fromnode:testdirectly while gating only its tests, so on a machine with noDATABASE_URLthe setup hook still opened a pool with an undefined connection string andnpm run test:unitexited non-zero. Hooks now come from the same gate as the tests, which is what the sibling suites already did.
0.55.0 (2026-07-27)
Corrected
-
The 0.54.0 note about
planCacheModeandfindManywas false, and it told readers they were safe when they were not. It said, in the changelog, in the README, on the docs site and in theplanCacheModeJSDoc that ships in the published.d.ts: "findMany/findFirstbindLIMIT $n, and a parameterized limit denies the planner the limit fraction that makes a skewed plan look cheap, so those are much less exposed." PostgreSQL denies nothing. For an unknownLIMITit SUBSTITUTES a default fraction, 10% of the child node's own row estimate, and that is not protection, it is a different wrong number, wrong in BOTH directions: too generous when the real limit is under 10% of the matching rows, too stingy when it is over. Anyone who read the old text and concluded a paginatedfindManydid not need looking at was misled.What is actually true. Everything below is a number reproduced on PostgreSQL 16 on two fixtures small enough to rebuild, both stated so you can check them rather than take them on trust.
synchronize_seqscansoff andmax_parallel_workers_per_gather = 0throughout (the first of those matters: it is on by default and makes a repeated seq scan resume where the last one stopped, which reported one 8,000-buffer scan below as 4 buffers until it was turned off).FIXTURE 1, the substituted defaults. 400,000 rows,
k = id % 104son_distinctis exactly 104, a btree onk, underforce_generic_plan:statement generic estimate rule WHERE k = $13846 rows rows / n_distinct= 400000/104WHERE k > $1133333 rows 1/3 of the table WHERE t LIKE $12000 rows 0.5% of the table WHERE k = $1 LIMIT $2Limit385 rows10% of the 3846-row child WHERE id = $1 LIMIT $2Limit1 rowthe 10% fraction clamps at 1 row Two conditions on that 10% that are easy to miss, and the second one is the one Turbine walks into. It clamps at one row, so it is not always an overestimate. And an unknown
OFFSETtriggers the same substitution ON ITS OWN, even when the limit is a constant:LIMIT 20 OFFSET $2estimated 20 rows correctly but costed a 385-row prefix as its startup, and picked a DIFFERENT plan shape from the same query with no offset. Turbine binds both (... LIMIT $2 OFFSET $3), so a paginated read has no constant-limit escape. And note the direction: in that fixture the constant-limit form is the one that chose a seq scan, so "make the limit a literal" is not a fix either.FIXTURE 2, what actually goes wrong, and when. Two 200,000-row tables joined on an indexed key, with a skewed predicate (
k2matches 190,000 rows for one value and one row for the rest;n_distinctsampled at roughly 1,600, which is an ANALYZE estimate and varies on rebuild), for the statementSELECT count(*) FROM j JOIN jc ON jc.j_id = j.id WHERE j.k2 = $1. NO LIMIT, noOFFSET, noORDER BYanywhere in it:- custom plan: hash join, 1,770 shared buffers
- generic plan: nested loop (the ~1,600-way estimate makes 190,000 inner lookups look cheap), 761,002 shared buffers, a 430x difference
- and it PROMOTES under the default
plan_cache_mode = auto:pg_prepared_statementsreportsgeneric_plans = 2, custom_plans = 5after seven executions
That is the correction that matters most, because it inverts the ranking the 0.54 text gave. The shape that bites you unprompted is the one with NO limit at all, because
autopromotes only when the generic plan's ESTIMATED cost is not worse than the average custom cost, and this generic plan underestimates itself. ALIMIT $nfrequently makes the generic plan look MORE expensive rather than less (its substituted row count is larger than the real one), and the limited statement in the same session was never promoted at all:generic_plans = 0, custom_plans = 8across eight executions.So the honest reading of
findManyagainstcount()is neither the 0.54 claim nor its mirror image. A limitedfindManygives the planner two things it cannot see instead of one (the predicate value and the limit count) and either alone can flip the plan shape, but MORE unknowns is not the same as more damage: the extra unknown often keeps the statement on custom plans. Measured, bothcount()and an UNLIMITEDfindManycompile to the pure parameterized-predicate shape that promotes on its own, and that is the shape to look at first.A note on the shapes we cannot show you. Earlier drafts of this entry carried further figures (a 2040x seq-scan case, an 858x correlated-column case, an early-termination cliff) whose fixtures were not written down and which did not reproduce on rebuild. They are gone rather than restated: publishing an unfalsifiable number in the entry that exists to correct one would be the same mistake. What survives from them is the qualitative point, which does hold on fixture 2 above: neither an
ORDER BYnor any limit is required for a generic plan to be catastrophically worse than a custom one.Two corrections to the correction, so it is not overstated in the other direction:
- The sixth execution is a ceiling, not a trigger.
autodoes not promote unconditionally on execution six; it promotes when the generic plan's estimated cost is not worse than the average custom cost, which for many statements is never. The README and theplanCacheModeJSDoc said "promotes on its sixth execution" flatly, and now say this. implicitPkOrderingis OFF by default in core. An earlier draft of this entry described it as a default Turbine supplies, which is wrong: a defaultfindMany({ where, limit })emitsSELECT ... WHERE ... LIMIT $2with noORDER BYat all. The option is opt-in (turbine-orm/prisma-compatdefaults it on, core does not), and turning it ON adds an ordering a generic plan can walk the whole table in. Nothing about the option changes here.
The short rule: a plan-cached statement with any parameter can diverge; no shape of limit, bound or constant, protects you; and the promotion decision turns on the generic plan's own estimate, not on your query's shape. Measure with
plan_cache_mode = force_generic_planversusforce_custom_plan, and checkpg_prepared_statements.generic_plansto see whether a statement is actually being promoted under the default.Nothing about
planCacheModeitself changes in this release. It still does what it did; the guidance around it was wrong. -
The 0.54.0
infinitynote overclaimed its own coverage. It said the fix landed "on top-level, join relations and batched relations alike". It did not reach the join strategy:json_build_objectrenders an infinite timestamp as the string"infinity", which no driver parser sees, so the join and positional paths kept returning an Invalid Date while the batched path returned a number, for the same row of the same query. Fixed below.
Breaking
-
infinity/-infinityin a temporal column now read as the JS numbersInfinity/-Infinityon EVERY read strategy. 0.54.0 stopped them becoming an Invalid Date on some paths and not others: the driver hands back a number, butjson_build_objectrenders the same value as the string"infinity", which no driver parser sees, so the join and positional strategies kept returning an Invalid Date while the batched and top-level paths returned a number, for the same row of the same query. Both forms are now normalized in ONE place, the ORM row parser, so the same stored value cannot read differently depending on which plan the query took.That normalization is what changes on upgrade. If you read infinity-bearing rows through a
withclause (the join strategy, or the positional wire encoding), you were getting an Invalid Date and you now get the number. Everywhere else the value is what 0.54.0 already gave you.Applied in ONE place, so top-level reads,
findUnique/findFirst, streaming, the join / batched / flatten strategies, the positional encoding, writeRETURNING/ reselect /OUTPUTprojections andgroupBykeys all take the same reading._min/_maxare mapped at their own assembly sites for the same reason (they hand back a stored cell, so they can carry the value;_count/_sum/_avgcannot). Array elements map too, so atimestamp[]reads[Infinity, -Infinity]rather than two Invalid Dates. Nothing is registered withpg.types.setTypeParserfor this: the driver parsers are untouched, so raw SQL,client.sqland other libraries on the samepgkeep the driver's value.Why the number and not
null.nullis the nicer-looking reading, and it was the one this release originally shipped with. It is also lossy, which a default may not be. Measured on PostgreSQL 16, on a nullabletimestampcolumn holdinginfinity, withvalid_until::textread back through a rawpgclient:const row = await db.leases.findUnique({ where: { id } }); await db.leases.update({ where: { id }, data: { ...row, note } }); // reading the number: valid_until::text is still "infinity" // reading null: valid_until::text is NULL, and nothing said soUnder a
nullreading a storedinfinityand a stored NULL are indistinguishable, so that write, the single commonest write shape there is, destroys the value with no error. The complaint thenullreading answered is a real one, thatJSON.stringifyrenders the number asnullwhile the declared type saysDate, but a lossy default is the wrong price for a cleaner JSON encoding. On a NOT NULL column the same write would at least fail loudly withNotNullViolationError(E010); on the nullable columns whereexpires_at/valid_untilactually live, it is silent.What the default costs you, stated plainly. The generated types declare the field
Date, and it hands back anumber, so on exactly the rows holding an infinity:row.validUntil.toISOString()and.getTime()throw aTypeError. Guard withtypeof row.validUntil === 'number'(orNumber.isFinite) before calling aDatemethod on a column that can hold one.JSON.stringifystill renders the valuenull, because JSON has no infinity literal. The API response is unchanged from 0.54.0 and from the Invalid Date before it.where: { col: null }still compiles toIS NULLand does NOT match these rows. Filter them withwhere: { col: 'infinity' }. Compilingnullto also matchinfinitywould silently change every null predicate on every temporal column, which is far worse.
A one-time warning, and it is not dev-only. When
temporalInfinityis left unset and a stored infinity is actually read, Turbine says so once per process per field, naming the table and column, describing the reading and both escapes. Unlike every other Turbine warning it is NOT silenced byNODE_ENV=production, because production is where aDatemethod throws on live traffic and where a destructive write under the opt-in would commit. It costs oneconsole.warnper field, and only on a row that actually held an infinity. Naming either reading in the config silences it: the warning exists to surface an unacknowledged trade, not to nag.Cross-engine: Postgres is the only engine that can produce an infinite temporal value (SQLite has none, MySQL and SQL Server have bounded datetime domains, PowDB stores micros). The row parser is engine-shared and is not dialect-gated, so the one visible effect elsewhere is that a stray
'infinity'string on a date-typed column reads as the number instead of an Invalid Date.
Added
-
temporalInfinityclient option ('preserve' | 'null', exported asTemporalInfinityReading).'preserve'is the default described above and naming it explicitly silences the warning.'null'opts into reading a stored infinity asnullinstead:JSON.stringifyis then honest about what the value became, the declaredDate | nulltype holds so no method call throws, andgroupBy/distinct/_min/_maxtake the same reading as the rows. Its cost is the data loss above, and three consequences worth spelling out before you choose it:groupBykeys stop being unique.GROUP BYreturns one row per distinct stored value and the ORM then relabelsinfinity,-infinityand SQL NULL all asnull, so three rows holding those three values come back as three groups keyednull. Anything building aMaporObject.fromEntriesoff the group value keeps one of the three counts.distinct: ['col']has the same shape.+infinityand-infinitycollapse into each other, not just into NULL. On avalid_untilcolumn that puts "never expires" and "expired forever" in the same bucket._maxcan returnnullon a table that plainly has rows. With2026-01-01,2026-06-01andinfinitystored,aggregatereports_min: 2026-01-01, _max: null, _count: 3, and_max: nullis the same value an empty table returns.
Reach for
'null'when the rows are read-only in your code path and the declared type contract matters more than the stored value; rows read under it must not be written back. Both readings are identical on every read strategy, every write projection,groupBykeys and_min/_max, and the write path is untouched by either, so'infinity'/'-infinity'/Infinity/-Infinityall remain bindable and a value read asnullis still recoverable if you know what it held. Anything outside the two values throwsValidationError(E003) at construction. -
A one-time dev warning when Turbine overwrites a pg type parser that somebody else already customized.
pg.types.setTypeParseris process-global, which was documented; what was not is that it is RETROACTIVE. There is one parser table and it is consulted per row at decode time, so registration changes how everypg.Poolin the process decodes that OID, INCLUDING POOLS THAT ALREADY EXIST AND ARE ALREADY QUERYING. Same pool, same query, only a Turbine client construction in between (TZ=Asia/Tokyo):datewent from2026-07-20T15:00:00.000Zto2026-07-21T00:00:00.000Z. The failure that creates is order-dependent and invisible: a reporting job reading through its own pool returns different days depending on whether some unrelated module has constructed a Turbine client yet, and with lazy route imports that ordering is not stable between requests. Turbine now says so once per OID (1114, 1082, 1115, 1182 and int8's 20) when the parser it is about to replace is not the driver's default. Detection is BEHAVIOURAL, not by function identity:pg-typeskeeps its default table private, so the registered parser is run over a canonical wire value and compared with what the default produces for it. That catches every parser that behaves observably differently, and deliberately stays silent about one that is observably equivalent. Turbine's own parsers are tagged, so a second registration in the same process (a client plusturbine studio, an ESM plus CJS copy) never reports itself. Dev-only, silent underNODE_ENV=production. The process-global notes in the README, theutcTimestampsJSDoc and the docs site now say "including pools that already exist and are already querying" instead of leaving it to inference.
0.54.0 (2026-07-27)
Breaking
-
Postgres
datecolumns now read back at UTC midnight, not the process's local midnight. Adateis a calendar day with no time zone, but the pg driver's default parser builds the JSDatefrom the process's LOCAL zone, so the stored day2026-07-21came back as2026-07-20T22:00:00ZinEurope/Berlinand2026-07-20T15:00:00ZinAsia/Tokyo: the wrong calendar day everywhere east of UTC. West of UTC the calendar day happened to be right, which is why this survived so long. The write side already rendered a boundDatefrom its UTC components, so the two halves disagreed, and east of UTC every read-modify-write cycle on adatecolumn moved the STORED day one day earlier and kept going. Turbine now registers a UTC parser for OID 1082 under the existingutcTimestampsflag, alongside thetimestamp(1114) parser it has always registered, andinfinity/-infinity/ BC dates / five-digit years keep the driver's own values.What changes for you: the calendar day is unchanged in a UTC-rendered form, but the EPOCH VALUE moves by your process's UTC offset.
getTime(),toISOString()andJSON.stringify(row)change, so API payloads, exports and golden-file tests containing adatecolumn change text. Code that reads LOCAL components off adate(toLocaleDateString(),date-fns/dayjsformat('yyyy-MM-dd')) was correct by accident west of UTC and now reads the previous evening: format in UTC instead (toISOString().slice(0, 10)). Nothing stored in the database changes, no emitted SQL changes, and a process running in UTC is byte-identical. Opt out withutcTimestamps: false, which also reverts the write half.Scope:
pg.types.setTypeParseris process-global, so constructing a TurbineClient on a Turbine-owned pool changesdate,date[]andtimestamp[]parsing for EVERY consumer of the samepgmodule in that process, including a second ORM, a query builder, or your own hand-writtenpool.queryreporting code. That was already true oftimestamp(OID 1114) andint8; this release widens the set to OIDs 1114, 1082, 1115 and 1182. Clients on an EXTERNAL pool never TRIGGER registration and a process containing only external-pool clients is untouched, but they read through the parsers an owned client installed, so they are not exempt from the effect. For that reason theutcTimestampsagreement check (which refuses a second client asking for the opposite value) now covers external-pool clients too: an external-pool client withutcTimestamps: falsenext to an owned client reading UTC would write localdateliterals and read UTC ones, which is the read-modify-write drift described above. That pairing now throwsValidationError(E003) at construction instead of corrupting stored days.Cross-engine, this REMOVES a divergence rather than creating one: SQLite, MySQL, SQL Server and PowDB already returned UTC midnight for a
datecolumn. Postgres was the outlier. -
date[]andtimestamp[]now agree with their scalar forms. Postgres array OIDs do not inherit their element type's parser, sotimestamp[](OID 1115) has been returning local-zone Dates ever since the scalartimestampparser shipped, disagreeing with thetimestampcolumn next to it in the same row. Both array OIDs (1115 and 1182) are now registered alongside their scalars, so scalar and array can no longer settle on different interpretations.
Fixed
-
infinity/-infinityin atimestampordatecolumn no longer reads as anInvalid Date. There were two independent bugs here, one behind the other, and fixing only the first would have changed nothing an ORM caller could see.At the driver level, the OID 1114 parser built its Date from the wire text directly, so
'infinity'became'infinityZ'and then anInvalid Date. It now delegates every shape that is not a plainYYYY-MM-DD HH:MM:SS[.ffffff]to the driver's own parser, the way the newdateparser already did, and keeps the driver'sInfinity/-Infinity. Same for the newtimestamp[]parser, which would otherwise have taken the defect to arrays as well. BC timestamps and five-digit years, which are not ISO-8601 parseable either, now come back as Dates rather thanInvalid Date.Above it,
parseRowre-coerced any non-Date, non-array value on a date-typed column, so it took the driver's numberInfinityand ranparseDbDate(String(Infinity))=parseDbDate('Infinity'), reintroducing the Invalid Date on every scalar read path (top-level, join relations and batched relations alike). Arrays escaped only because they took an earlier branch, which is why the array form looked correct while every scalar read was not. Both halves are fixed, and the regression test asserts throughfindManyagainst a live database rather than through the parser, because a test at the parser seam passes while the ORM is still broken.The coverage claim above is wrong on two counts, annotated rather than rewritten. The join and positional strategies were NOT fixed (they see the JSON string
"infinity", not the driver's number, and kept returning an Invalid Date), and the reading this shipped, the numberInfinityon a field declaredDate, was itself a defect. Both are addressed in 0.55.0, where the value reads asnull. -
turbine studiorenders zone-lessdate/timestampcells the way the application reads them. Studio builds its own raw pool and never constructs aTurbineClient, so it kept the driver's local-zone parsers: east of UTC adatecell displayed as the previous evening while the app reading the same row saw UTC midnight, and in--writemode that displayed value is what an edit echoes back into the column. It now registers the same UTC parsers, from one shared helper so the registrations cannot drift.turbine mcp, which also builds a raw pool and serializes sampled rows, gets the same treatment.
Added
-
PlanCacheModeis exported from the package index, so the option's type can be named directly instead of throughNonNullable<TurbineConfig['planCacheMode']>. -
planCacheModeclient option ('auto' | 'force_custom_plan' | 'force_generic_plan', Postgres only). PostgreSQL promotes a named prepared statement to a generic plan on its sixth execution, and a generic plan is costed blind to the bound values. A predicate whose selectivity varies wildly per value (the canonical case is atenant_idequality on a shared table, where one value matches a handful of rows and another matches most of them) is then planned for the average value and never reverts, so a sparse tenant can be locked onto a plan chosen for a dense one.planCacheMode: 'force_custom_plan'pins the backend's choice and removes that cliff.Which statements this reaches, measured rather than assumed:
count()on such a predicate is promoted after five executions and is controlled by the option.findMany/findFirstbindLIMIT $n, and a parameterized limit denies the planner the limit fraction that makes a skewed plan look cheap, so those are much less exposed. Treat it as a targeted remedy for a statement measurably slower after its fifth execution, not a general speed-up.The two sentences about
LIMIT $nabove are FALSE. Left in place because released history is not rewritten, annotated because acting on them leaves a real query unprotected. A bound limit does not deny the planner a limit fraction, it substitutes a default of 10% of the child row estimate, and an unknownOFFSETtriggers the same substitution even when the limit is a constant. Nor is the sixth execution a trigger:autopromotes only when the generic plan's estimated cost is not worse than the average custom cost. See the 0.55.0 entry for the measured rule and the fixtures.Applied as a connection parameter (
options=-c plan_cache_mode=...) when the pool opens a connection, so it is in force for that connection's first statement and for every checkout,$transaction, stream and pipeline on it, and it cannot race the caller's first query. The default isundefined, in which case Turbine issues nothing and behaviour is byte-identical to before. The value is a closed enum validated at construction (a GUC value cannot be a bind parameter, so the enum check is the boundary); anything else throwsValidationError(E003). Engines whose dialect does not report the newsupportsPlanCacheModecapability throwUnsupportedFeatureError(E017). On an externally supplied pool, where the caller owns connection lifecycle, it is a documented no-op with a dev-mode warning, the same ownership rule the type parsers follow; Turbine-owned stringreplicason that same client are Turbine's own connections and do get it, which the warning says.Two limits worth knowing before you enable it. The capability flag can only speak for the dialect: a Postgres wire-compatible engine driven through the default dialect (CockroachDB, YugabyteDB, a pre-12 PostgreSQL) has no
plan_cache_mode, and refuses the connection parameter itself withunrecognized configuration parameterrather than raising E017. And behind a connection pooler that filters startup parameters (PgBouncer'signore_startup_parameters), set the GUC on the role instead (ALTER ROLE ... SET plan_cache_mode = ...). Where the option is unset, nothing is sent and nothing changes. An existingPGOPTIONSor a?options=...already on the connection string is preserved and appended to, never replaced.
0.53.0 (2026-07-27)
The release that finally finds the bug 0.50 and 0.52 both aimed at and missed. Three consecutive releases fixed something real in the temporal write path, and the reported symptom survived every one of them, because the defect was never in the coercion itself. It was in which spelling of a key reaches it.
- A write whose data key is spelled as the COLUMN name skipped every value
coercion. Turbine resolves a data key to a column in two places, and they
did not agree. The SQL builder's
toColumnaccepts both the field name and the snake_case column name (it falls back tocamelToSnakevalidated against the reverse map). The write-value coercion resolved the same key throughcolumnMapalone, which maps field name to column name and knows nothing about the column spelling. So{ last_run: date }produced correct SQL with a completely uncoerced value, while{ lastRun: date }was correct. The temporal rewrite was the visible casualty (a zone-less column stored the process's local calendar fields), but the gap was total: array casts, JSON encoding, every per-column transform was skipped on that path. Both callers now share oneresolveColumnNameresolver, so the value a column receives no longer depends on how its key was spelled. findUniquecould returnnullfor a row thatfindFirstfinds. The simple-where fast path pushed operands straight onto the params array while the general where walker routed them throughcoerceWhereOperand. On a zone-lesstimestamp/date/timecolumn that is the same defect as above, on the read side: the same predicate against the same row found it one way and missed it the other, with no error. Both the build path and the cache-hit collector now coerce. The emitted SQL and the cache key are unchanged, so this is a value-only fix.- An unknown key in the client config was silently ignored.
logParams,redactParams, and every other near-miss did nothing at all, which reads as "the option is on and has no effect" rather than "the option does not exist". Unknown keys now warn once per key name (outside production) with a suggested correction, including the subsequence case that plain edit distance misses (logParamssuggestslogQueryParams).
Added
- Scalar
havingfilters, andAND/OR/NOTinsidehaving. AgroupByfield entry previously accepted only an aggregate filter ({ _sum: { gt: 100 } }). It now also accepts a filter on the grouped value itself ({ categoryId: { not: null } },{ status: { in: ['a', 'b'] } }, or a bare value as equality shorthand), matching Prisma, and both forms may appear in the same object, ANDed. A scalar filter is legal only on a column listed inby, since a non-grouped column cannot be referenced inHAVINGat all; anything else throwsValidationError(E003) naming the column instead of emitting SQL the database will reject._min/_maxare no longer typed as numeric-only either, because they return a stored cell:MIN(title) > 'm'is as valid asMIN(views) > 10. $extendsonprisma-compat. The client and model extension components, in both the object andPrisma.defineExtensioncallback forms, returning a new client rather than mutating the existing one.resultandquerycomponents are refused AT$extendstime with an explanation and the alternative, rather than being accepted and silently ignored at the first query.
Changed
- The relation-strategy and unbounded-read warnings state the mechanism, not
just the condition. Every one of them now follows the same shape: what
triggered it, why that costs what it costs, the fix, and the escape hatch. The
autoto-one demotion said only that no covering index was found, which reads as a bug report about a missing index when the decision is a cardinality trade that stands even on a unique index. The_countdemotion did not say that an inline_countis a correlated subquery re-evaluated once per parent row. The unbounded-read warning said the query "will fetch every row" and left the memory cost implicit, which is the part that actually bites.
0.52.0 (2026-07-26)
A correctness release, and an unusually self-critical one. Two of its four headline items are corrections to things this project shipped and documented wrongly in 0.50, not defects found in someone else's code.
- A
Datecould still be stored in the process's local time zone. 0.50 fixed the binding but resolved the column's database type from ONE of the two places metadata carries it. Metadata that fills the table-leveldialectTypes/pgTypesmaps but not the per-column entries resolved to nothing, the rewrite silently no-opped, and the driver stored local calendar fields. A turbine-only round trip HIDES this, because the read path shifts back by the same offset: the ORM reports the value you wrote while the column is off by hours to psql, to another ORM, or to a BI tool. utcTimestamps: falsenever reached the write path at all. The flag was declared optional on the builder context and never copied onto it, so both consumers evaluatedundefined !== falseand read that as opted IN. Setting it therefore produced the worst available combination: reads stopped pinning UTC while writes kept rewriting to it. It is honored now, which means anyone already setting it writes DIFFERENT TEXT after this upgrade.- The 0.50 note about the
autostrategy and relation_countwas wrong twice. It asserted that a groupedCOUNT(*)scans the child table once whether it runs inline or batched. An inline_countis not a grouped scan, it is a correlatedCOUNT(*)re-evaluated per parent row. And the change it announced as a fix was a regression: it pinned bounded queries to the plan that measures 12.9x slower at 30 parent rows and 1,093x slower at 10,000. Reverted, with the crossover measured rather than assumed. - On PowDB below 0.20, every comparison against a
datetimecolumn returned wrong rows. A timestamp literal binds as an integer, and the engine compared type tags rather than values, so a filter matched every non-null row, an equality matched none, and the answer changed with the access path. The exposure is narrow (turbine's own DDL never emitsdatetime), but where it applied it was silent.
Around those: create({ data: {} }) emitted invalid SQL, createMany silently
wrote NULL over a column default for any row whose shape differed from the
first, relation _count key order was a race between concurrent queries, and
three of the five engines could not set half the client config.
Added
- PowDB 0.20.0 support. Two version-gated capabilities resolved from the
live version probe: comparisons against a native
datetimecolumn, and the per-field_countform (count(T { .col })), which upstream changed from a row count to a non-null count. The_countgate applies only to a NULLABLE column, because on a NOT NULL column the row count IS the non-null count and those calls were always correct. Turbine also now compiles adatetimein/notIninto an equality chain ((.ts = $1 or .ts = $2), and!=joined byandplusis not nullfor the negated form), because the upstream 0.20 fix covered the binary operators but NOT the list forms: a rawinstill matches nothing and a rawnot instill matches everything. That is the exact shape relation filters and the batched loaders emit, so it would have been a silently empty or silently widened relation. PowQL spends one nesting level per chain term, so lists are capped at 32 values and the loaders chunk to the same width. The tested lexer ceiling moves to 0.20, verified byte-identical upstream rather than assumed. logQueryParams(client config). Query-event parameters are redacted by default, and the only previous way to see them waserrorMessages: 'verbose', which also un-redactsNotFoundErrormessages. So "parameters visible, error messages still safe" was unreachable.logQueryParamsderives fromerrorMessageswhen unset, so the default and the old spelling both still work.scopedConnectgets a threat model in the docs, not just a bullet. With it off (the default), a handler that forwards a client-supplied id into a nestedconnecthands any caller a cross-tenant write primitive.create({ data: {} })inserts a row of defaults on every SQL engine (DEFAULT VALUESon PostgreSQL and SQLite,() VALUES ()on MySQL,OUTPUT INSERTED.* DEFAULT VALUESon SQL Server), through a new dialect hook. It previously emittedINSERT INTO t () VALUES ()and failed with 42601. A handler building its payload from optional fields produces{}on a legitimate request. The multi-row all-defaults form raises a typed E017 on SQLite and SQL Server, which cannot express it, rather than emitting SQL the engine rejects.
Fixed
- A column's database type is resolved from both places metadata carries it.
col.dialectType ?? col.pgType ?? tableMeta.dialectTypes[name] ?? tableMeta.pgTypes[name]. A column indateColumnsthat still resolves to nothing now raises a once-per-table dev warning naming the columns, because that is the residual silent-wrong-write shape. Migration: if your metadata carries types only in the table-level maps (hand-written, converted, or produced by a tool rather than byturbine generate), your zone-lessdate/timestampcolumns have been storing local calendar fields. Establish the boundary before shifting any history: upgrade, run in dev, confirm the new warning is silent, and only then decide which rows predate the fix. utcTimestamps: falseapplies to writes and where-clause binds. Migration: the flag is genuinely per client on the write side, but the read side is a pg type parser on OID 1114, which is process-global and settled by the first turbine-owned client. Those cannot be made symmetric without changing every non-ORM read on the same pg module. So a process that builds two turbine-owned clients with OPPOSITE values now raisesValidationError(E003) at construction rather than silently producing a client whose reads and writes disagree. Give every client in a process the same value, or isolate the odd one. Clients on an external pool register no parser and are exempt.- Relation
_countkey order is deterministic again. The batched loader ran its per-relation follow-ups throughPromise.alland assigned each key on completion, so insertion order was a race: the same query on the same data produced up to 9 distinct key orders across 12 runs. Anything deriving an ETag, a cache key, or a snapshot fromJSON.stringifyof the payload saw different bytes per request. Relation keys and_countentries are now seeded in the join plan's own order before any query runs, so the batched, auto, and join outputs are byte-identical. Note this is a different axis fromstableRelationOrder, which governs row order INSIDE a relation array. createManysilently wrote NULL over column defaults. It derives its entire column list from the first row, so a field a later row omitted was written as NULL over that column's default, and a field only a later row named was dropped and never reached the database. Both directions now raise E003 naming the row index and the differing columns. An explicitundefinedcounts as omitted, matching single-rowcreate. Migration: nested writes andprisma-compatsplit mixed-shape arrays into contiguous same-shape runs automatically, soposts: { create: [...] }and a compatcreateManykeep working and now apply column defaults correctly (Prisma binds literal NULL where a default exists only in the database, so this is strictly better). A DIRECTdb.t.createManywith mixed rows still refuses, because it must remain one statement forpipeline()and the$transaction([...])array form. Split the call, or name the field on every row.- Relation
_counton an unindexed foreign key batches again, from two parent rows up. Measured on a 200,000-row child table: batched wins 1.73x at 2 parents, 12.9x at 30, 311x at 1,000, and 1,093x at 10,000.EXPLAIN (ANALYZE, BUFFERS)at 30 parents shows 50,013 buffers inline against 1,727 batched,loops=30withRows Removed by Filter: 199980each time. An INDEXED_countis untouched and stays inline at every size (inline wins 1.30x to 2.06x), because the per-parent subquery collapses to an index-only scan. No knob: choosing batched wrongly costs one round trip once, choosing inline wrongly costs 31 seconds, andrelationLoadStrategy: 'join'already forces the single statement. - The engine factories dropped half the client config. SQLite, MySQL, and
SQL Server built their client from a hardcoded key allowlist, so
errorMessages,globalFilters,scopedConnect,utcTimestamps,implicitPkOrdering, the SQL-cache options, and the newlogQueryParamswere unreachable on those engines. Forwarding is now by exclusion, so the next option added toTurbineConfigworks everywhere on the day it lands. - The
prisma-compattransaction client had no raw SQL.$queryRaw,$queryRawUnsafe,$executeRaw, and$executeRawUnsafeexist on the transaction surface now, bound to the transaction's own connection (proven by a raw read seeing an uncommitted delegate write), sharing one factory with the client-level surface so the two cannot drift. - The many-to-many junction accessor warned about itself. An m2m pair is declared on both sides, so the second visit found the junction's own registration and reported a collision on an accessor that existed and worked. Genuine collisions still warn once.
- PowDB nested-projection
datetimechildren came back as raw micro-second strings. Micros exceed safe-integer range so the engine renders them as JSON text, and the coercion handled only numbers. Nested projections are the default on engine 0.18 and above, so this was the default path. - Five tracked source files contained literal NUL bytes, each a Map-key or
join separator written as a raw character rather than an escape sequence
(
src/powdb.ts,src/index-advisor.ts,src/query/compound-unique.ts, and the Studio UI source and its generated module).grepandfileclassify such a file as binary, so a search over it returns nothing and looks like a clean miss rather than an error. The runtime values are unchanged; the bytes are now escapes. Present since 0.48 in the PowDB case and longer in the others.
Changed
- PowDB
createManyrequires every row to name the same fields, matching the SQL engines. PowQL tuples each carry their own column list, so ragged rows were actually correct there; the alignment is for portability, so that moving a codebase from PowDB to Postgres does not meet a new hard error. Migration: split the call, or name the field on every row. - PowDB storage-integrity failures are
ConnectionError(E004), not E003. A corrupt page, corrupt catalog, or CRC mismatch is an open-time integrity failure whose only recovery is restoring from a backup, not a query defect. Migration: code catching E003 for storage failures stops matching. - PowDB
limit: 0returns[](it previously returned one row through a projection fast path) and a negativelimitoroffsetraises E003 client-side rather than being ignored. TransactionClient.rawQueryis@internal. It is the seamprisma-compatdetects by shape. It takes a prebuilt SQL string, so escaping is the caller's problem, which is exactly whattx.raw's tagged template exists to prevent. It still exists and still typechecks, so nothing breaks; it is no longer advertised. Note that transaction-scoped raw SQL (tx.rawandtx.rawQuery) emits no$on('query')event and runs no middleware or timing.PrismaCompatTransactionClientis now an intersection type including the raw surface and the lowercased alias delegates (the aliases already existed at runtime; the type omitted them). Migration: type-level only. A hand-built transaction-client stub typed against the old shape needs the raw methods added.- The
prisma-compatSQL-fragment marker is a module-private symbol rather than a global-registrySymbol.for, so a fragment cannot be forged from elsewhere in the process. The check fails closed either way (an unrecognized fragment binds as a parameter rather than splicing), so this is hardening.
Documentation
- The relations page taught the wrong model of
_count, and it caused a bug report. It stated that a groupedCOUNT(*)scans the child table once whether inline or batched. It does not: the inline form is a correlated subquery per parent row. A reader believed the page and filed a report repeating its reasoning back to us. The page now leads with the correlated shape, carries the measured crossover and theEXPLAINnumbers, and names the two remedies. - "Resolves in one SQL statement" is now scoped to
relationLoadStrategy: 'join'on the queries, quickstart, and agent-facing pages. It has been inaccurate under theautodefault since 0.41, and the new_countthreshold widens it further. The agent-facing page additionally tells agents not to assert on statement counts unless a strategy is pinned. - A PowDB benchmark inference was over-generalized. Upstream re-measured its
own numbers and an indexed point lookup published as 3.0x FASTER than SQLite
is 7.9x slower. Our page correctly cited that, then used it to explain all
three of our read rows, when upstream measured one workload and ours differ by
harness, hardware, row count, and durability mode. The claim is scoped to
findUnique;findManyand nestedwithare stated as un-isolated between engine drift and host noise, and the benchmark record file now agrees with the public page instead of contradicting it. - The README's PII claim said enforcement is "in the SQL on every engine". PowDB
is the exception: its
returningtakes no column list, so the strip is client-side after the values cross the wire. That distinction matters when the claim is a security property.
0.51.0 (2026-07-26)
A read-correctness release. Two things in it silently returned wrong data, and both were found by porting a real application onto the library rather than by reading the code:
- An unrecognized JSON filter operator compiled to no predicate at all.
{ path: ['title'], string_contains: 'x' }(the Prisma spelling, which turbine did not have) returned every row of the table, and inside anANDit dropped that conjunct, so a tenant scope written that way widened to the whole table. The same typo on a scalar column had always thrown. Unknown keys are refused now. Reads only, mutations were never at risk. - Values read through a
withrelation could differ from the same values read at top level, on every engine except Postgres. A nested relation is built as JSON, and a JSON number is an IEEE double: SQLite and MySQL and SQL Server all rounded 64-bit integers, MySQL flattenedDECIMALto a float, and binary columns came back as base64 text or failed the query outright. Postgres was fixed in 0.50; the other three are measured and fixed here, so a top-level read, thejoinstrategy, and the batched loader now agree everywhere.
Around those: update({ data: {} }) emitted invalid SQL rather than doing
nothing, createMany into a table with an array column failed outright,
orderBy through more than one relation hop was refused, and MySQL and SQL
Server introspection ran every query against a pool the finally had already
closed. orderBy and nested select / omit are key-checked at compile time
now, the same way where became in 0.50.
Added
stringContains/stringStartsWith/stringEndsWithon JSON path filters, withmode: 'insensitive'. These match against the text at a JSON path. They are deliberately not calledcontains, which on a JSON column already means@>containment. Prisma'sstring_containshas no equivalent spelling in turbine, and the new strictness error names these when it sees it.orderBythrough multiple to-one relation hops.orderBy: { model: { category: { name: 'asc' } } }threw E003 before; only a single hop compiled. Each additional hop becomes anINNER JOINinside the SAME correlated subquery, so an N-hop chain still costs one subquery with oneLIMIT 1rather than N levels of nesting. Every hop must be to-one: a to-many hop is refused with a message pointing at{ pick, by }or_count, because it has no single value to order by and picking an arbitrary row silently is worse than an error. Each hop applies its target's global filter to the join condition, so ordering never keys off a soft-deleted or other-tenant row. Depth is capped like nestedwith.updatedAt: true/.updatedAt()sets a column to the current time on every update that does not name it (Prisma's@updatedAt). Opt-in per column and NEVER inferred from a column name, so an application already managing its own timestamp is untouched and an untagged schema emits byte-identical SQL. The timestamp is client-side, so it flows through the same UTC coercion as any other boundDate.scopedConnect(client config, off by default) refuses a nestedconnect/connectOrCreatethat would re-parent a to-many child already owned by a different parent.connect: { id: 42 }takes row 42 unconditionally, so a handler forwarding a client-supplied id hands any caller a cross-tenant write primitive, closable only by a hand-rolled check at every call site. Unowned children and idempotent re-connects still succeed. hasMany and hasOne only: abelongsToconnect takes nothing from anyone, and an m2m connect adds a junction row rather than moving one.relationNames, a per-table rename map for DERIVED relation names, on the introspection config AND as aturbine.config.tskey threaded to every CLI introspect call site (nobody introspects by hand; a port runsnpx turbine generate). A port from another ORM becomes a mechanical mapping instead of a hand edit at every call site. A typo, an unknown table, or a name that would shadow a column is a typed E003, not a silent no-op: ignoring it would break the very call sites it exists to fix, at runtime.- Relation names in the "unknown field" error. The message listed only
columns (
Known fields: id, name.), so a user who guessed a relation name wrong concluded that relations are not valid inwhereat all. It now lists relations too, marks them as valid inwhereandwith, and suggests the closest match, with containment ranked above edit distance because the real miss is a longer guess likemodelVersionsfor a relation derived asversions. Naming a relation where a column is expected says so and shows the nested form.
Fixed
- An unrecognized JSON filter operator was silently dropped. See the summary
above. Unknown keys and a
pathwith no comparison now raise E003, on the SQL builder and PowQL alike, and the check runs on the cache-hit param-collect path too so a warmed cache cannot skip it. Migration: a filter that was silently matching everything now throws. The common case is a Prisma spelling:string_containsbecomesstringContains,string_starts_withbecomesstringStartsWith,string_ends_withbecomesstringEndsWith. The error names the replacement. - Values diverged between read paths on SQLite, MySQL, and SQL Server.
Measured, not assumed, per engine: SQLite
INTEGER9007199254740993 came back 9007199254740992 throughjoinandBLOBfailed the query outright ("JSON cannot hold BLOB values"); MySQL roundedBIGINTthe same way, returnedDECIMAL'1000.50'as1000.5, and returnedVARBINARYas the literal stringbase64:type15:AQL/; SQL Server roundedBIGINTand returnedVARBINARYas base64 text. The Postgres fix does not port directly (it decodes via pg OIDs), so the cast/decode pair is now a dialect hook,jsonWireRule, and each engine states its own divergent set. Postgres keeps its exact behavior through it. Migration: if you read a bigint, decimal, or binary column through awithrelation on one of those three engines, the values you get back change with this upgrade, to the correct ones. Code that worked around the old behavior (parsingbase64:type15:…, re-fetching a bigint at top level) should be removed. update({ data: {} })emitted invalid SQL. It compiled toUPDATE t SET WHERE …and failed with Postgres 42601. Any handler building a payload from optional fields produces{}on a legitimate request. It is a no-op returning the current row now, matching Prisma. The empty-where guard andNotFoundErrorstill apply, andoptimisticLockstill emits a realUPDATE.updateManyreportscount: 0and issues no statement.createManyinto a table with an array column failed. The PostgreSQL bulk path is a column-majorUNNESTtranspose, andunnestflattens, so N rows oftext[]arrived as one flattext[]and failed with 42804. Those tables use the row-majorVALUESform now; tables with no array column keep the byte-identicalUNNESTform.- MySQL and SQL Server introspection queried a closed pool. Both
introspectMysqlandintrospectMssqldidreturn somePromiseinside atry/finallywhosefinallyclosed the pool, so the pool closed before the promise settled. MySQL introspection failed outright. warnOnUnlimitedfired on reads that can match at most one row. It no longer warns when thewherepins a full primary key or unique column set to literal values. Conservative: an operator object, a null, anOR, or a partial key all still warn. A warning that cries wolf on correct code teaches people to disable it.
Changed
orderByand nestedselect/omitare key-checked.wherebecame key-checked in 0.50.0, but these stayed open records, soorderBy: { nmae: 'asc' }andwith: { posts: { select: { titel: true } } }compiled and failed at runtime with E003. All four (including nestedorderBy) are now checked against the right entity:orderByagainst the table's columns AND its relations, and each nested block against the RELATION TARGET rather than the parent. A clause built dynamically still assigns, because an index signature satisfies each optional target key, so computedorderByis not broken. The types degrade to the open record when the entity or its relations are unknown (db.table(name), or a client generated before the relation brand), matching howTypedWithClausealready degrades. Migration: this is a compile-time break only, and every error it produces was a runtime E003 before. Fix the misspelling, or regenerate if your client predates the relation brand.- The deferred
build*family now carries itswithgeneric into the return type.buildFindMany/buildFindUnique/buildFindFirstand theirOrThrowvariants took the generic and discarded it, so apipeline()result lost its relations. They returnDeferredQuery<QueryResult<…>>now, the same type their async counterparts do. Migration: a hand-written annotation on abuild*result that omitted the relations may now be too narrow; remove the annotation and let it infer. - Every em-dash is gone from the tracked source, docs, and site, 3,253 of them across 254 files. No behavioral content.
0.50.0 (2026-07-25)
A correctness release, and the widest one so far. Four things in it are worth reading before you upgrade:
- A nested
belongsToupdate could rewrite every row of the related table. ANULLparent foreign key compiled torefKey IS NULL, which matches every related row with a null reference key, and the nesteddatawas applied to all of them. This is the reason to upgrade. - A
Datewritten to adateortimestampcolumn was stored with the process's local offset, so outside UTC the stored value was wrong and atimestampcolumn did not round-trip. Fixing it changes stored values for affected users: see the BREAKING entry under Changed, which tells you how to check whether you are one. - Studio's PII predicate guard had three holes, and its depth limit failed
open, so a query could read a redacted column through a relation filter,
through
cursor/distinct, or by nesting past the cap. - Many-to-many nested writes exist.
connect/disconnect/seton an m2m relation, which before 0.49 reported success and wrote nothing at all, are implemented rather than merely refused: the oldest silent-write gap in the library.
Around those: an unvalidated pagination argument could turn a paginated read into a full-table read on Postgres, and a misspelled update operator could write JSON text into a scalar column. Both are hard errors now. Every behavior that changes in a way an existing app can notice is listed under Changed with the exact error and the concrete fix.
Added
- Many-to-many nested writes:
connect,disconnectandset. m2m has never had a nested-write implementation on any engine. Before 0.49 the write fell off the end of the relation dispatch, sodb.posts.update({ where: { id: 1 }, data: { tags: { connect: [{ id: 7 }] } } })returned a row and reported success while writing no junction row at all; 0.49 turned that silence into aValidationError. It now writes the junction rows, in the same transaction as the parent write, on every engine (the shared nested-write engine backs the SQL dialects and PowDB alike).connectis idempotent, by the strongest means each engine offers: where the junction constrains the pair and the dialect supports it (PostgreSQL, SQLite, MySQL), the insert goes throughcreateMany({ skipDuplicates: true }), so the engine itself dedupes and two concurrent transactions connecting the same pair cannot both insert. Where that is unavailable (SQL Server and PowDB refuse it, and an implicit junction need not declare the constraint it fires on), it falls back to reading the parent's existing links for exactly the named targets inside the transaction and inserting only the missing ones. Target selectors resolve in ONE query for the whole list rather than one query per target, and are compared by a normalized key, so a junction column that a table parser hands back as a string cannot fail to match the number the parent write returned and insert a duplicate.disconnectis scoped by BOTH the parent key and the named targets, so it can neither clear the parent's other links nor touch another parent's rows, andsetreplaces the whole set (set: []clears it). Oncreateonlyconnectapplies (disconnect/setare update-only on every relation type) and the junction rows are written after the parent insert, since they carry its key. A target selector that matches no row is refused rather than skipped, a composite junction key is refused rather than partially written, and a junction whosesourceKeyandtargetKeyname the SAME column is refused rather than writing a link row that cannot mean what it says. - Junction-table accessors on the
prisma-compatclient. Prisma's schema has no model for an implicit junction, soPRISMA_MAPhas no entry for one and the compat client exposed no accessor: the escape hatch the m2m error message recommended was unreachable, and following it literally meant a second transaction on the core client, breaking the atomicity the advice asked for. Every many-to-many junction table in the Turbine metadata now gets an identity delegate (same table, no renames, no relations), built from one shared list so the accessor exists both on the client and inside$transaction. The accessor is an escape hatch and never worth breaking a real member of the client for, so a colliding junction name is skipped rather than installed: a Prisma model name, a table some model maps to, a model's lowercased property alias (compat.userformodel User), and the client's own$transaction/$queryRaw/$connectfamily all keep their key. Every skip that costs the caller a capability warns once in dev. implicitPkOrdering(client config, defaultfalsein core). AfindManythat paginates with noorderByis ordered by the table's primary key ascending (every column of a composite key, in declaration order). An explicitorderBywins and a PK-less table is untouched. Acursorquery is ordered on the CURSOR's own field rather than the primary key when the two differ: a seek on column X ordered by column Y walks the table in an order the seek does not follow, which is no better than no order at all. Two shapes are deliberately left alone:distinct(an addedorderBychanges which representative rowDISTINCT ONpicks, so it would change results, not just their order) and a MULTI-field cursor, wherea > $1 AND b > $2is a conjunction rather than a composite keyset seek and no single ordering makes it sound. Both still warn. Off by default in core because switching it on rewrites SQL that existing applications already emit.turbine-orm/prisma-compatapplies the same ordering unconditionally, with no flag to set (see Changed).- A dev warning for an unordered paginated
findMany. An unorderedLIMITis not stable in Postgres: as the heap changes underneath it the same query can return different rows, so a row can appear on two pages or on none. The warning names the table, the pagination shape, and the exactorderByto add, and fires once per table and shape. Acursorcounts as pagination and is the worst case rather than an exception:WHERE id > $1 LIMIT $2with noORDER BYis precisely this bug, and the warning names the cursor's field rather than the primary key when they differ. It is gated exactly likewarnOnUnlimited(per-call, per-table, then the global flag), silent underNODE_ENV=production, and suppressed only whenimplicitPkOrderingwill actually order the query, so a PK-less table and an ambiguous multi-field cursor still hear about it with the flag on. autoToOneJoinMaxRows(client config, default 1000). The parent-row ceiling for the'auto'strategy's new to-one rule (see Changed).turbine migrate-from-prismareads the connection string yourdatasourceblock declares, including itsenv("NAME")indirection, so a project whose schema saysurl = env("DATABASE_URL_STAGING")needs no--urlflag. Precedence is unchanged where it already existed and the datasource is last:--url, thenDATABASE_URL, thenurlinturbine.config.ts, then the datasource (url, thendirectUrl). A literalurl = "postgres://..."works too. When nothing yields a URL, the existing error gains a fourth suggestion naming the exact variable the schema asked for.- The migration report resolves your many-to-many call sites for you. A
migration audit that greps the Turbine relation names (
grep -rn "manyToMany" generated/, then searching for the names it prints) cannot find anything: application code written against the compat client uses the PRISMA field names, and the two are related only throughPRISMA_MAP. That is true of every compat integration, so the recipe reports a clean audit no matter how many m2m writes a codebase has.prisma-migration-report.mdnow has a "Many-to-many relations (audit these call sites)" section listing every m2m relation with BOTH its Prisma field name and its Turbine relation name plus the junction table, and a ready-to-rungrepover the Prisma names. In--no-dbmode it says the list needs a database run rather than printing an empty one. relationLoadStrategy: 'flatten', a fourth relation plan. An eligible to-one relation compiles to aLEFT JOINover a derived table inside the same statement instead of a correlated subquery: one round-trip, no per-parent re-evaluation of the subquery, and no client-side stitching. The whole to-one subtree collapses into a single derived table that exposes only prefixed column names (f0__id,f1__code, …), so a child column can never collide with a parent column of the same name, and the nested object is reassembled client-side to a result deep-equal to the join strategy: same shape, same camelCase keys, sameDatecoercion. The match discriminator each node carries is deliberately value-free, so a PII-tagged key column never reaches the wire, and the correlation columns the outerONneeds are never projected to the caller. It is an explicit opt-in at the client or on a single query;'auto'is unchanged and never selects it. Cache keys carry a plan signature, so every pre-existing cache key stays byte-identical. Eligibility, which matters because an ineligible relation falls back silently rather than erroring:belongsTo/hasOneonly, where the target-side correlation columns are PROVABLY unique, meaning an exact set match against the target primary key, a declared unique constraint, or a full unique index that is neither partial nor an expression index. The proof is exact set equality, so a unique index on(a, b)does not prove(a). The relation must also carry nolimitand noorderBy, contain no nested_count(a top-level_countis fine and stays a correlatedCOUNT(*)), and sit under the same depth cap of 10 the subquery path uses. Inside an eligible relation these all work: a relationwhere, target global filters,select/omit, to-one chains to the depth cap, self-relations, and a nested to-many, which stays a correlated subquery hanging off the joined node. Fallback is per relation, so one ineligible relation does not stop the others. Four conditions disable flattening for the whole query instead:distinct,jsonEncoding: 'positional', SQL Server (which has its ownFOR JSON PATHrelation compiler), andfindUnique, which never plans one.findFirstdoes, since it routes throughfindMany. Runs on PostgreSQL, MySQL and SQLite; PowDB has its own relation path and is unaffected. Performance, stated exactly: measured on 9,200 parent rows against local PostgreSQL,'flatten'runs 1.33x faster than'join'on a shallow to-one and 1.56x on a two-deep to-one chain, and loses to'batched', which is 2.83x faster than'join'on the same shape. That ordering is structural rather than a defect: with 2,000 distinct targets behind 9,200 parents the join transmits the target's columns 9,200 times while the batched loader transmits 2,000 rows once, and the gap closes as cardinality approaches 1:1. Local round-trip time of about 0.1 ms also favors the batched loader's extra round-trip more than a real network would. So the claim for'flatten'is one round-trip, transaction-trivial, no client-side stitching, and strictly better than'join'on large to-one parent sets. It is not the fastest plan, and that is why it is deliberately not wired into'auto'.whereis key-checked at compile time. On a generated, typed client an unknown key in awhereclause is now a type error rather than a silently ignored one:where: { emial: 'x' }used to compile, match nothing in the emitted SQL, and hand back the whole table. The check follows the clause wherever it nests, including insideAND/OR/NOT, inside a relation filter at arbitrary depth, and inside a nestedwithblock's ownwhere. No generator change was needed and no regeneration is required: it threads theRelationDescriptorbrand the code generator has emitted on*Relationsinterfaces since 0.7.1, so an existing generated client picks it up on upgrade. The change is purely type-level and the emitted SQL is byte-identical. It is still deliberately permissive in four places, listed so nobody reads a clean compile as full coverage: (1) a client with no relations map, meaning adefineSchema-only client or an untypedclient.table(name)call site, keeps the historical open-keyed clause because there is no relation type to thread; (2) a legacy generated client whose*Relationsmembers are bare types (posts: Post[]) rather than brands has its relation KEY checked but not its VALUE; (3)orderByeverywhere, andselect/omitinside awithblock, remain open-keyed, though top-levelselect/omitare checked; and (4) the deferredbuild*variants used bypipeline()take the entity type only, so theirwherestays open-keyed. Every awaitable method is checked:findMany,findFirst,findFirstOrThrow,findUnique,findUniqueOrThrow,findManyStream,update,delete,upsert,count,updateMany,deleteMany,aggregateandgroupBy. Because the guarantee is type-level, a transpile-only runner such astsxwill not surface it;tsc --noEmitis the gate.
Fixed
- A JS
Datewritten to atime/timetzcolumn was rejected outright. The driver serialized it as a full ISO timestamp with the process offset (1970-01-01T04:00:00.000-05:00), and Postgres answered22007 invalid input syntax for type time, so a PrismaDateTime @db.Time(6)field had no working write path and no compat-layer workaround. ADatebound to a time-of-day column is now narrowed to a time literal built from its UTC components (Prisma's choice, and the only one that round-trips regardless of where the process runs), with an explicit+00:00ontimetzso the session'sTimeZonecannot be attached instead, and fractional seconds only when non-zero. Applied on every write path (create, createMany, upsert, and the updatesetclause) including the cache-hit param path, and onwheretoo: filtering a time column by aDateraised the same22007, so the column had no working read path either.createManyneeded a second fix on top of the value narrowing: the per-column array cast had no entry fortime/timetz, so the value list fell through totext[]and Postgres answered42804 column "start_at" is of type time without time zone but expression is of type texteven though every literal in it was valid. The cast table now covers every Postgres type that has no assignment cast fromtext(time and interval types, the network / geometric / range / multirange families,tsvector,citext,money,vector);varchar/charkeep theirtext[]cast, which is correct and already-emitted SQL.timeandtimetzare deliberately still notdateColumns: they have no date part and still read back as strings. Every other column type emits byte-identical SQL and params. - BREAKING: a
Datewritten to adateortimestampcolumn was stored with the process's local offset. The driver serializes aDateusing the process's calendar fields, and a zone-less column keeps whatever fields it is handed, so the value stored depended on theTZof the machine that wrote it. Writingnew Date('2026-07-25T00:00:00Z')to atimestampcolumn from a process inAmerica/Los_Angelesstored2026-07-24 17:00:00, and reading it back gave2026-07-24T17:00:00Z, because Turbine's read path parses an offset-less value as UTC (utcTimestamps, on by default). Atimestampcolumn was therefore not round-trip stable anywhere but UTC, adatecolumn could land on the wrong day, and two app instances in different zones wrote different values for the same instant. Writes now bind theDate's UTC components, mirroring the read path exactly.timestamptzis untouched (it stores a real instant, and the driver's offset-carrying string is already correct for it), as is every non-temporal column. PostgreSQL only: MySQL and SQL Server bind these types through their own drivers, and MySQL reads a zone-less literal in the session time zone, where a UTC literal would be misread. See Changed for who is affected and how to check. - An empty
orderByemitted invalid SQL.orderBy: [](and an object whose every value isundefined) compiled to a bareORDER BYwith nothing after it:SELECT "posts".* FROM "posts" ORDER BY LIMIT $1, which Postgres rejects withsyntax error at or near "LIMIT". It carries no ordering, so it is now treated as absent, which is also what the implicit-ordering and unordered-page logic already assumed. This matters more than it did: "pass an explicitorderBy" is the documented way out of the implicit ordering above, and code that assembles that array conditionally ends up passing[]. prisma-compatpagination returned non-deterministic pages. Prisma appends an implicitORDER BY <primary key> ASCto a paginatedfindMany; the compat layer emitted a bareLIMIT, so a ported paginated endpoint silently inherited pages that can repeat a row or skip one as the heap changes underneath the query, and took the slower unordered plan. Compat now matches Prisma (see Changed).- The
'auto'relation strategy ignored cardinality. It kept the single-statement join whenever the correlation columns were indexed, but an index says nothing about how many parent rows the correlated subquery will be re-evaluated for. A to-one include on a large parent set is exactly the case where the batched follow-up wins, and'auto'was picking the slower plan. See Changed for the new rule. - The
'auto'strategy demoted relation_countfor nothing. A groupedCOUNT(*) ... GROUP BY fkscans the child table once whether it runs inline or as a follow-up, so moving_countoff the join plan because its FK is unindexed bought no scan and cost a round-trip. See Changed. - A nested
belongsToupdate could rewrite every row of the related table.processBelongsToUpdatederived itswhereby reading the parent's foreign key and comparing the related table's reference key to it. When the parent FK wasNULL, that compiled torefKey IS NULL, which matches every related row with a null reference key, and the nesteddatawas applied to all of them.db.posts.update({ where: { id: 1 }, data: { author: { update: { name: 'x' } } } })on a post with noauthorIdrenamed every author-less row it could reach. The operation now routes through the same correlation helper every sibling nested operation uses: aNULLparent FK points at nothing, so there is nothing in scope, and it throwsNotFoundError(E001) naming the relation. A target that exists but belongs to a different parent reports the same error rather than being written. This is the reason to upgrade. limit/offsetare validated on every path. An unvalidated bound was passed throughNumber(), soNaNbound as SQLNULL, and Postgres readsLIMIT NULLas no limit at all: forwarding an unvalidatedreq.query.limitsilently turned a paginated endpoint into a full-table read, and a badoffsetsilently vanished. This was never MySQL-specific; the inlined-literal path (MySQL) already checked, the parameterized path (Postgres, SQLite, SQL Server) did not. A singlepaginationValue()helper now validates every call site: top-levellimit/offset, per-relationlimit, the SQL-build path and the cache-hit param-collect path (a warmed template could otherwise bind an unvalidated value with the build path never running). Anything that is not a non-negative safe integer throwsValidationError(E003) naming the argument and the table.- A misspelled atomic operator was written into the column as JSON text.
data: { viewCount: { incremnt: 1 } }did not match a known operator, fell through to the plain-value branch, and stored the string{"incremnt":1}. It now throwsValidationError(E003) naming the unknown key and listing the supported operators. See Changed for the exact scope of the check. - Studio's PII predicate guard had three holes. Relation filters were walked
as if the wrapper (
some/none/every/is/isNot) were itself a clause, so a redacted column inside one was never inspected;cursoranddistinctwere not walked at all, though both name columns directly and both leak the hidden value (paging on it is an oracle just as filtering is); and the depth limit failed open, returning silently past depth 10 so a deeply-nested clause was simply not checked. The walker now handles relation wrappers and field-name lists, and the depth cap fails closed: past 32 levels the query is refused withValidationError(E003) rather than passed unverified. findManyStreaminside a caller transaction opened a second connection. It checked a connection out of the pool unconditionally, so a stream started inside$transactionran on a different connection and a different snapshot, outside the caller's transaction. Inside a transaction it now rides the caller's pinned connection, emits no transaction control of its own (ambientTransaction), and releases nothing, so the caller's transaction is intact when iteration ends. Outside a transaction the behavior is unchanged.- A read-only client's nested writes bypassed the read-only guard.
runInImplicitTxbuilt itsTransactionClientwithout passing the source pool, so the transaction-scoped proxy pool lost the pool'sreadonlyflag and its PowDB capability set. A read-only PowDB client's nested write skipped theReadOnlyError(E018) check, and an older-engine client fell back to the full capability set inside the implicit transaction and could emit PowQL the engine rejects. The pool is now threaded through. - A failed
BEGINno longer emits a strayROLLBACK. The implicit-transaction wrapper rolled back in itscatcheven when theBEGINitself had thrown, so a connection that never opened a transaction received aROLLBACK. - Prototype-chain lookups in metadata and operator maps. Relation names,
aggregate-function keys, vector metric names and Studio table names were read
with a bare index, so a key such as
constructorortoStringresolved to an inherited builtin and its source text could be spliced into the emitted statement. Every one of those lookups now goes through an own-property helper (or aMap, in the PowQL builders). - MySQL string-literal escaping doubled the quote but not the backslash.
escapeStringLiteralinherited the Postgres rule, but unless MySQL runs withNO_BACKSLASH_ESCAPESa\escapes the following character, so a value ending in a backslash could escape its own closing quote. The backslash is now escaped first. The only caller isbuildJsonObject(relation and column names from schema metadata), so this is defence in depth rather than a user-value path. - PowQL
havinginterpolated the caller's aggregate key. The function token was derived from the key by stripping its leading underscore and emitted verbatim into the PowQL text. It now comes from a fixed allowlist (_sum/_avg/_min/_max/_count); any other key throwsValidationError(E003) listing the supported set. The comparison operators moved to aMapfor the same reason. redactUrlcould be defeated, and backtracked. The pattern missed a password containing/,:or@, sopostgres://u:pa/ss@host/dbprinted in full, and its nested quantifiers made it a ReDoS candidate on a long non-matching string. It is replaced by a linear scan that anchors on the scheme and consumes the whole userinfo section.vector(n)dimensions are validated before they reach the DDL.vectorDimensionsis the one number interpolated into a type token and was trusted to be numeric. It must now be an integer between 1 and 16000 (pgvector's cap) orValidationError(E003) names the column.- The upsert conflict-UPDATE predicate no longer orphans parameters. MySQL
and SQL Server both reported the inherited
supportsUpsertUpdateWhere: truewhile theirbuildUpsertStatementemitted no predicate:ON DUPLICATE KEY UPDATEhas no predicate slot, andMERGE'sWHEN MATCHED ANDcannot take the unqualified column references the builder supplies (they are ambiguous between the target and source aliases). With a global filter in play the builder compiled the predicate and bound its parameters, which the statement then had no placeholder for. Both now reportfalse. SQLite genuinely supports it and now emits it. - CJS consumers no longer see TS1479. The CommonJS build emitted no
declarations, so a consumer on
moduleResolution: node16/nodenextthat resolved therequirecondition was pointed at the ESM declarations, which live in a"type": "module"package:tscreported "the file is an ES module and cannot be require()d" even though the runtimerequireworked.dist/cjsnow ships its own declarations beside its{"type":"commonjs"}package.json. - PowQL doc-field index DDL quotes the indexed column. The json document column was spliced in bare. See Changed: this changes emitted bytes for a keyword-named column.
- Studio's session cookie was matched mid-value, so a cookie whose name
merely ended in
turbine_studio_tokencould supply the token. The pattern is anchored on a cookie boundary now. - Studio's rate limiter counted only authenticated sessions. Unauthenticated requests were rejected before the limiter ran, so they were unlimited. The limiter now runs first, keyed per caller.
- Legacy migration checksums are upgraded only when they actually match.
A stored pre-0.6 djb2 checksum was accepted on length alone, so any short
stored value suppressed drift detection for that file. The legacy hash is now
recomputed and compared before the record is upgraded, and
migrate statususes the same rule asmigrate up.
Changed
prisma-compatreads now emitORDER BY <primary key> ASC. A compatfindManywithtake/skipand noorderByis ordered by the model's primary key ascending, matching Prisma. So is everyfindFirst/findFirstOrThrow, which core compiles to a bareLIMIT 1: "give me any one row" quietly meant "give me whichever row the heap hands back today", and it is the most common nondeterministic shape in Prisma-shaped code.findUnique/findUniqueOrThroware never ordered (at most one row matches, so it would be pure overhead) anddistinctreads are never ordered either: an addedorderBymoves core'sDISTINCT ONinto its two-level derived-table rewrite, whose outerORDER BYsees only the projected columns, so adistinct+select+takeread would fail withcolumn "id" does not exist. Cursor reads ARE ordered here, unlike core: the ordering is applied strictly after the cursor translation, so the seek direction is still resolved from the caller's ownorderBy, and Prisma pairs cursor pagination with the same implicit key. This changes the SQL existing compat call sites emit and therefore which rows a given page returns: the new pages are the deterministic ones, and the old ones could repeat or skip a row. There is no opt-out flag, because reproducing Prisma's semantics is this layer's contract. Migration: none to keep Prisma's behavior. To page in a different order, pass an explicitorderBy, which always wins. Core is unchanged and still emits a bareLIMITunless you setimplicitPkOrdering: true.- The
'auto'strategy (the default) picks a different plan for to-one relations on a large parent set. AbelongsTo/hasOneinclude now loads batched when the query is unbounded or itslimitexceedsautoToOneJoinMaxRows(default 1000), instead of staying in the single-statement join whenever its correlation column happened to be indexed.findUniqueis never affected (its parent set is one row). Results are unchanged and byte-identical either way; what changes is the plan and the round-trip count, so a query that was tuned against the old choice can move in either direction. Migration: pin the old plan withrelationLoadStrategy: 'join'(per query or client-wide), or raiseautoToOneJoinMaxRows. A dev-mode note names each relation'auto'moved and why. - The
'auto'strategy keeps relation_countinline unless the parent set is large._counton an unindexed foreign key previously always moved to the batched follow-up; it now does so only when the query is also unbounded or bounded aboveautoToOneJoinMaxRows. Same results, one fewer round-trip on bounded queries. Related: the unindexed fallback now requires DB-backed index metadata to engage at all (adefineSchema-only schema can never prove a probe unindexed), while the new cardinality rule applies with or without it. - The many-to-many nested-write error covers a smaller set, and points
somewhere reachable.
connect/disconnect/setno longer throw (see Added). The remaining operations (create,connectOrCreate,update,upsert,delete) still throwValidationError(E003), and the message now names the operation, lists the supported set, and points atdb.table("<junction>"), the one spelling that actually resolves on both the core and compat clients (and inside$transaction), rather than promising transaction scoping the old advice could not deliver. - Generated
*Create/*Updateinput types accept aDateontime/timetzcolumns (string | Date), matching the write path above and Prisma, which types those fields asDate. The row type staysstring: that is what the column reads back as. This only widens an input type, so existing code still compiles. - BREAKING: a nested
belongsToupdate with aNULLparent FK throws. It previously matched (and wrote) every related row with a null reference key. It now throwsNotFoundError(E001). Migration: this is the fix, not a regression. If you were relying on the old behavior you were relying on a full-table update; write it explicitly asdb.authors.updateMany({ where: { ... }, data: { ... } }). - BREAKING: a
Datebound to adateortimestampcolumn is stored in UTC. It was stored using the writing process's local calendar fields, so the value in the column depended on that machine'sTZ. Writes now bind theDate's UTC components, matching the read path, which has always interpreted an offset-less value as UTC. This changes the values your application writes from today on, and it means rows written BEFORE the upgrade from a non-UTC process are shifted relative to rows written after it. Only PostgreSQL, only the zone-lessdate/timestamptypes, and onlyDatevalues: a string literal, atimestamptzcolumn, and every other engine are unaffected. Migration: you are affected only if all three hold. (1) You have adateortimestampcolumn (nottimestamptz), which you can check withSELECT table_name, column_name, data_type FROM information_schema.columns WHERE data_type IN ('timestamp without time zone', 'date'). (2) You writeDatevalues to it through Turbine. (3) The process doing the writing did not run in UTC:node -e "console.log(Intl.DateTimeFormat().resolvedOptions().timeZone, new Date().getTimezoneOffset())"in your deployment environment, where a non-zero offset means the old writes were shifted by exactly that many minutes. If all three hold, existing rows written by that process can be corrected withUPDATE t SET col = col + interval 'N minutes'using that offset, scoped to the rows written before the upgrade. To keep the old behavior instead, setutcTimestamps: falsein the client config: that is the same switch that turns off UTC read parsing, so reads and writes stay symmetric either way. Anything that is not a non-negative safe integer (NaN, a non-numeric string, a negative, a fraction, a value pastNumber.MAX_SAFE_INTEGER) throwsValidationError(E003):limit on "users" must be a non-negative integer, received: NaN. Numeric strings ('5') still coerce. This applies to top-levellimit/offsetand per-relationlimit, on every engine. Migration: coerce and validate at the edge, for exampleconst limit = Math.min(Number(req.query.limit) || 20, 100). - BREAKING: a single-key plain object in
dataon a non-json column throws. On a scalar column that shape can only be a misspelled atomic operator, and binding it plainly wrote JSON text into the column. It now throwsValidationError(E003):Unknown update operator "incremnt" on "posts.viewCount", listing the supported operators. The check is deliberately narrow and skips json / jsonb columns, arrays,Dates, class instances (Buffer, decimal wrappers) and multi-key objects, none of which are operator-shaped. Migration: fix the operator spelling, or move the value onto a json / jsonb column if it really is a payload. - BREAKING: the
dialectoption is removed frommigrateUp,migrateDown,migrateDeploy,migrateStatusandinspectMigrationDeploy. It advertised multi-engine migrations the runner never implemented: every one of these opens apg.Clientdirectly, so passing a SQLite or MySQL dialect produced non-Postgres SQL sent to a Postgres connection. The migration runner is Postgres-only and now says so in its signature. Migration: delete the option. If you were passingpostgresDialect, the behavior is unchanged. - BREAKING (type-level): a typo in
selectoromitis a compile error.select?: Salone could never reject one, becauseSis inferred from the object literal, so{ emial: true }simply became the inferred type and silently narrowed the result toPick<T, never>. The property is nowS & FieldFlags<T, S>, which maps any key that is not a field ofTtoneverand reports the error at the offending key. Legitimate flag maps are unaffected and the result narrowing is unchanged. Migration: fix the misspelled key the compiler now points at. seedFileis the canonical config key.turbine initwritesseedFileand scaffolds the seed to./turbine/seed.ts(a pre-existing root-level./seed.tsis kept, so a re-run never creates a second seed file).seedremains a back-compat alias and still works;seedFilewins when both are present. Config-less discovery gained theturbine/locations, appended after the root-level ones so no project that relies on./seed.tschanges behavior:seed.ts,seed.js,seed.sql,turbine/seed.ts,turbine/seed.js,turbine/seed.sql. Migration: none required; renameseedtoseedFilewhen convenient.- PowQL doc-field index DDL quotes the indexed column, so
alter T add index (.order->"x")becomesalter T add index (.`order`->"x"). This changes the emitted bytes for a column whose name is a PowQL keyword or is otherwise not a bare identifier; every other column emits identically. The previous form spliced the name in raw and failed to parse on a keyword-named column. Migration: none, unless you diff generated DDL byte-for-byte. - Studio refuses a query it cannot prove is PII-safe past 32 levels of
nesting rather than passing it through unchecked, and refuses a redacted
column in
cursoranddistinctas it already did inwhereandorderBy. Migration: flatten the query, or restart Studio with--show-pii.
Documentation
- A published benchmark claim was wrong and has been corrected. The README
and the benchmarks page showed Turbine fastest at streaming 50K rows (60.7 ms)
and labelled the scenario a near-tie. A fresh run against 0.50.0 measures
Drizzle 0.45 fastest at 50.18 ms against Turbine's 63.87 ms, a 27% Drizzle
win, reproduced in five runs across two harnesses, with Drizzle sitting
exactly on the hand-written
pgkeyset control at 50.97 ms. The published figure rested on a single Drizzle measurement of 65.8 ms that did not reproduce. The loss is now stated plainly on both surfaces rather than the row being dropped. Two further corrections in the same table: the "four near-ties" is two (countis a clean Turbine win, streaming is a Drizzle win), and the "1.6x to 2.6x ahead of Prisma" range on nested shapes is actually 1.9x to 3.0x, corrected even though it errs in our favor. - The whole benchmark section is republished from a fresh 2026-07-25 run
against 0.50.0, replacing figures measured once on 2026-07-21 against 0.39.0.
The new harness (
benchmarks/bench-interleaved.ts) runs every arm once per round, rotates the arm order every round, reports medians over three full runs, and adds a hand-written rawpgcontrol arm. That control yields the strongest claim available, and the pages now lead with it: Turbine runs at 1.07x hand-writtenpg, where Drizzle runs at 1.47x and Prisma at 1.84x. Headline geomeans over ten scenarios are 1.87x faster than Prisma 7.9 and 1.36x faster than Drizzle 0.45. The measurement drift floor is now published alongside the table: the identical control arm drifts 1% to 14% between runs on multi-millisecond scenarios but 21% to 47% on the sub-0.15 ms ones, so every published sub-0.15 ms figure carries roughly one third uncertainty in its absolute value even though its ordering is stable. Full writeup inbenchmarks/RESULTS-0.50.0.md;benchmarks/RESULTS.mdis marked historical and its contradictory "pipeline about 3x faster" prose is scoped to the retired Prisma 7.6 table it describes. Claims this run cannot speak to are called out as unverified rather than carried forward, including the Prisma 7.6-to-7.9 improvement percentages, which have been removed. - The migrate-from-prisma page no longer argues that a correlated indexed
subquery beats the batched loader. The page cited 659 parent rows to make
that case, and fresh data contradicts it for to-one relations at every size at
or above about 25 parent rows, on a plan Turbine's own
'auto'default no longer follows. The passage now explains the actual tradeoff: a correlated subquery is one round-trip whose cost scales per parent row, the batched loader is two round-trips each of which is a flat keyset lookup, so the crossover depends on both parent-set size and round-trip time, with a table of break-even points from a local socket to a cross-region pooled connection. The index-versus-no-index finding, which is the point of the section and a ~290x difference, is unchanged.
0.49.0 (2026-07-25)
A correctness release. Two silent wrong-data paths on default code paths are closed, several safety guards that could be switched off by a single character are fixed, and every public claim that a file in this repo disproved has been corrected. Two behaviors change in ways an existing app can notice; both are listed under Changed with the exact opt-in.
Fixed
- Nested writes could touch rows belonging to another parent.
delete/update/disconnect/upsertinside a relation'sdataused only the caller-suppliedwhere, with no predicate tying the target to the parent being written.users.update({ where: { id: 1 }, data: { posts: { delete: { id: 4 } } } })deleted post 4 even when it belonged to user 2, which turns any endpoint that forwards a client-supplied id into a cross-tenant write primitive. Every one of those operations now ANDs the relation correlation (child.foreignKey = parent.referenceKey) onto the caller'swhere, and reportsNotFoundError(E001) when the target is not related to this parent. The same fix covers thebelongsToupsert, which looked up and rewrote the row named bywhereregardless of which row the parent actually pointed at. - The batched relation loader discarded relation
wherefilters. WithrelationLoadStrategy: 'batched'(and therefore on the'auto'default's batched fallback), the correlation predicate was spread over the caller'swherewhen both named the same key, sowith: { posts: { where: { userId: 2 } } }silently returned the parent's own posts instead of an empty set. The two predicates are now ANDed, and the join and batched strategies return byte-identical results. - A single
E'...'string could disable the destructive-migration guard. Both the migration statement splitter and the destructive scanner treated a backslash-escaped quote inside an escape string as the end of the literal, so everything after it parsed as string content: a migration containingE'it\'s fine'hid every followingDROP TABLEfrom the two-step confirmation. Quoted identifiers containing an apostrophe ("customer's_orders") had the same effect, and a dollar-quote tag with a digit in it ($do1$) hid a procedural body. All three are fixed, with regression tests that fail on the old code. doctor --unused/--auditno longer recommend dropping an index the same run demands. Indexes serving a live relation probe are subtracted from the drop suggestions (audit keeps the row, annotates it, and withholds the DROP), so the report can no longer contradict its own missing-index section.doctorreports exact-duplicate indexes. Two byte-identical indexes were invisible to the redundancy check, which only looked for a strict leading prefix. Exactly one side of the pair is reported.- Studio: banners no longer open a several-hundred-pixel void. The app grid declared two rows for five children, so any visible banner (write, PII, demo) stretched into the implicit row. Also fixed: switching tables from another tab and then opening the Data tab rendered the previous table's rows under the new table's name until the next refresh.
- PII redaction in Studio and the MCP server now has something to redact.
pii: trueis a code-first declaration that introspection never infers, and both tools build their schema from live introspection, so their redaction was inert against a real database (it only ever worked understudio --demo). Both now read tags from the generated metadata in youroutdirectory, Studio states at startup how many it found (and warns plainly when it found none), and the MCPsample_rowstool redacts tagged columns before rows reach an agent's context. _min/_maxandgroupBykeys respected the PII contract only by accident. See Changed: they are now gated.- A PII-tagged correlation column silently emptied its relation. When a
relation's
referenceKey(or a child FK) was itselfpii: true, the default projection left it out, the batched loader had no keys to stitch on, and every parent came back with an empty relation array on the DEFAULT'auto'strategy, with no error. The key is now projected explicitly and stripped after stitching, so the value still never reaches the caller. - Studio's query builder refused nothing on PII columns. The Data tab
already refused a
where/orderBy/isNullon a redacted column (the answer is an oracle for the hidden value);/api/builderaccepted all of them, at everywithlevel. It now refuses them the same way. This became reachable in this same release, when PII tags started reaching Studio at all. redactUrlleft credentials in CLI output. A password containing/,:, or@defeated the pattern, sopostgres://u:pa/ss@host/dbprinted in full. It now anchors on the scheme and consumes the whole userinfo section.prisma-compathad no way to opt into PII.includePiinow passes through on reads,groupBy, andaggregate, so a tagged schema is not a one-way door for compat call sites.- Nested-write
NotFoundErrorcarriestable/where/operationagain, as the errors documentation promises, and its message says the target may belong to a different parent rather than only that it was not found. - Full-text
searchand array-column filters throw on engines that cannot run them. They previously emitted PostgreSQL-only SQL on SQLite / MySQL / SQL Server; both are now gated by dialect capability and throwUnsupportedFeatureError(E017) with the portable alternative named. - Observability metrics were attributed to the wrong minute when a flush straddled a bucket boundary; the bucket is part of the buffer key now.
- The
_turbineRLS test fixture no longer needs a manualGRANT USAGE ON SCHEMA public, sonpm testpasses on a stock PostgreSQL. doctor --unusednow says when its own statistics are too young to act on, matching the cost section, which already refuses to score them.- A wrong-shaped schema fails at construction with an actionable message
instead of dying later as
TypeError: this.tableMeta.columns is not iterable. AdefineSchema()result is named specifically, with theschemaDefToMetadata(def)fix. - Studio: reverse navigation finds one-sided relations. "Referenced by" was
derived only from a table's own
hasMany/hasOne, so a relation declared only on the child (comments.user -> users, the norm in adefineSchemafile) was unreachable from the parent even though the child grid visibly rendered the foreign key. InboundbelongsTorelations now count too. - Studio: counts read
1 row, not1 rows, and the PII banner no longer cites--show-piiwhen the flag was not passed (demo-mode pill).
Changed
- BREAKING (opt-in restores it):
groupByby a PII column, and_min/_maxover one, now requireincludePii: true. Both return stored values, which contradicted the documented contract that PII columns stay out of every default projection. Without the flag they throwValidationError(E003) naming the column, the table, and the fix._count,_sum, and_avgover a PII column need no opt-in, andwhere/orderBy/havingon PII columns stay unrestricted. Untagged schemas emit byte-identical SQL and are unaffected. Migration: addincludePii: trueto the affectedgroupBy/aggregatecall, or drop the PII column fromby/_min/_max. - BREAKING (previously silent): a nested write on a many-to-many relation
throws. m2m has never had a nested-write implementation on any engine; the
relation op fell off the end of the dispatch, so the write never happened and
the call reported success. It now throws
ValidationError(E003) naming the junction table. Migration: write junction rows directly (db.userTags.createMany(...)) inside the same$transaction. - A nested
delete/update/disconnectselector that binds nothing is refused.delete: {}ordelete: { id: undefined }used to be caught by the empty-where guard; with the parent correlation now ANDed on, the merged predicate is never empty, so the caller's half is checked directly. It throwsValidationError(E003) rather than deleting every child of that parent.delete: trueremains valid on a to-one relation and is refused on a to-many. doctor --jsonhas a stable key set.unused,redundant,audit, andinvalidare always present (empty when the scan did not run) rather than appearing only with a flag, and asubtractionobject reports which scans ran. Each unused-index entry gained a structuredshape(kinds/accessMethod/definition) beside the prosecaveat, so a consumer never parses a sentence.- Coverage thresholds re-baselined to measured values (lines/statements 75,
branches 85, functions 59) with type-only modules excluded from the
denominator, and the release workflow now gates publishing on the coverage and
error-code jobs. The previous floors had been failing continuously, which is
how a red gate got normalized. The reasoning, the measuring command, and a
dated target for function coverage are recorded in
.c8rc.json; floors ratchet up only.
Documentation
- Corrected every claim a file in this repo disproved: the nested-relation
benchmark ratios and the "slowest on all ten scenarios" line, the bundle-size
figures on the landing page, the superseded PowDB-versus-SQLite headline in the
cross-engine benchmark notes,
DISTINCT ONdescribed as a SQL Server gap when it is Postgres-only, and the coverage thresholds quoted inSTABILITY.md. - The Prisma migration guide gained a Silent value differences section
(
Decimalasstring,BigIntasnumber,select/includedropped on writes,aggregate()ignoringorderBy/take/skip, JSONequalscompiling to containment), documents that the 0.41 unique-FK change renames the relation as well as changing its shape, and no longer claims nested writes cover many-to-many. - Full-text
search, array-column filters, andgroupBy({ distinctOn })are listed in the Postgres-only sets on the engines page, the README, and the migration guide, and appear in the capability matrix. - The PII gate is documented where a reader lands: the
groupByandaggregatesections, the E003 row in the error table, and the Studio / MCP pages.
0.48.0 (2026-07-24)
Added
- PowDB introspection is relation-aware on 0.19.1+. PowDB 0.19.1 ships a link
introspection surface (
schema linksplus link rows indescribe).introspectPowdbDatabasenow reads declared entity links and populatesSchemaMetadata.relationsfor the first time on PowDB: to-one links becomebelongsTo(with a synthesized reversehasMany), to-many links becomehasMany. Many-to-many junctions cannot be inferred from links, sodefineSchemaremains the recommended relation-aware path. Gated on a new patch-awarelinkIntrospectioncapability (engine version probe, 0.19.1 floor). - Scalar link paths lift the bigint/bytes to-one loader fallback. On PowDB
0.19.1+, a
withclause for abelongsTorelation whose projected child columns include bigint or bytes (which JSON projection blocks cannot carry, previously a per-relation loader round trip) now compiles to alias-qualified scalar link-path projections on the parent statement, single statement, when a matching link is declared in the database (verified against a cachedschema linkssnapshot; any mismatch falls back silently to the loader). Output is loader-identical, including missing-relationnullshapes and native bigint values. Cases nested projections already serve keep nested projections: the engine never plan-caches link-bearing queries, so hot cacheable paths are deliberately left alone. Gated on a newlinkPathscapability (0.19.1 floor; 0.19.0 is never eligible). - Opt-in link DDL emission.
powqlSchemaDDLacceptsemitLinks: trueand emits onelink Owner.name -> Target on local = targetper single-column relation (composite-key and junction relations skipped; owner-column name collisions skipped with a once-only warning). The apply path existence-checks viaschema linksfirst (link DDL is create-only with noif not exists): already-declared identical links are skipped, and a declared link with different endpoints is warned about, never dropped or replaced. Note: the first link declaration permanently upgrades the data directory to catalog v7; pre-0.19 binaries can no longer open it. - 0.19.1 hard-error mapping. The two behaviors that silently returned wrong
results on 0.19.0 (bare dotted projection paths, aggregates over link or nested
projections) are hard errors on 0.19.1 and now wrap to
ValidationError(E003) on both transports. Dev dependencies track^0.19.1; the PowQL lexer is verified unchanged 0.19.0 to 0.19.1, so the tested ceiling continues to cover the 0.19 line.
0.47.0 (2026-07-23)
Added
turbine doctor --unused: index subtraction advice. Doctor now reports the other half of index hygiene: indexes with zero scans sincestats_reset(with the reset age printed and the honest caveats: counters zero on stats resets, and replica reads never feed primary counters, so a replica-only index looks dead here), redundant indexes whose columns are a leading prefix of a wider compatible index, and invalid indexes left behind by failedCONCURRENTLYbuilds. Suggestions areDROP INDEX CONCURRENTLYstatements with reclaimable sizes, report-only: never written to a migration, never auto-applied, and there is deliberately no--fixfor drops. Primary-key, unique-constraint, exclusion-constraint, and replica-identity indexes are always excluded.--min-scansadjusts the never-scanned threshold.turbine doctor --audit: did doctor's own advice earn its keep? Audits the indexes doctor previously suggested (by their deterministic names, 63-character truncation handled, post-truncation collisions reported as ambiguous rather than guessed) and flags the ones that have never been scanned since stats reset.- Workload-heat annotations. When the
_turbine_metricstable from the observe module is present (same database or--metrics-url), doctor maps query heat to tables and annotates findings ("hot in your workload: N queries/min, p95 X ms"), prioritizing hot findings. One honesty line is printed when heat data is unavailable.doctor --jsoncarries all of the above as additive fields under the sameschemaVersion: 1contract. - Pluggable observe sinks.
ObserveEngine's flush target is now theObserveSinkinterface. The default remains the_turbine_metricsPostgres writer, byte-identical to previous behavior (covered by a regression test). NewHttpJsonSinkPOSTs metric batches as JSON to any HTTP endpoint (fire-and-forget, never throws), for self-hosted dashboards and metrics pipelines.ObserveConfig.connectionStringis optional when a custom sink is provided. Telemetry remains aggregates only (model, action, counts, latency percentiles): no SQL text, no parameters, opt-in, off by default.
Fixed
- Doctor's index-column reads now survive node-pg's
name[]handling. The stats collector aggregatedpg_attribute.attnameinto aname[], which the driver returns as an unparsed string; iterating those columns (new in the redundancy check) would have thrown. The collector now casts totext[].
0.46.0 (2026-07-23)
Added
turbine doctorscores index suggestions by cost, not just topology. On PostgreSQL, doctor now reads live statistics (pg_stat_user_tables,pg_stat_user_indexes,pg_stats, relation sizes,stats_resetage) and sorts missing-FK-index findings into three tiers: take freely, take deliberately, and scrutinize, printing the numbers behind each verdict (table size, writes per day since stats reset, existing index count, probing relations). Mostly-NULL FK columns get a partial-index suggestion (CREATE INDEX ... WHERE col IS NOT NULL) instead of a full one, and candidates on tables with high HOT-update ratios carry an explicit warning and a tier bump. Invalid indexes (failedCONCURRENTLYbuilds) are reported. When statistics are unavailable, too young, or never reset, doctor degrades honestly to the previous size-sorted topology report with a printed reason; it never fakes confidence. Non-Postgres engines keep the topology-only report.turbine doctor --json. Machine-readable report with a stable versioned contract (schemaVersion: 1): scored findings with tier, numbers, and SQL, invalid indexes, and degradation notices. Built for CI gates and external tooling.- No-transaction migrations and
CREATE INDEX CONCURRENTLY. A migration whose header carries the-- turbine:no-transactiondirective now runs outside a transaction, one statement per query (required forCONCURRENTLY), and is recorded only after every statement succeeds. The statement splitter understands quotes, dollar-quoting, and line/block comments.doctor --fixemitsCREATE INDEX CONCURRENTLY IF NOT EXISTSmigrations with this directive by default, including a recipe comment covering idempotency, the failed-build-leaves-INVALID-index trap, andlock_timeoutguidance;--no-concurrentlykeeps the plain transactional form.migrate upprints a loud notice whenever it applies a no-transaction file.
0.45.0 (2026-07-23)
Fixed
- PowDB:
notandnotInfilters now match SQL null semantics. PowDB 0.18.2 changed!=and range comparisons to exclude missing-value rows (SQL parity), but Turbine still emittednot (col = $n)for thenot:operator and a barenot infornotIn, both of which continue to match missing-value rows. On a nullable column,where: { col: { not: v } }and{ notIn: [...] }therefore returned different rows on PowDB than on every SQL engine. Thenot:leaf now compiles tocol != $n(including the case-insensitive variant) and non-emptynotInappends anis not nullpresence guard;notIn: []keeps its match-everything semantics (SQL parity requires missing-value rows to match an emptynotIn). The change is behavior-neutral on pre-0.18.2 engines, so it is not capability-gated. TheNOT: {...}combinator remains a documented divergence for null-involved sub-clauses on PowDB (its whole-clause negation cannot be mechanically re-spelled).
Added
- PowDB 0.19 support:
entityLinkscapability and catalog v7 awareness. The capability map now recognizes PowDB 0.19's entity links (entityLinks, gated on an engine version probe of 0.19+, never assumed). Query generation deliberately keeps Turbine-composed nested projections instead of link traversal: link-bearing plans are not cached by the engine, declared links cannot be introspected (so drift from the schema would be undetectable), and the published 0.19.0 builds have known link-projection defects.wrapPowdbErrormaps the newunsupported catalog versionfailure toConnectionError(E004) with an upgrade hint, and the engines page documents the catalog v7 one-way door: the firstlinkdeclaration permanently upgrades a data directory so pre-0.19 binaries can no longer open it; databases that never declare links stay on v6. - PowDB tested ceiling raised to 0.19. The PowQL lexer is verified byte-identical
from 0.18.0 through 0.19.0 (
linkwas already a reserved keyword), so the string encoding ceiling (POWQL_LEXER_TESTED_CEILING) rises to'0.19'. Dev dependencies now track@zvndev/powdb-client/@zvndev/powdb-embedded^0.19.0, and the live integration suite exercises link DDL, traversal, catalog persistence, and the new null-semantics parity against the real 0.19.0 embedded engine.
0.44.0 (2026-07-23)
Added
turbine-orm/prisma-compatemulates Prisma's client-side defaults. Prisma fills@default(uuid())/@default(cuid())/@updatedAtin the client, so those columns usually have no database default and migrated call sites omit them (previously a NOT NULL violation on every such create).migrate-from-prismanow records these in the generated name map (clientDefaults, including@default(now())when introspection finds no database default), and the adapter fills them oncreate/createManyand touches@updatedAtfields onupdate/updateMany/upsert, exactly like Prisma. Explicitly provided values are never overwritten.
Fixed
- Nested writes now work in the
$transaction([...])array form. The lazy batch path compiled each call to a single SQL statement, which cannot express nested write data (connect,create, ...), so such a batch item failed with an unknown-field validation error. When any item in the array carries nested write data (or an upsert needs the lookup-first path below), the whole array now runs sequentially inside one transaction through the nested-write-capable path: ordering, atomicity, and Prisma's array-form contract are preserved. Plain batches keep the single-round-trip path. turbine-orm/prisma-compatupsert now follows Prisma's lookup-first semantics. Turbine coreupsertcompiles a singleINSERT ... ON CONFLICTkeyed on the create data's unique values. When an upsert'swherekey values differ from itscreatevalues, that inserts the create row even though thewhererow exists (a silent divergence from Prisma). The adapter now passes through to the native atomic upsert only when thewherekey values equal the create values; otherwise it emulates Prisma inside a transaction: look up bywhere, update the found row, else insertcreate. Core semantics are unchanged and now documented in the migration guide.
0.43.0 (2026-07-23)
Fixed
wherefilters on many-to-many relations now route through the junction table. A relation filter (some/every/none) on amanyToManyrelation compiled the direct foreign-key correlation used forhasMany/belongsTo, which degenerates totarget.pk = parent.pkand silently matches nothing (acountorfindManyfiltered on such a relation returned 0 rows while theincludepath returned the linked rows correctly). The filter now correlates through the junction:EXISTS (SELECT 1 FROM junction WHERE junction.targetKey = target.pk AND junction.sourceKey = parent.ref), composite keys paired positionally, all bare table names so nested relation filters keep working. AmanyToManyrelation missing itsthroughdescriptor now throws aValidationErrorinstead of compiling wrong SQL.migrate-from-prismaresolves a remaining unnamed relation pair by elimination. When a model has several relations to the same target and all but one are pinned by@relation("Name")pairing or explicitfields: [...], the one unnamed pair now takes the single unconsumed candidate instead of reporting "ambiguous", matching Prisma's own resolution. Two competing unnamed pairs still report ambiguous.- pg-style pool option aliases.
turbine({ max, idleTimeoutMillis, connectionTimeoutMillis })now works as documented aliases forpoolSize/idleTimeoutMs/connectionTimeoutMs(the turbine-native field wins when both are set). Previously the pg spellings failed typecheck and were silently ignored by untyped callers, producing default pool sizing. turbine-orm/prisma-compatmatches Prisma'stimecolumn convention. A Postgrestime/timetzvalue now surfaces from the adapter as aDateon 1970-01-01 UTC (Prisma's epoch-day convention) instead of the driver's rawHH:MM:SSstring, so.getHours()-style call sites survive migration. Turbine core is unchanged (raw string, documented).
0.42.0 (2026-07-23)
Fixed
migrate-from-prismanow pairs two relations to the same model by@relationname. When a model had two or more relations pointing at the same target model (for example acreatedBy/modifiedBypair of foreign keys), the resolver reported them as "ambiguous" even though Prisma disambiguates them with a shared@relation("Name"). Resolution now matches the relation name first: an inverse relation field resolves through the foreign key pinned by the opposing side that carries the same name, and only falls back to the ambiguity report when there is no name or the named pair cannot be found. Such pairs now resolve fully with no unresolved items.@@uniquenow matches aUNIQUE INDEX, not only a unique constraint. Prisma creates composite uniques as unique indexes rather than table constraints, so every@@uniquewas reported as having "no unique constraint" even when the database had the exact unique index. The resolver now also accepts a match from a non-partial unique index whose column set equals the@@uniquecolumns, and those index-backed uniques are included in the emitted name map (custom@@unique(name:)selector names preserved).migrate-from-prismanow emits the generated client too. The command previously wrote only the migration report and the typed name map, so users had to runturbine pullseparately, and a partially resolved run (--allow-partial) emitted no client at all. It now always generates the standard client (types.ts,metadata.ts,index.ts) from the live introspected metadata alongside the report and name map, including on the--allow-partialpath (unresolved Prisma items never block the client).migrate-from-prismanow honors--keep-column-names. The flag was accepted but silently ignored, so a keep-column-names client (raw snake_case field names) and the emitted name map (camelCase field names) disagreed. The flag now flows through to both the generated client and the name map's field values, which become the raw database column spellings so the two agree.- Prisma implicit many-to-many junctions are now detected. A Prisma implicit m2m
join table (e.g.
_UserOrganizationswith columnsAandB) has no primary key, just a two-columnUNIQUEindex over the two foreign-key columns, so auto-detection previously skipped it and themanyToManyrelations were missing (anincludeon either side threw at runtime).turbine generate/pullnow accepts a two-column unique index over exactly the two single-column foreign keys as the junction key when a table has no primary key, with the same purity checks as before (exactly two foreign keys to two distinct tables, no payload columns). Tables that DO have a primary key, or that carry an extra payload column, are still never treated as junctions. - Index column parsing no longer swallows a partial-index
WHEREclause. Introspection parsed the indexed columns with a greedy match that, for a partial index likeCREATE UNIQUE INDEX ... (ledger_id, line_id) WHERE (line_id IS NOT NULL), captured the trailingWHERE (...)as part of the column list. That garbage fragment then leaked into generated compound-unique selector names and produced atypes.tsthat failed to parse. Column parsing is now anchored on theUSINGclause, quoted identifiers are de-quoted, and expression columns are dropped conservatively. - Partial unique indexes are excluded from compound-unique selectors. A partial
UNIQUEindex only guarantees uniqueness over its predicate's rows, not table-wide, so it can no longer back afindUnique-style compound selector in either the runtimewhereexpansion or the generated*WhereUniqueselector branches.IndexMetadatacarries a new optionalpartialflag. - Generated compound-unique selector names are always valid TypeScript. A synthetic
selector name that is not a valid identifier (for example a junction-style quoted
uppercase column) is now emitted as a single quoted string-literal key, so the generated
types.tsalways parses. turbine-orm/prisma-compatdelegate errors now reject instead of throwing. Translation and validation errors raised while building a query (an unknown relation ininclude, a malformed compound selector, a missingwhere, a negativetake, ...) previously threw synchronously from the delegate call, so a Prisma-style.catch()never fired. Every delegate method and the$transactionarray-batch path now surface these as a rejected promise; error types and decoration are unchanged.
0.41.0 (2026-07-23)
Breaking changes
-
Unique foreign keys now introspect as one-to-one (
hasOne) relations. When a child table's foreign-key column set is EXACTLY covered by aUNIQUEconstraint or a plain (non-partial, non-expression)UNIQUEindex,turbine generate/pullnow emits a to-one relation on the parent side (RelationDescriptor<Child, 'one', …>) instead of a to-many array, matching Prisma one-to-one introspection. The relation is also renamed to the SINGULAR of the child table (e.g.users.profilesbecomesusers.profile), falling back to the previous plural name only if the singular collides with an existing column or relation. This is a correctness fix, and it is a double-barreled change when it applies:- TypeScript consumers get loud compile errors where the relation was iterated
(
.map(...),[0]) or filtered withsome/every/none; the fix is to read the relation as an object-or-null. - Plain-JS consumers silently receive
Child | null(an object ornull) where an array used to be; a childless parent is nownullrather than[].
The parent-side field stays nullable (
Child | null) even for aNOT NULLunique FK, matching Prisma. To keep the pre-0.41 to-many shape, pass--legacy-to-many-uniques(CLI), setlegacyToManyUniques: trueinturbine.config, or passlegacyToManyUniques: truetointrospect(). Partial and expression unique indexes are deliberately excluded from the flip (they do not guarantee at most one child row). - TypeScript consumers get loud compile errors where the relation was iterated
(
Added
--import-ext <js|none|auto>(configimportExtension). Controls the extension on the generatedindex.tssibling imports.jsemits./types.js(required by NodeNexttscand by tsc-compiled ESM on Node, the previous behavior);noneemits./types(correct for bundlers andmoduleResolutionbundler/node10: webpack, Next.js/SWC, Vite/esbuild);auto(the new default) walks up from the output directory to the nearesttsconfig.json(extends chains are not followed) and picks.jsfornode16/nodenextmodule resolution, extensionless otherwise, falling back to.jswhen the tsconfig is missing, unparseable, or ambiguous. Because the fallback is the previous behavior, NodeNext projects never regress; bundler projects that check generated files into source control will see a one-time diff to extensionless imports on the next regenerate.--keep-column-names(configkeepColumnNames). Generates column FIELD names as the raw database column names (snake_case) instead of camelCase, souser_idstaysuser_idend to end (row keys,where/orderBy/select, nestedwithrows, aggregate keys). It is a pure generate-time transform with zero runtime changes; relation names, table accessors, and entity type names are unaffected. Opt-in: untouched schemas emit byte-identical output. The transform is also exported from the package root aswithDbFieldNames(schema)so runtime-introspection and serverless users can apply the same identity mapping to a schema they build at runtime.introspect()gainslegacyToManyUniquesandonDefaultTableExclusionoptions, andwithDbFieldNames/applyTableFilters/DEFAULT_EXCLUDED_TABLESare exported for programmatic use.
Changed
- Migration bookkeeping tables are excluded from introspection by default.
turbine generate/pullnow skips_turbine_migrations,_prisma_migrations, and_turbine_metrics, so a freshly introspected schema no longer emits accessors and stray FK-derived relations for them.generateprints a note for any that were present. To keep one, name it ininclude(CLI--include, configinclude), which restores the old output for that table byte for byte. Only these three names are special-cased; other leading-underscore tables are never excluded.
Added
-
turbine migrate-from-prisma, an official Prisma-to-Turbine migration path (phase 1). Point the new command at aschema.prismaand it emits two artifacts next to your generated client:prisma-migration-report.md, a per-model resolution report: each Prisma model mapped to its Turbine table + client accessor, every field, relation, junction table, and compound-unique selector, plus an explicit list of anything that could not be resolved (with the reason), and a fixed section of Prisma-vs-Turbine behavior notes (cursor exclusivity,_countshape, relation-array ordering, thesslmodeURL recommendation).prisma-map.ts, a typedPRISMA_MAPname map (models, fields, relations with cardinality, and compound-unique selector names including custom@@unique(name:)ones). It is the input to hand-written compat wrappers today and to the phase-2turbine-orm/prisma-compatruntime adapter next.
The
schema.prismaparser is hand-rolled and adds zero dependencies: it understands models, enums, views,@map/@@map, relations (including implicit m2m junctions),@@unique(named and default), and@@id, and is deliberately lenient: any attribute or block it does not recognize is skipped, never fatal. Names are resolved against the live database (viaturbine's existing introspection); a model that matches multiple candidate tables is reported UNRESOLVED rather than guessed.--no-dbproduces a parse-only report without a database,--allow-partialaccepts an incomplete map, and--no-timestampmakes the output reproducible. The command exits non-zero when anything is unresolved (unless--allow-partial).PrismaCompatMapis exported from the package root so runtime consumers can share the shape. -
turbine-orm/prisma-compat, a runtime PrismaClient-surface adapter (phase 2).createPrismaCompatClient(db, PRISMA_MAP, options?)wraps aTurbineClientand exposes Prisma'sdb.Model.findMany(...)surface, driven by thePRISMA_MAPthatturbine migrate-from-prismaemits. It is a pure TypeScript shim: zero new dependencies, never imported by core. It translates args recursively (include→with,selectsplit into scalar selection + relations, field/relation renames both ways,take/skip→limit/offset, compound-unique selectors including custom@@unique(name:)names), reshapes results (_countkeyed back to Prisma relation names, to-one relations surfaced asobject | null), and supports both$transactionforms (callback and Prisma's lazy$transaction([...])array batching via the core batch path),$queryRaw/$executeRaw(withPrisma.sql-style nested-fragment flattening) and the*Unsafevariants. Options:stablePkOrder(passes through the corestableRelationOrderflag) andprismaErrorCodes(decorates thrownTurbineErrors with the nearest Prisma code, e.g.P2002, without fakinginstanceof). Cursor translation: the idiomaticcursor+skip: nmaps to an exact exclusive cursor +offset n-1; a bare inclusive cursor compiles to agte/ltekeyset predicate only when its field is the singleorderBykey (or the single-column primary key with noorderBy) and otherwise throws a descriptive error rather than emit a wrong page. Documented non-goals (client extensions,$use, fluent relation chaining, Accelerate/Pulse, the Mongo API, byte-exact error identity) throw or are listed rather than silently mis-behaving. -
createMany({ skipDuplicates: true })is now engine-gated. PostgreSQL and SQLite emitON CONFLICT DO NOTHINGand MySQL emits its no-opON DUPLICATE KEY UPDATE(unchanged); SQL Server and PowDB now throwUnsupportedFeatureError(TURBINE_E017) instead of silently ignoring the flag and inserting duplicate rows, since neither has a single-statement skip-duplicates form.
Changed
- Behavior change:
relationLoadStrategynow defaults to'auto'on SQL engines. Previously everywithclause resolved as a single-statementjson_aggjoin. The new'auto'default keeps that join for every relation EXCEPT the ones the introspected metadata proves are pathological: when a relation's probe column has no covering index, that relation alone falls back to the batched loader (one flatWHERE fk = ANY($1)follow-up), which pays the missing index once instead of once per parent row. This only ever replaces a provably catastrophic plan with an equivalent-output one. Result bytes are identical (the batched loader guarantees the same shape), and everything stays on the same connection/transaction, so most callers see no difference. What can change: middleware and query-event logging see more than one statement for a fallen-back relation, the per-querytimeoutnow applies per statement rather than to a single statement, and tests that assert exact SQL text or statement counts on such a query may need updating. The fallback engages only when DB-backed index metadata exists (a generated / introspected client); a code-firstdefineSchema-only client behaves exactly like'join'. To restore the previous behavior everywhere, setrelationLoadStrategy: 'join'at the client level (or per query). Composite-key relations always stay on the join plan. Dev builds print a once-per-relation note when the fallback engages, and the query event carries astrategy: 'auto-batched'tag. Tip: runnpx turbine doctor(or add the missing FK index) to keep a relation on the single-statement join.
Added
- Prisma-style compound-unique
whereselectors. ThefindUniquefamily (andupdate/delete/upsert, plus nested-writeconnect/connectOrCreate/disconnect/set/delete) now accept a synthetic selector key that holds a composite unique constraint's member columns, e.g.findUnique({ where: { orgId_userId: { orgId, userId } } }), which expands to the column conjunction (WHERE "org_id" = $1 AND "user_id" = $2). Selector names are derived from the primary key, composite UNIQUE constraints, and declared composite UNIQUE indexes (the code-firstdefineSchemasource), and the generated*WhereUniqueunion gains a matching branch (plus an*CompoundUniqueshelper type). A selector with the wrong members throws a clear E003 listing the required fields; a name that collides with a real field, column, or relation is never treated as a selector. The expansion runs before cache fingerprinting, so an expanded query is byte-identical to (and shares the SQL-cache entry of) the spelled-out conjunction. The PowDB engine adopts the same selectors onfindUnique. _count: { _all: true }record form onaggregateandgroupBy. Both now accept the reserved_allkey alongside per-field counts:_count: { _all: true, email: true }returns{ _count: { _all: n, email: n } }(Prisma parity). Scalar_count: trueis unchanged and still returns a plain number; the emitted SQL for existing calls is byte-identical.- Opt-in
stableRelationOrder. A new client-config and per-query option that fills a primary-key-ascendingorderByinto every to-manywithrelation that has no explicit ordering, so unordered child arrays come back deterministically (child-array order without anorderBywas never guaranteed, and the'auto'fallback above can change it). An explicit per-relationorderByalways wins. Off by default; when off the emitted SQL is byte-identical to before. QueryEvent.strategy. Query events emitted for an'auto'query that engaged the batched fallback now carrystrategy: 'auto-batched', so production observability can see which queries were re-planned.
Fixed
- The dev-mode missing-FK-index warning now dedupes process-wide. It previously
used a module-level set, which a dual-package (ESM + CJS) load or a bundler /
HMR re-evaluation could reset or duplicate, causing the warning to repeat. The
dedupe now lives on a
globalThisregistry keyed bySymbol.for(...), shared across every module copy in the realm and surviving dev-server recompiles, so each relation warns at most once per process (bounded so it can never grow unboundedly). The deep-withadvisory moved to the same registry.
0.40.1 (2026-07-22)
Fixed
turbine seednow actually runs TypeScriptdefineSeedseeds. The scaffoldedseed.tsquickstart could printSeed completedwhile the seed callback never executed (no writes, no output). The self-run detection mistook the library's own compiled frame (dist/seed.js, or a plain-pathtsxframe) for the caller, so the "this file is the entry" check never passed and the callback was never queued. Seed detection now identifies the library's own module by its real path and skips it, regardless of the.ts/.jslayout ortsxplain-path stack frames, so a defineSeed file run viaturbine seed,tsx, ornodeexecutes its callback. The CLI seed runner also reports honestly: if a seed file loads but no callback runs, that is now an error instead of a false success.turbine pushno longer hides destructive schema changes. A diff that only removed a column reportedDatabase is already in sync(the column stayed), and a mixed diff printedApplied 0 statement(s)next to a falseAltered, with--allow-destructivedoing nothing. Destructive statements (a dropped column, a lossy column-type change) now stay in the plan and flow through the existing data-loss guard: without consent,pushrefuses loudly with the same itemized, classified report the migration gate uses and a non-zero exit (nothing applied); on a TTY it offers the same typed two-step confirmation asmigrate up; and--allow-destructiveactually applies them.migrate deploy --allow-driftis honored. Deploy's own drift error recommends passing--allow-drift, but the flag previously changed nothing. It now bypasses checksum validation ondeployexactly as it does onup, with a loud warning.migrate create --autoon a destructive-only diff produces a real migration. It used to reportDatabase is already in sync: nothing to migratewhen the only change was a dropped column. It now writes a migration with the destructive statements flagged inline, matching--from-diff.migrate deployprints a destructive-statement notice. Before applying, if the pending batch contains data-destroying statements, deploy now prints the same itemized, classified report as a notice (deploy still proceeds by design), instead of runningDROP TABLEand similar with no warning.migrate statuslists applied migrations whose file was deleted. Such entries now appear with a! Missing filemarker and a warning banner, and are counted in "applied", instead of being silently dropped from the reported history.- Out-of-order applies are flagged. When
migrate up/deployapplies a migration whose timestamp is older than the newest already-applied migration, it now prints a one-line warning naming both. - Clearer drift remedies for deleted files. The drift error's "roll back with
migrate down" suggestion is impossible for a file that was deleted from disk; the message now tells you to restore the file for those, and scopes the roll-back suggestion to modified files. - Root
turbine --helpdocuments more flags. The Migrate options block now lists--from-diff,--recipe, and--allow-destructive, and a new Init options block documents--yes,--skip-schema,--skip-seed,--skip-push, and--skip-generate.
0.40.0 (2026-07-21)
Added
turbine migrate create <name> --from-diff. Scaffold a migration from the live schema diff. Loads your code-first schema (the same filepushuses), introspects the database, runs the diff, and writes the forward statements into the migration's-- UPsection and the reverse statements into-- DOWN(a clearly commented "irreversible, write manually" placeholder is written when no reverse is derivable). Any data-destroying statement in either direction (a lossyALTER COLUMN ... TYPEin UP, aDROP TABLE/DROP COLUMNreverse in DOWN) is flagged inline with loud comments and a file-level banner, and the statement is left intact somigrate upstill refuses it by default unless you confirm interactively or pass--allow-destructive. Diff warnings (e.g. enum value removals the diff will not apply automatically) are surfaced as-- NOTE:comments.--from-diffcannot be combined with--autoor--recipe. This complements the existing--autoflag, which writes the raw diff without the destructive annotations.- Richer interactive
turbine init.initis now a sequenced bootstrap that detects project state and runs only the needed steps: writeturbine.config.tsif missing, offer to create a starter schema file and seed file, and (when a reachable database is configured) offer to push the schema, generate the typed client, and run the seed file. Re-runs skip completed steps (existing files are detected), soinitis safe to run repeatedly. New flags:--yes/-yaccepts every step's default non-interactively, and--skip-schema,--skip-seed,--skip-push,--skip-generateskip individual steps. A destructive push keeps the existing typed confirmation. A bare non-interactive invocation behaves as before (scaffold files + generate run; push and seed do not) and prints a note pointing at--yesand the--skip-*flags. - Typed
groupByresults.groupByno longer returnsRecord<string, unknown>[]: its result-row type is now inferred from the args, matching Prisma and Drizzle. Eachbyfield carries its entity field type,_countis anumber,_sum/_avgfields arenumber | null, and_min/_maxfields carry the field's own type. Noas constneeded at the call site. Grouping by a JSON-path key still yields a runtime alias that can't be typed, so those columns are left off the row type (cast when grouping by a JSON path). Compile-time assertions guard the inference in the typecheck job. groupBylimitandoffset.groupByaccepts optionallimitandoffset, compiled toLIMIT/OFFSETafterORDER BY(parameterized on PostgreSQL / SQLite / SQL Server, inlined on MySQL, native on PowDB). Useful for "top N groups" and paginated grouped results; pair with a deterministicorderBy.- Array
orderBy(Prisma-style). Everywhere anorderByis accepted (findMany, awithrelation, andgroupBy) you can now pass an array of objects, e.g.orderBy: [{ createdAt: 'desc' }, { id: 'asc' }]. The array's element order is the authoritative multi-key sort precedence, so multi-key ordering no longer depends on JS object key iteration order. The single-object form is unchanged and byte-identical. Both forms flatten through one shared helper, so the SQL-template cache fingerprint, SQL build, and param-collect paths stay in lockstep (a permuted array is correctly a distinct cache key). Supported on every engine, including PowDB. - Configurable SQL-template cache size. A new
sqlCacheSizeoption on the client config (andQueryInterfaceOptions) bounds the per-table LRU SQL template cache. Default stays1000. Values are parameterized and never fragment the cache, so this bounds distinct query shapes: raise it for apps with a very large query surface to lift the hit rate, lower it to cap memory.sqlCacheSize: 0disables caching entirely (identical tosqlCache: false).
Changed
- PowDB 0.18.1 driver packages adopted. The optional PowDB peers
(
@zvndev/powdb-client,@zvndev/powdb-embedded) now resolve to the published0.18.1line, so the nested-projection relation path shipped in 0.39.0 lights up automatically on install via the engine version probe (previously it activated only against a locally built 0.18 engine). The supported peer range is unchanged. Storage-raised unique-constraint violations now arrive with the typed wire error class already mapped toUniqueConstraintError(E008); no behavior change for consumers.
0.39.0 (2026-07-20)
Added
- PowDB nested projections:
withruns as one statement. PowDB 0.18 adds nested projections (shaped results) to PowQL: a projection field can be a whole correlated child query returning a per-parent JSON array. Turbine now compiles eligiblewithclauses straight into the parent statement, sodb.users.findMany({ with: { posts: { orderBy: { views: 'desc' }, limit: 3 } } })runs as ONE PowQL statement, with per-parent ordering, limits, and offsets applied natively by the engine, childless parents kept ([]for hasMany,nullfor to-one), and arbitrary nesting depth sharing one alias counter, the same single-query shape Turbine'sjson_aggstrategy gives Postgres. On an engine >= 0.18 this replaces the batched N+1 loaders as the default relation path; an explicitrelationLoadStrategy: 'batched'opts back out, and'join'also prefers nesting (it is the strictly better server-side path: no fan-out, survives parent paging, keeps childless parents). Ineligible shapes silently fall back to the loaders with identical output: many-to-many (the junction-order stitch has no nested equivalent), bigint-typed child columns (JSON cannot carry them losslessly), a to-one relation with paging, parentdistinct, and projection-key collisions. PII-tagged child columns stay excluded at the query level;select/omit/includePiiare honored; child JSON values are re-coerced per column type (datetime micros come back asDate).explain()shows the engine's nested plan. Everything is capability-gated on the probed engine version: older engines keep the loaders byte-for-byte, and the feature lights up automatically once the 0.18 driver packages are on npm. - PowDB typed wire error classes (engine >= 0.17). PowDB 0.17 error frames
carry a stable one-byte error class, and
wrapPowdbErrornow classifies by it before the message-substring families, so a server-sanitized message ("query execution error") still maps to the right typed error: timeout →TimeoutError(E002), memory/size limit →ValidationError(E003), read-only refusal →ReadOnlyError(E018,reason: 'snapshot'), auth failure / rate limiting →ConnectionError(E004), constraint violation →UniqueConstraintError(E008), cooperative cancellation →ConnectionError(E004, final). The specific message families keep precedence where they extract richer detail (constraint and column names, RBAC-vs-snapshot read-only reasons); classless errors from older servers keep the exact pre-0.17 behavior.
Changed
- PowDB driver dev/test baseline bumped to
@zvndev/powdb-client/@zvndev/powdb-embedded^0.17.0(the peer range is unchanged and already admits 0.18). The PowQL lexer escape set was re-verified byte-identical through the 0.18 engine line, soPOWQL_LEXER_TESTED_CEILINGis now'0.18'.
0.38.1 (2026-07-19)
Fixed
- createMany respects declared column types in its UNNEST casts. The
bulk-insert cast picker fell back to a name-based heuristic (
*_idimpliesbigint[],*_atimpliestimestamptz[]) whenever the metadata carried no precomputed array type, so acreateManyagainst a text or uuid foreign key (for exampleauthor_id text) failed withinvalid input syntax for type bigint. The column's declared type inpgTypesnow always wins; the heuristic survives only for columns entirely absent from the metadata. Build-only regression tests pin both behaviors. - PowDB embedded version detection under tsx on Node 20. The optional-peer
helper resolved the addon's version with
require('<pkg>/package.json'), which tsx's CommonJS hook on Node 20 fails to load even though resolution succeeds, so every version-gated capability threw E017 ("could not report a version") on that lane. The helper now resolves the path and reads the file directly, which is loader-independent. - Error-code enforcement script recognizes typed subclasses. The CI check
now knows
ReadOnlyError(E018) andDestructivePushRefusal(theValidationErrorsubclass thrown byschemaPush), which it previously flagged as untracked, keeping the error-codes gate red.
These three were the standing CI failures on main; the branch is fully green again, and the hardened tag-publish gate introduced in 0.38.0 (which correctly refused to publish over the red integration lane) now passes end to end.
0.38.0 (2026-07-19)
Added
- Studio data-tab power pass. The Data tab grows the tools a real inspection session needs. Per-column filters (equals, not, contains, comparisons, IS NULL / IS NOT NULL) stack with the text search, compile to fully parameterized SQL on the server (validated column and operator whitelists, capped at 10), and are refused outright on redacted PII columns, including null checks, so a hidden value cannot be probed. Rows are selectable (checkbox column with select-all) with a selection bar for Copy JSON / Copy CSV / Delete selected / Clear; an Export modal copies or downloads the current page or selection as JSON or CSV; double-clicking any cell copies its raw value; and the page size is adjustable from 25 to 500 rows (persisted).
- Studio bulk writes, still PK-addressed. In
--writemode,/api/row/insertand/api/row/deleteaccept arowsarray (capped at 500): each entry passes the same per-row validation as a single write (full-primary-key addressing, column checks against introspected metadata), compiles to its own single-row statement, and the batch runs in one all-or-nothing transaction; a delete whose primary key matches nothing rolls the whole batch back. This powers multi-select delete and the new Paste rows flow (bulk insert from pasted TSV/CSV with a header row, or a JSON array, with live parse preview and per-row errors). Predicate-based mutations and bulk update remain deliberately unsupported. - Studio query-tab visibility. After a run, View SQL / Copy SQL expose the single statement the builder compiled to. Builder validation messages now render (a disabled Run button explains which clause is incomplete instead of graying out silently), and the where builder gains IS NULL / IS NOT NULL operators. Loading a saved query now restores NOT combinators and null-check clauses correctly, the save dialog locks the target table to the builder's table, and deleting a saved query asks for confirmation.
- Studio keyboard shortcuts, for real. The shortcuts the command palette
advertised now work: Cmd+Enter runs, Cmd+S saves (instead of the browser
save dialog), G then Q / D / S switches tabs, R refreshes data, Shift+R
reloads the schema. Tab buttons keep
aria-selectedin sync. - Typed row editor. Enum and boolean columns get dropdowns, JSON columns validate before submit with the parse error shown inline, and timestamp fields carry ISO 8601 placeholders.
Fixed
- Nested-write errors no longer embed user-supplied values. The
connect/update miss messages in the nested-write engine interpolated the
full
where/connectobject (including values such as email addresses) into the exception text even in safe error mode, contradicting the PII-safe errors contract. They now follow the same safe/verbose convention asNotFoundError: key names only in safe mode, full detail under verbose. - Demo mode now honors its "nothing is saved" promise for saved queries.
Saving a query in
turbine studio --demowrote.turbine/studio-queries.jsoninto the working directory, and demo sessions displayed the project's real saved queries. Demo saved queries now live in memory only, die with the process, and the real file is never read or written. - Prototype-safe field validation. Field names that collide with
Object.prototypemembers (constructor,toString,__proto__, ...) inwhere/orderBy/select/ nested-writedatapreviously bypassed the unknown-column check via inherited lookups and crashed with aTypeError. All user-keyed metadata lookups are nowObject.hasOwn-guarded and throw the normal typedValidationError(E003). - Studio boot failures are visible. A schema-load error now renders an error box with a Retry button on every tab instead of leaving the default Query tab on an eternal "Loading schema...". Live demo-mode toggles no longer reset the composed builder query, current table, filters, or selection; builder results re-run after a toggle so redaction on screen always reflects the server state.
- Studio data-grid honesty. Column headers render verbatim
(
created_at, notCREATED_AT), redacted PII columns show a sorting-disabled tooltip instead of a lying sort arrow, rate-limit (429) responses surface a retry hint, and nullable-typed columns (number | null) are classified correctly by the value editors.
Changed
- Release gate hardened. The tag-triggered publish workflow now requires a
live Postgres integration run (seeded fixture) and the packed-tarball smoke
job before
npm publish, matching the PR gate instead of trusting unit tests alone. - Site. The hero version badge derives its tagline from the changelog at
build time (it can no longer go stale), a full changelog page ships at
/changelog, the landing page gains cards for
turbine doctor, multi-engine support,explain(), and the MCP server, and the comparison table is dated with Prisma'srelationJoinsmarked as Preview.
0.37.0 (2026-07-18)
Added
- Studio demo mode.
npx turbine studio --demoboots Studio with no database and noDATABASE_URL: a seeded in-memory sample dataset (users with PII-tagged emails and phones, posts, comments, orgs, relations wired) served by Turbine's own SQLite engine over the Node built-innode:sqlite:memory:(Node 22.5+). A demo banner carries two live toggles, PII (hidden/shown) and Writes (off/on), so the three Studio modes can be experienced in one session: read-only and redacted on boot, flip to see real-looking PII reveal warnings, flip again to insert/edit/delete rows. Writes genuinely apply to the in-memory store (edits stick, refresh shows them) but nothing is ever saved anywhere: the store dies with the process and every launch starts pristine. The full security model applies (token auth, Origin checks on mutating routes, rate limiting, nonce CSP), the mode switcher route exists only in demo mode, and the Postgres path is byte-identical when the flag is off.
0.36.1 (2026-07-18)
PowDB 0.16 support. The 0.16 driver contract is byte-identical to 0.15 (the release is an engine-internal index-correctness fix plus documentation), so this is a verification-and-pinning release, not a feature round.
Changed
- PowDB 0.16 verified and pinned. The full live matrix (networked server +
embedded addon) runs green on 0.16; dev dependencies track
^0.16.0(the optional peer range>=0.7.1 <1.0.0already admits it). The PowQL literal-escaper's tested lexer ceiling is bumped to0.16after verifying the 0.16 lexer is untouched. - NUL-byte regression coverage. PowDB 0.16 fixed wrong rows from
non-unique string indexes on values with embedded NUL bytes (a new on-disk
index format, rebuilt automatically on first writable open). A live
integration test now locks the fix in through the ORM surface: indexed
equality, prefix lookups, and index-driven updates around
"A"vs"A\0"neighbors. The test fails on the 0.15 addon and passes on 0.16. - Read-only snapshot note. The engines page documents the 0.16 index upgrade nuance for snapshot fleets: a read-only open rebuilds the affected indexes in memory on every open until a writable open persists the new format, so run snapshots through one writable open (or take them from a 0.16 primary).
0.36.0 (2026-07-18)
The safety release: first-class PII field tagging with opt-in return semantics,
an opt-in writable Studio (read-only stays the default), an honest and hardened
migration story (destructive gate on push, declared indexes in DDL and diff,
a sanctioned backfill recipe), and the where-clause cache paths unified onto a
single canonical walk with a sampled production cross-check. Reviewed by five
independent passes (product, strategy, security, code quality, UX) before
release; every confirmed finding was fixed or explicitly documented below.
Added
- PII fields. Tag a column
pii: trueindefineSchema(or.pii()on the fluent builder) and Turbine excludes it from every default projection: top-level rows, relation subqueries (with), the batched loader, positional JSON encoding, PowDB loaders and native joins, and the row a write returns. It comes back only when explicitly named inselect, or via the newincludePii: trueread option, which restores every PII column at the top level and at every nestedwithlevel of that query. Filtering, ordering, and grouping by a PII column stay allowed (naming the column is itself the opt-in). Untagged schemas emit byte-identical SQL. The SQL cache key carries the flag, so a cached no-PII statement can never serve an opt-in call. - PII is enforced at the SQL level on writes. A write against a table with
PII columns (
create,createMany,update,delete,upsert, nested writes) returns an explicit non-PII projection instead ofRETURNING *:RETURNING "col", ...on Postgres and SQLite, a projected follow-upSELECTon MySQL, and per-columnOUTPUT INSERTED./OUTPUT DELETED.on SQL Server. PII values are persisted normally; they simply never cross the wire back unrequested. A PII-tagged primary key stays in the projection so the returned row remains addressable. Tables with no PII columns keepRETURNING *byte-for-byte. PowDB is the one exception (itsreturningkeyword takes no column list per the driver spec), so the returned row is stripped client-side there; its upsert reselect already projects non-PII columns. - Writable Studio (opt-in).
turbine studio --writeenables single-row insert/update/delete from the Data tab. Every write is addressed by the row's full primary key (the predicate is rebuilt from the PK alone, so a widenedwherecannot reach the database), compiled by the same validated builders as the library, runs in its own transaction with the same parameterized statement timeout and pinnedsearch_path, and requires a matchingOriginheader. Without the flag the write endpoints do not exist (requests 404) and every transaction remainsBEGIN READ ONLY. The UI shows a persistent WRITE MODE banner and a delete confirmation. - Studio PII redaction. PII-tagged columns render as a redaction
placeholder in every tab, applied server-side before serialization (table
rows, builder rows, nested relation rows, and the echoed post-write row).
Redacted columns are also excluded from the Data-tab substring search and
from
orderBy, so a redacted value cannot be probed or inferred through sort position.--show-piireveals values for a launch, with a loud terminal warning and a persistent PII SHOWN banner in the browser. - Studio row editor: explicit set-NULL. Nullable non-PK columns get a
per-field NULL toggle (insert and edit) that sends an explicit
nullparameter end-to-end; a blank field still means "unchanged" (edit) or "use the default" (insert). A null-toggled PII field sendsnull, never the redaction placeholder. - Destructive gate on
push.turbine pushnow scans the statements it is about to apply with the same destructive-SQL scanner asmigrate upand refuses to run them without the two-step typed confirmation (the literal phrasedestroy my data, thenyes) or an explicit--allow-destructive. The diff is computed once and the confirmed statements are exactly the ones applied (no re-diff between confirmation and apply), and the refusal is a typedDestructivePushRefusal(exported, extendsValidationError, carries the offending statements) rather than a message-text convention. - Declared indexes in SQL DDL.
defineSchematable-levelindexes: [{ columns, unique?, name? }]now emitCREATE [UNIQUE] INDEXfromschemaToSQL/push, andschemaDiffadds declared indexes missing from the live database (reverse:DROP INDEX). Matching is by name with a definition check: a name that matches an existing index whose uniqueness, column list, or partial-WHEREdiffers produces a warning, never a drop. A declared index that resolves to the same name as an automatic FK index supersedes it, so declaring a UNIQUE index on an FK column works. Undeclared database indexes are surfaced as warnings and never dropped. - Backfill migration recipe.
turbine migrate create <name> --recipe backfillscaffolds the sanctioned two-phase pattern for changing a populated column's type: nullable add, batched keyedUPDATEloop,SET NOT NULL(with theCHECK ... NOT VALID+VALIDATEnote for huge tables), and an atomic rename swap, fully commented and reversible. - Check constraints round-trip. Table-level
checksnow survivegenerate: the metadata emitter writes them intometadata.ts, andschemaDiffdiffs named checks (add missing, warn on expression drift). TURBINE_CACHE_CHECK_SAMPLE. Opt-in sampled production re-verification of the SQL template cache: set it to a rate in (0, 1] and that fraction of cache hits rebuild the statement and compare byte-for-byte, logging once per fingerprint and throwing on mismatch. The dev-mode always-on cross-check is unchanged.
Changed
- The where-clause walk is unified, at every level.
fingerprintWhere,buildWhereClause, andcollectWhereParams(the three-way hand-synced functions behind two previously shipped cache bugs) now consume one canonical enumeration (walkWhereinsrc/query/where-compile.ts) with a single column-aware scalar classifier. The relation sub-where walkers (the relation-filterEXISTSbody and the relationwith-clausewhere) are consumers of the same walk through one shared scoped trio, so no hand-mirrored where walker remains anywhere in the query builder. The dev-mode cross-check and the new sampled production cross-check stand as tripwires on top. - The query builder is physically decomposed. The 7,000-line
query/builder.tsis now a 2,300-line execution facade over four cohesive modules (query/where.ts,query/relations.ts,query/writes.ts,query/aggregates.ts). Pure refactor: the publicQueryInterfaceAPI is unchanged and the emitted SQL is byte-identical (the full suite's exact SQL assertions pass with zero expectation edits). - Studio CSP hardened. The inline UI script is authorized by a
per-request nonce (
script-src 'self' 'nonce-...');unsafe-inlineis gone fromscript-src. Mutating routes reject absent as well as mismatchedOriginheaders. redactUrlredacts every credential. Multi-URL strings (primary plus replicas) have all userinfo passwords redacted, plus case-insensitivepassword=query parameters.- PowQL literal escaper version ceiling. The embedded
literal-materialization fallback (pre-0.14 addons) now refuses to run
against an engine line newer than the escaping rules were verified on
(
POWQL_LEXER_TESTED_CEILING), with a typed upgrade-pointing error, instead of assuming a future lexer tokenizes escapes identically. - Bundle-size claims are measured and gated.
.size-limit.jsbudgets are re-baselined to measured brotli sizes (main 52.36 kB, edge 39.78 kB) andnpm run sizenow runs inprepublishOnly, so stale size marketing cannot ship again. - Docs honesty pass. New "Migrations in Practice" page documenting exactly
what
migrate create --autocannot do (blindUSINGcasts, rename detection,SET NOT NULLbackfill) and the sanctioned recipes; theschemaDiffexample now matches the real signature; the engines page states up front that the CLI drives PostgreSQL only;TURBINE_E018added to the README error table; every "read-only Studio" claim reconciled with the opt-in write mode.
Fixed
turbine pushcould apply destructive statements without confirmation (it bypassed the gatemigrate upalready had).- Declared-index emission could produce duplicate index names against the automatic FK indexes (apply-time failure) or silently skip a declared UNIQUE index whose name matched an existing plain index.
--recipewith a missing name now errors instead of silently creating a plain migration.push --helpnow documents--allow-destructive.schemaDiffundeclared-index warnings now fire whenever a table defines anindexesarray, including after the last declaration is removed (they were previously silenced exactly when the operator most needed them).- A relation filter with a
nullvalue ({ some: null }) is now treated identically by the SQL build and the cached-parameter collection paths (latent asymmetry, unreachable through the public types). - The SQL-cache segment for global filters now fingerprints another table's filter with that table's own column/relation context. Previously, a function global filter whose shape varied inside a nested relation filter could collapse two different SQL texts onto one cache entry (an exotic configuration; the dev-mode cross-check would have caught it).
0.35.0 (2026-07-16)
PowDB 0.14/0.15 adoption: the embedded transport joins the native typed wire,
relation loading can compile to native server-side joins, read-only snapshot
serving is a first-class deployment mode with a typed routing error, and every
engine gains explain(). Adversarially reviewed pre-release (19 findings
confirmed and fixed, including three high-severity ones and two pre-existing
loader bugs the new parity testing exposed).
Added
- Native PowQL joins for relation loading (PowDB >= 0.13). Passing
relationLoadStrategy: 'join'(per query, or client-wide via the newTurbinePowdbOptions.relationLoadStrategy) compiles eligible top-level relations - hasMany, hasOne, belongsTo, and many-to-many through the junction - to hash-accelerated server-side joins instead of keyed batch lookups: no key lists, no 1,000-key chunking. Eligibility requires no parentlimit/offsetand a unique (or primary-key) correlation column; ineligible relations silently use the batched loaders, so results are identical either way. PowDB's default remains the batched loaders. - Embedded native typed transport (PowDB addon >= 0.14). Embedded queries
now run through the engine's parameterized
queryWithParamsAPI: real positional$Nbinding (token-level, injection-inert) and the same lossless typed result cells as the networked wire, decoded by one shared path. A JSONnull, a missing field, and the string"null"are now distinguishable on the embedded transport too. Older addons keep the literal-materialization path unchanged. Embeddeddisconnect()now performs a real checkpoint-flushclose()on addon >= 0.14. - Read-only snapshot serving (PowDB >= 0.14). Open an embedded snapshot
with
turbinePowDB({ embedded: dir, readonly: true }, schema), or point at apowdb-server --readonly. A newReadOnlyError(TURBINE_E018) is the routing signal:reason: 'snapshot'means nothing can write there (route writes to the primary),reason: 'rbac'means this connection's role may not write. A client-levelreadonly: trueoption fails writes fast locally, before the wire, on both transports. explain()on every table accessor.db.posts.explain(args)compiles the exact statementfindMany(args)would run and returns the engine's plan as text lines:EXPLAINon PostgreSQL/CockroachDB/YugabyteDB and MySQL,EXPLAIN QUERY PLANon SQLite, nativeexplainon PowDB (lowered executed plans since PowDB 0.14, selectivity estimates since 0.15). SQL Server throws a typed E017. Plan text is diagnostic output, not a stable API, and middleware does not run for it.- Driver-spec error taxonomy. PowDB error mapping now covers the full
quasi-stable family list from the upstream driver spec: query timeouts keep
the engine's message, client-disconnect cancellations map to
ConnectionError, bounded-join rejections map toValidationErrorwith the engine's fix hint, anddatabase is closedmaps toConnectionError.
Fixed
- Two pre-existing PowDB relation-loader bugs, exposed by the new
join-vs-loader parity testing: a relation correlated on a datetime column
silently stitched to empty (Date object identity was used as a map key), and
a relation
selectthat omitted the foreign key returned[](the correlation column is now fetched internally and stripped from the output). $transactionon PowDB now carries the pool'sreadonlyflag and capability set into the transaction scope; previously the read-only fail-fast guard and version gates did not apply inside transactions.- An embedded PowDB transaction queued behind the single-writer gate when
disconnect()ran no longer executes against the closed handle; it fails with a typedConnectionError.
Changed
- Dev/test matrix pinned to PowDB 0.15 (client, embedded addon, server). 0.15 itself required no driver-surface changes: its per-index statistics and cardinality-aware conjunction planning benefit Turbine-generated queries, including the new native joins, with no code change.
- The upstream PowDB driver spec (
docs/integrations/powql-for-drivers.mdin the PowDB repository) is now the contract the driver is built against.
0.34.0 (2026-07-15)
PowDB engine parity with PowDB 0.12/0.13: the JSON document API, the lossless native wire, code-first doc-field indexes, catalog introspection, and typed connection errors. Adversarially reviewed pre-release (16 findings confirmed and fixed, including a CJS build break and a retry race).
Added
- JSON documents on PowDB (engine >= 0.12). The
jsoncolumn type is first-class: objects and arrays bind as typed document parameters, and Turbine's existing JSON API compiles to PowQL path expressions with every path segment and value bound as a typed parameter:JsonFilterwhere-filters (equals/not/gt/gte/lt/lte/hasKey), top-level and inside relation filters. A digit-only segment addresses an array index (SQL-engine parity).equals: nullmatches a JSONnullor a missing key on PowDB (documented divergence).containsand pathlessequalsthrow a per-operator E017 (PowQL has no containment operator).- JSON-path
orderBy(with numeric casting) andgroupByJSON-path group keys and aggregate targets, with the same alias, ordering, and error semantics as the SQL engines (including_countbeing selected by default and orderable without being requested). Every JSON feature is capability-gated: pre-0.12 engines get a typed E017 with an upgrade hint instead of an engine parse error.
- Native typed wire (engine >= 0.13, networked). The networked transport
uses PowDB's lossless
queryNativeRawAPI when the client and server support it: a JSONnull, a missing field, and the string"null"are distinguishable end-to-end, and every result is coerced according to the wire that actually served it (heterogeneous injected pools included). - Doc-field expression indexes (engine >= 0.13).
defineSchemaacceptsindexes: [{ docField, path, unique? }](and plain column indexes);powqlSchemaDDLemits the parenthesizedalter T add index (.col->"seg")DDL. Numeric path segments are validated as non-negative integers at schema-build time. Declared code-first indexes never arm the missing-index advisor (SQL DDL generators do not create them). - Catalog introspection (engine >= 0.10).
introspectPowdbDatabase(exported fromturbine-orm/powdb) reads a live PowDB catalog viaschemaanddescribestatements intoSchemaMetadata. Relations are always empty (PowDB has no declared foreign keys) and the primary key is inferred heuristically, sodefineSchemaremains the recommended path. - Typed connection errors + opt-in stale-read retry. Protocol-level
failures (including the "received unexpected frame" shape produced by a
stale idle socket) map to
ConnectionError(E004) with.causepreserved, andauth_failedmaps to a typed error. TheretryStaleReadsoption replays a first-statement read once on that exact signature: never a write, never inside a transaction (the action is threaded per call, so concurrent operations cannot confuse the retry decision). - Unique doc-field index violations map to
UniqueConstraintError(E008).
Changed
- JSON-path
orderBydefaults to NULLS LAST in both directions on PostgreSQL and SQLite (previously PostgreSQL'sDESCdefault put null/missing-path rows first). This matches pick-row relation ordering (0.33) and engines whose path ordering is nulls-last in both directions, so the same query orders identically across every driver. Passnulls: 'first' | 'last'to override. - PowDB
protocol_error-class failures now surface asConnectionError(E004) instead ofValidationError(E003). Update error handling that matched on E003 for connection-shaped failures. - Engine note documented:
_sumover a group with no value at the JSON path returnsnullon SQL engines and0on PowDB.
0.33.0 (2026-07-15)
Added
- Opt-in LATERAL plan for pick-row relation ordering (PostgreSQL). Pick-row
ordering entries accept
plan: 'lateral'to compile asLEFT JOIN LATERAL (SELECT ... ORDER BY ... LIMIT 1) ON trueinstead of the default correlated scalar subquery. Results are identical (verified row-for-row on live PostgreSQL, including NULLS placement, pagination, the batched relation-load strategy, and streaming); the join plan can be substantially faster on large parent sets where the ordering subquery dominates. The default plan's SQL is byte-for-byte unchanged.planis validated strictly: unknown values throw E003, and dialects without lateral join support (SQLite, MySQL, SQL Server, PowDB) throw a typed E017 via the newsupportsLateralJoindialect capability flag rather than emitting broken SQL. Lateral applies to top-level ordering entries; nested pick ordering keeps the subquery plan.
0.32.2 (2026-07-15)
Fixed
groupBycan order by every column the result actually contains.orderByongroupBypreviously validated keys against the table's physical columns only, so ordering by a JSON-path group-key alias threw E003 and ordering by an aggregate threw E005, even though HAVING already accepted both.orderBynow supports plain by-columns, JSON-path group-key aliases (explicit or default),_count, and_sum/_avg/_min/_maxblocks including JSON aggregate targets keyed by their alias, with{ sort, nulls }specs, on all four SQL dialects. The ORDER BY re-emits the same expression as the SELECT list (already-bound JSON path parameters are reused, no extra binds). Ordering by an aggregate that was not requested, or by an unknown key, throws aValidationErrorlisting the valid keys for that call. PowDB refuses aggregate order keys with a typed E017 instead of emitting invalid PowQL.
0.32.1 (2026-07-14)
A first-run and hardening release: the new-project funnel now works out of the box on current tooling defaults, the SQL cache polices its own invariants in dev, and a latent MySQL pagination-cache bug is fixed. Fully adversarially reviewed (11 findings confirmed and fixed pre-release).
Fixed
- CommonJS projects work with the CLI.
npm init -yon npm 11 writes"type": "commonjs"; in such projects the config and schema loaders received a CJS-interop double-wrappeddefaultexport, read every field asundefined, and every command failed with a misleading "No database URL provided". Both loaders now unwrap the interop shape, and config load errors are surfaced with the file name and cause instead of being silently swallowed.turbine initprints a note about the project module type. - MySQL pagination cache correctness. On dialects that inline LIMIT/OFFSET
literals into SQL (MySQL), the values are now part of the SQL-cache
fingerprint (top-level and per-relation
withlimits). Previously a cache hit could silently reuse a different limit or offset value. Parameterized dialects (PostgreSQL, SQLite, SQL Server) are unaffected. distinctcache fingerprint now uses the user-supplied column order, matching the emittedDISTINCT ONclause.- Published tarball ships no install scripts.
prepareis stripped at pack time and restored afterward, so consumers using install-script auditing get no warnings. Local dev hook installation is unchanged. - Docs accuracy sweep in
docs/USING-TURBINE-ORM.md, the README, and the site: Studio described as it exists today (ORM-native builder, port 4983), the realdb.pipeline([...])API (atomic by default,{ transactional: false }to opt out), error table extended through E017, CLI command list matchesturbine --help(includingmcpandmigrate deploy), CJS wording made precise, and the quickstart requires Node 20 to matchengines.
Added
- CLI auto-loads
.env. Everyturbinecommand loads a local.envat startup (viaprocess.loadEnvFile, Node 20.12+). Variables already in the environment always win, a warning is printed when an.env-sourcedDATABASE_URLoverrides a differingurlinturbine.config.ts, and unreadable.envfiles degrade to a warning instead of crashing. turbine()with no arguments now falls back to theDATABASE_URLenvironment variable when no pool, connection string, or explicit connection fields are provided, matching what the docs and generated JSDoc always said.- Dev-mode SQL-cache cross-check. When
NODE_ENVis notproduction, every SQL-cache hit rebuilds the statement fresh and verifies the cached SQL and parameters match exactly, throwing aValidationError(E003) on any lockstep mismatch. Zero overhead in production; disable withTURBINE_DISABLE_CACHE_CHECK=1. This class of invariant violation shipped silent wrong-results bugs twice before; it now fails loudly at development time. - Broader driver-error mapping.
wrapPgErrornow maps57014(server-sidestatement_timeoutcancellation) toTimeoutError(E002), and connection-class failures (SQLSTATE08xxx,53300,57P01-57P03, plus driver-levelECONNREFUSED/ECONNRESET/ETIMEDOUT/ENOTFOUND/EPIPE) toConnectionError(E004), all preserving the original error as.cause. - Quickstart smoke gate in CI. A new job installs the packed tarball into a
scratch CommonJS project and runs the documented quickstart literally (init,
push, generate, first query via
turbine()with an.env-sourced URL) against a real PostgreSQL service.
Changed
- Benchmarks re-measured on 0.32.0 against Prisma 7.6 (adapter-pg,
relationJoins) and Drizzle 0.45 on local PostgreSQL 17.9 over a Unix socket, which isolates per-query overhead instead of hiding it behind network latency. All ten scenarios published, including the ones Turbine does not win. README, the site benchmarks page, andbenchmarks/RESULTS.mdall carry the new numbers and methodology.
0.32.0 (2026-07-13)
The last two raw-SQL escape hatches for version-driven data models, designed first and adversarially reviewed (14 findings confirmed and fixed pre-release, including two cache/SQL-validity bugs in the new code itself).
Added
- Pick-row relation ordering. Order parents by a column or JSON path taken
from ONE row of a to-many relation, chosen by an inner ordering and optional
filter:
orderBy: { versions: { pick: { orderBy: { createdAt: 'desc' }, where: {...} }, by: { field: 'data', path: ['title'] }, direction: 'asc' } }. Compiles to a correlated scalar subquery in ORDER BY (no FROM-clause restructuring), cache-safe, works with both relation-load strategies. Parents with zero related rows sort last by default. hasMany only; not combinable withdistinct(clear error); plain-columnbyworks on SQLite/MySQL/SQL Server, JSONbyfollows the JSON-ordering dialect rules. - JSON-path group keys and aggregate targets in
groupBy.by: [{ field: 'data', path: ['category'], alias? }]and_sum: { price: { field: 'data', path: ['price'] } }(also_avg,_min,_max;_sum/_avgalways cast numeric).havingworks on the aliases; result-key collisions are detected on the EMITTED column names and throw upfront. distinctOnrow source forgroupBy(Postgres only): aggregate over the newest row per key,distinctOn: { columns: ['instanceId'], orderBy: { createdAt: 'desc' } }wraps the row source inSELECT DISTINCT ON.
Fixed
distinctcombined with any relation-basedorderBy(_count, to-one column, and the new pick shape) crashed at runtime with invalid SQL ("missing FROM-clause entry"). Now rejected with a clearValidationErrorupfront. The_count/to-one crash predates this release.- SQL-cache collision on multi-column to-one relation
orderBy. The cache fingerprint sorted entries while the compiler preserved their order, so{ name: 'asc', email: 'desc' }and the swapped literal shared one cache entry and a warm cache silently served the wrong ORDER BY precedence. The fingerprint now captures insertion order. Predates this release. - JSON-path parameters now encode per dialect. SQLite/MySQL/SQL Server
JSON extraction takes a
'$.a.b'JSONPath string, not a Postgrestext[], JSON filters, JSON ordering, and the new JSON groupBy now actually execute on those engines (verified live onnode:sqlite). Predates this release for JSON filters/ordering.
0.31.0 (2026-07-13)
Three production-blocking bug fixes plus two new query capabilities that eliminate common raw-SQL escape hatches in version-driven / multi-tenant data models.
Fixed
- Owned-pool
disconnect()leaked every driver connection on the networked PowDB path,turbinePowDB({host, port})never patcheddisconnect(), so the driver pool'sclose()never ran and a one-shot script hung until the server's 300s idle timeout. Owned pools now close ondisconnect(), andPowdbPool.end()additionally destroys still-checked-out clients (the driver'sclose()only reaps idle ones). Queries afterdisconnect()throw a typedConnectionErroron both transports. - Nested-relation
orderByrejected camelCase columns -with: { fields: { orderBy: { sortOrder: 'asc' } } }threw E003 because the relation-subquery path skipped the target table's columnMap. Nested orderBy now accepts exactly what top-level orderBy accepts, unified across the join strategy, batched loader, m2m, and the MSSQL override; belongsTo/hasOne subqueries with anorderBynow order before theirLIMIT 1. - Cold-client false E017 on a same-tick transaction burst, the
re-entrancy marker was planted with
AsyncLocalStorage.enterWith()in the caller's context, so sibling$transactioncalls launched in one tick could see each other's markers (runtime-dependent). The marker now lives only inside the transaction callback's async subtree via a new optionalwrapTransactionCallbackdriver seam, the failure is impossible by construction. Implicit nested-write transactions plant the same marker. One contract change: a second raw manualbeginfrom the same context now queues FIFO (bounded bytransactionQueueTimeoutMs) instead of throwing E017. - Connection release now honors the destroy contract (adversarial-review
finding):
release(err)with a truthy error destroys the connection instead of re-idling it, and any release with an un-endedbeginfires a bounded best-effortrollbackfirst, so a$transactiontimeout can no longer return a connection with an open server-side transaction to the pool (which blocked the next transaction on PowDB's global write lock).
Added
- Column-to-column
wherecomparison,{ equals: { col: 'otherField' } }(alsonot/gt/gte/lt/lte) compiles to"a" = "b"with no bound param; cache-fingerprint-safe, works in relation filters andwith.where. On json/jsonb columns anequalsobject stays a JSON value. - JSON-path
orderByon same-table json/jsonb columns -orderBy: { data: { path: ['weight'], direction: 'asc', type: 'numeric' } }, top-level and in nestedwithorderBy. Cross-relation JSON ordering and grouped JSON aggregates are deferred to a designed 0.32.
Docs
docs/internal/NEXT-INTEGRATIONS.mdno longer claims PowDB was "declined" - the driver shipped in 0.22 and is load-bearing. (Per-call/per-tablewarnOnUnlimited, also requested, already shipped in 0.30.0.)
0.30.0 (2026-07-13)
Fixes and features for JSONB-heavy, Prisma-migrated workloads, plus alignment with PowDB 0.10. Adversarially reviewed before ship; 15 review findings (including one critical) fixed pre-release.
Fixed
- JSON/array filters inside relation filters were silently dropped. A
JsonFilter({ path, equals }) undersome/every/none, or in awith.where, compiled to a broken jsonb equality and matched nothing, with no error. Both paths now route through the real JSON/array clause builders, with the SQL-cache fingerprint and cache-hit param-collect mirrors kept in lockstep. - Postgres enum columns failed
createManywith "column is of type X but expression is of type text". The bulkUNNEST($n::text[])form defeated Postgres's enum inference. Enum columns (recognized via introspected metadata) now get an explicit::"EnumName"cast on every write bind - create, createMany (::"EnumName"[]), update, updateMany, upsert. Gated to the Postgres dialect; cross-schema type-name collisions are excluded via the newly recorded type schema. - FK/relation name collisions produced unsound generated types and
unreachable relations. A camelCase FK like
currentVersionIdderived a belongsTo that shadowed the scalar column (TS2430 under--strict, relation-where misroutes at runtime). Relation naming now disambiguates per-FK-column (currentVersion), with legacy names preserved wherever they were collision-free, regenerating a working schema does not rename its relations.turbine mcp, the SQLite/MySQL/MSSQL introspectors, and the newschemaDefToMetadataall share one naming implementation, and the generate-typecheck CI gate now compiles the colliding fixture under strict tsc. turbine-orm/powdbwas unusable from CommonJS with ESM-only@zvndev/powdb-client≥ 0.9 (ERR_PACKAGE_PATH_NOT_EXPORTED): the CJS build lowered the lazyimport()torequire(). Optional-peer loads (powdb, mysql2, mssql) now route through a.ctshelper whose NodeNext-built copy keeps a realimport(). Peer ranges widened to>=0.7.1 <1.0.0.- A failed BEGIN no longer emits a best-effort ROLLBACK (all engines).
Previously a
$transactionwhose BEGIN threw (e.g. PowDB queue timeout) still sent ROLLBACK, which on single-handle engines (embedded PowDB) landed inside the other open transaction, silent partial commits. The PowDB pools additionally refuse to forward commit/rollback from a scope that never acquired the transaction gate. (found by adversarial review)
Added
-
JsonFilter range operators
gt/gte/lt/lte(withpath): numeric values compare via a::numericcast, strings as extracted text.db.products.findMany({ where: { data: { path: ['rating'], gte: 4 } } }). -
schemaDefToMetadata(def), pureSchemaDef→SchemaMetadataconverter, so code-first schemas drive non-SQL engines without a live database:turbinePowDB(pool, schemaDefToMetadata(mySchema)). -
PowDB concurrent transactions queue FIFO instead of throwing E017 under the single-writer lock. Re-entrant and nested transactions still throw E017 immediately (queueing them would deadlock; detection via a chained AsyncLocalStorage marker that survives cross-pool nesting). New
transactionQueueTimeoutMsoption (default 30000;0/Infinitywaits forever) →TimeoutErrorE002 on elapse. -
turbine generate --no-timestamp, omits theGenerated at:header so regenerated output is byte-identical. -
warnOnUnlimitedper call and per table,findMany({ warnOnUnlimited: false }), orwarnOnUnlimited: { userProfiles: false }in config (accessor or snake_case keys). -
PowDB 0.10 alignment: reserved PowQL words (incl. the new
schema/describekeywords) are backtick-quoted automatically in bare-identifier ledger_entries of generated PowQL; the server's new "transaction gate timeout" maps toTimeoutErrorE002.
0.29.0 (2026-07-12)
Feature: batch $transaction([...]) pipelines on drivers that support it.
Changed
- Batch
$transactionis one write burst on pipelining drivers. The array form previously awaited each deferred query sequentially, N statements cost N round trips even though the batch is a single atomic unit. When the checked-outPgCompatPoolClientadvertises the new additive capability flagsupportsPipelining: true,transactionBatchnow dispatches every statement back-to-back inside BEGIN/COMMIT and collects replies in order (Promise.allSettled, all in-flight replies drain before a ROLLBACK, and the lowest-index failure is thrown, wrapped as before). The PowDB pool's checked-out clients advertise the flag;node-postgrespaths are byte-identical to 0.28.x (flag absent → sequential path unchanged), and the pipelined path is disabled for dialects withresultStrategy: 'reselect'.
Added
PgCompatPoolClient.supportsPipelining?: boolean, optional, additive; any PgCompat driver whose connection preserves FIFO reply order over a single socket can opt in to get the batched-transaction fast path.
0.28.3 (2026-07-11)
Patch: turbine generate output typechecks again.
Fixed
- Generated client failed
tscwith TS2415 ("incorrectly extends") since 0.26. The baseTurbineClient.$transactiongained the batch-array overload ($transaction([...queries])), but the generator's interface-merge for the typed client still emitted only the callback signature, and a merged member must be compatible with the base member on its own. The generatedTurbineClientinterface now redeclares both overloads (typed callback + batch array). Caught by type-checking a generated client on 0.28.2. - New regression gate:
generate-typecheck.test.tscompiles freshly generated output withtsc --noEmitagainst the repo's own source types (path-mapped), so template ↔ client-type drift can never ship again, string-pin tests alone stayed green through this break.
0.28.2 (2026-07-10)
Post-smoke-audit patch for 0.28.1, consumer typecheck + docs honesty.
Fixed
@types/pgrestored todependencies. Moving it todevDependenciesin 0.28.1 broke stricttscfor consumers whose projects do not setskipLibCheck(published.d.tsfiles importpgtypes). Runtime was always fine; TypeScript-first installs were not. Release gate now requires a pack-install + strict consumer typecheck before publish.- README PowDB capability blurb rewritten to match the real engine (returning writes, auto or UUID PKs, client-side relation loads incl. m2m, nested writes for hasMany/hasOne/belongsTo).
- Bundle-size claim clarified as brotli import graph excluding
pg, not dual-build install size. - STABILITY.md stable CLI list includes
doctor,mcp,migrate deploy. - Committed
site/lib/version.tsregenerated so the git tree matches the package version.
0.28.1 (2026-07-10)
Gold-standard OSS hygiene pass, trust surface for consumers and CI, not a feature drop.
Improved
- Error messages include stable codes. Every
TurbineErrormessage is prefixed with its code tag (e.g.[TURBINE_E008] …) so logs are greppable without structured field access. Branch onerr.code/instanceof, do not parse the message text. - Coverage floors ratcheted to lines/statements 80%, functions 82%, branches 82% (measured actuals ~82–84%).
- Engine CI jobs are hard gates (MySQL, SQL Server, CockroachDB, PowDB), no longer
continue-on-error. - Pack-smoke verifies
sqlite,powdb, andadapterssubpath exports in addition to main/serverless/mysql/mssql. - Seeded generative SQL-safety fuzz (
sql-safety-fuzz.test.ts) overquoteIdent, equality/LIKE, and numeric filters. - Query module split:
query/filters.ts(filter-shape guards + fingerprints) andquery/deferred.ts(DeferredQuery/ options types) extracted frombuilder.ts. @types/pgmoved todevDependencies, runtimedependenciesis justpg.engines.nodeis>=20(matches the CI matrix; Node 18 is EOL).- SECURITY.md / STABILITY.md supported-version tables updated for 0.28.x.
- CODE_OF_CONDUCT.md added; linked from README + CONTRIBUTING.
- Internal sprint/strategy scaffolding moved under
docs/internal/.
0.28.0 (2026-07-09)
Parity sprint, the largest feature release since multi-engine support. A batch of gaps closed at once: query ergonomics (NULLS ordering, relation _count, ordering by a relation), schema completeness (referential actions, code-first enums/arrays/vector/checks), global filters for soft-delete and multi-tenancy, read replicas, a read-only MCP server for AI agents, seed-as-code, a non-interactive migrate deploy, Zod generation, and views + generated columns. Everything is additive, the Postgres default and the existing findMany/with/where API are unchanged, and npm i turbine-orm still installs only pg.
Added, query ergonomics
NULLS FIRST/NULLS LASTordering.orderByvalues accept a spec object{ sort: 'asc' | 'desc', nulls?: 'first' | 'last' }alongside the plain'asc'/'desc'direction:orderBy: { lastLoginAt: { sort: 'desc', nulls: 'last' } }→ORDER BY "last_login_at" DESC NULLS LAST. Applies everywhere anorderByis compiled, top-levelfindMany/stream,groupBy, and the inner subquery of awithrelation. Plain directions are byte-identical to before. Behavioral note:NULLS FIRST/LASTis a PostgreSQL and SQLite feature, on MySQL and SQL Server, explicit nulls placement throwsUnsupportedFeatureError(E017) rather than emitting broken SQL.- Relation
_countinwith.with: { _count: true }counts every to-many relation of the table;with: { _count: { posts: true } }counts only the named ones. Each becomes a correlatedCOUNT(*)scalar subquery (hasMany + manyToMany via the junction), assembled into a typed_count: { [relation]: number }object per row. Coexists with real relation subqueries. Errors:RelationError(E005) on an unknown relation,ValidationError(E003) on a to-one relation. The batched load strategy computes it with one grouped follow-up per counted relation, output deep-equal to the join strategy. - Ordering by a relation.
orderBy: { posts: { _count: 'desc' } }orders by a to-many relation's count (correlatedCOUNT(*));orderBy: { author: { name: 'asc' } }orders by a to-one relation's target column (correlated scalar subquery,{ sort, nulls }supported). Relation ordering adds no bound params. To-many relations allow only_count; to-one allow real target columns. Unknown relation → E005, invalid key/column → E003.
Added, schema completeness
- Referential actions on foreign keys.
referencesnow accepts{ target, onDelete?, onUpdate? }in addition to the'table.column'string, and the fluent builder gains.references(target, { onDelete, onUpdate }). Actions:'cascade','restrict','set null','set default','no action'; omitted clauses default toNO ACTION. DDL emitsON DELETE …/ON UPDATE …; introspection reads them back frompg_constraint, andschemaDiffdetects action changes (non-destructiveDROP CONSTRAINT+ADD CONSTRAINT). The plain string form is unchanged. - Code-first enums.
defineSchema(tables, { enums: { post_status: ['draft', 'published', 'archived'] } })declares enum types; columns opt in with{ type: 'enum', enumName: 'post_status' }. DDL emitsCREATE TYPE … AS ENUM (…)before the tables that use it (labels are single-quote escaped), and codegen maps enum columns to string-literal unions. - Array columns.
{ type: 'text', array: true }emitsTEXT[](works withvarcharlengths →VARCHAR(8)[]), maps toT[]in generated types, and is queryable with the existinghas/hasEvery/hasSomeoperators end-to-end. - pgvector columns.
{ type: 'vector', dimensions: 1536 }emitsvector(1536)and, by default, prependsCREATE EXTENSION IF NOT EXISTS vector;(passextensions: 'manual'to emit a comment instead); maps tonumber[]. Gated on the Postgres dialect, a dialect without vector support throwsUnsupportedFeatureError(E017). - Check constraints. Column-level
check: 'price >= 0'emits an inlineCHECK (expr); table-levelchecks: [{ name?, expression }]emitCONSTRAINT "name" CHECK (expr)(or a bareCHECKwhen unnamed). Introspection reads them back. A violation throwsCheckConstraintError(E011) at write time. - New package-root exports:
ReferentialAction,ReferenceDef,CheckDef,CheckMetadata, andDefineSchemaOptions.
Added, client
- Global filters (soft-delete / multi-tenancy).
TurbineConfig.globalFiltersmaps a table accessor to aWhereClause, or a() => WhereClauseevaluated per query build, that is AND-merged into the compiledWHEREof every read and mutation, and every relation subquery targeting that table (join and batched strategies, relation filterssome/every/none,_count, and relationorderBy). Function filters enable per-request tenancy via closure.create/createManyare never filtered. A per-queryskipGlobalFilters: true | string[]opts out. Behavioral note: the empty-whereguard onupdate/deletestill checks the user-suppliedwhere, so a global filter never turns an unguarded mass mutation into an allowed one. Filter shape participates in the SQL cache; values are re-collected per build. - Read replicas.
TurbineConfig.replicas(connection strings orPgCompatPools) load-balances read-only operations outside a transaction (findMany,findFirst,findUnique,*OrThrow,count,aggregate,groupBy,findManyStream) round-robin across replicas; ALL writes,$transactionbodies,pipeline,raw/sql,$listen/$notify, and observability flushes stay on the primary.client.$primary()returns a cached view that pins every operation (reads included) to the primary, for reading your own writes without replication lag. String replicas are owned pools (closed ondisconnect()); external pools follow the caller-owns-lifecycle contract. Zero replicas = today's single-pool path, unchanged. - Batch
$transaction(DeferredQuery[])overload. Pass an array ofbuild*deferred queries and they run atomically on one connection (BEGIN… each query …COMMIT), returning a positionally-typed results tuple. Any error rolls the whole batch back and rethrows; an empty array resolves to[]. The callback form is unchanged,$transactionaccepts either.
Added, CLI & tooling
turbine mcp, zero-dependency, read-only MCP server. Speaks JSON-RPC 2.0 over stdio (protocol2025-06-18, server nameturbine-orm) for AI agents like Claude Code and Cursor. Six read-only tools,schema_overview,table_detail,migrate_status,doctor_report,explain_query(schema-validatedfindMany-style builder args only, no free-form SQL),sample_rows(≤ 50 rows, table validated against the introspected schema). Every database access runs insideBEGIN READ ONLY; there is no write surface and no raw-SQL execution path. Malformed frames return a JSON-RPC error without crashing.--include/--excludescope the exposed tables.- Studio / Observe refuse non-loopback binds by default.
--hostother than loopback (127.0.0.1/localhost/::1) exits 1 unless you pass--allow-remote(loud warning when you do). Matches the “local single-user tool” security model instead of warn-and-proceed. turbine migrate deploy, non-interactive production apply. Never prompts (works with no TTY): applies all pending migrations inside the same advisory-lock + per-migration-transaction machinery asmigrate up, reportsN applied, and supports--dry-runto list pending without applying. Refuses to run (exit 1, clear message) on a checksum mismatch or a missing migration file, so a drifted history fails the deploy instead of diverging. It never auto-generates, seeds, or pushes.- Seed-as-code.
turbine seedresolves the seed from the configseedfield (orseedFilealias) or the first default candidate,seed.ts,seed.js, thenseed.sql..tsruns throughnpx tsx(clear error iftsxis missing),.jsis imported (a default-export function is called),.sqlruns as SQL. NewdefineSeed(fn)export wires up a client fromDATABASE_URL, runs your function, and disconnects. turbine generate --zod. Emits azod.tsfile alongside the generated types with aXSchema/XCreateSchema/XUpdateSchemaper table, derived from column metadata (scalars,z.coerce.date()for dates,z.enum([...])for enums,.array(),z.array(z.number())for vectors,.nullable(); Create/Update optionality mirrors the generated input types). The generated file imports the user-sidezoddep; the Turbine runtime never does.turbine generate --include-views. Introspects views and materialized views as read-only entities (isView: true): codegen emits an entity type and read accessors; a no-PK view's accessor omits thefindUniquefamily (Omit<QueryInterface<T>, 'findUnique' | 'findUniqueOrThrow'>). Behavioral note: every write builder throwsValidationError(E003) "cannot write to a view".- STORED generated columns. Introspection detects
GENERATED ALWAYS AS (…) STOREDcolumns; codegen keeps them in the entity type but omits them from*Create/*Updateinputs. Behavioral note:create/update/upsertrejectdatacontaining a generated column withValidationError(E003) before hitting Postgres.
Docs
- New pages: Global Filters, Read Replicas, MCP Server, Seeding, Zod Schemas, and Views & Generated Columns. Extended the API Reference (NULLS ordering, relation
_count, ordering by a relation), Schema & Migrations (referential actions, enums, arrays, vector, checks), Transactions (batch$transaction), Relations, and the CLI page (migrate deploy,mcp,--zod,--include-views, seed-as-code). - Quickstart honesty: site
/quickstartdocumentstsx,"type": "module",schemavsschemaFile, and the empty-DB path (defineSchema→push→generate) alongside the existing-tables path. - Comparison copy corrected: the Drizzle Studio row previously read "paid tier", local Drizzle Studio is free (only the hosted Drizzle Gateway is paid). Softened across the README, landing page, and Drizzle migration guide.
Chore
- Removed broken root
npm run examplesscript (missingexamples/examples.ts); addedexamples/README.mdindex.npm run dogfoodunchanged. - Widened optional peer
mssqlto^10 || ^11 || ^12(matches testedmssql@12).
0.27.1 (2026-07-08)
Identical to 0.27.0 with an internal lint cleanup in the destructive-statement scanner; 0.27.1 is the canonical release (0.27.0's npm artifact predates the tagged source).
0.27.0 (2026-07-08)
Destructive migrations now require explicit, triple confirmation. A migration file containing data-destroying SQL should never run just because it exists, and "one flag and your table is gone" is too easy.
Added
- Data-loss gate on
migrate upandmigrate down. Before applying, Turbine scans every pending migration (UP direction) / every DOWN section being rolled back for destructive statements:DROP TABLE,DROP SCHEMA,DROP COLUMN,TRUNCATE,DELETE FROM,UPDATEwithout aWHERE, andALTER COLUMN … TYPE(potentially lossy cast). Comments, string literals, and dollar-quoted bodies are stripped first, so-- DROP TABLE xor a seeded string containing SQL never false-positives;DROP INDEX/DROP CONSTRAINT/DROP TRIGGERare deliberately not flagged (recreatable, no row data). When something is found:- Interactive CLI: prints an itemized report (statement kind, target object, what it destroys), then requires typing the literal phrase
destroy my data, then a finalyes. Anything else aborts with nothing applied. - Non-interactive (CI/pipes): always refuses; proceeding requires the explicit
--allow-destructiveflag, which also prints a loud warning. - Programmatic API (
migrateUp/migrateDown): refuses by default with an itemizedMigrationError; opt in withallowDestructive: true. - The refusal is checked BEFORE anything runs, a refused batch applies zero migrations.
- Interactive CLI: prints an itemized report (statement kind, target object, what it destroys), then requires typing the literal phrase
- (
turbine pushandmigrate create --autowere already non-destructive: the schema differ has never emitted forwardDROP TABLE/DROP COLUMNstatements, dropped columns are detected but excluded from executable statements.)
0.26.0 (2026-07-08)
Dogfood release from migrating a large production Prisma app onto Turbine, a batch of correctness fixes the migration surfaced, plus the tooling that makes Turbine's correlated relation loading robust on schemas that grew up under batched-loader ORMs: a missing-FK-index doctor, an opt-in batched loading strategy, and an opt-in lean JSON wire encoding.
Fixed
timestamp(without time zone) columns are now parsed as UTC (correctness, behavior change). Both the pg driver default (OID 1114) and nestedjson_aggstrings parsed offset-less timestamps in server-local time, the same row produced a different instant per deployment region, and every timestamp shifted by the machine's UTC offset. Turbine now pins offset-less timestamps to UTC (parseDbDate), matching Prisma/Rails/Django semantics. The OID 1114 parser is registered only for Turbine-owned pools (external/serverless pools are never touched). Opt out withutcTimestamps: falseif you relied on local-time parsing.hasOnerelation subqueries correlated in the wrong direction. ThehasOnepath reused thebelongsTocorrelation (target.pk = parent.fk) instead oftarget.fk = parent.pk, producing wrong rows (or type-mismatch errors when the parent column wasn't a key). OnlybelongsToreverses the correlation now. (Also fixed in the SQL ServerFOR JSON PATHpath.)- Wide tables no longer hit Postgres's 100-argument limit in relation subqueries.
json_build_objecttakes 2 arguments per column, so relations targeting tables with >50 columns failed withcannot pass more than 100 arguments to a function. The Postgres dialect now chunks into(jsonb_build_object(…) || jsonb_build_object(…))::jsonconcatenation. distinct+orderByno longer conflicts with Postgres's DISTINCT ON rule.SELECT DISTINCT ON (cols) … ORDER BY other_colis rejected by Postgres unless the DISTINCT columns lead the ORDER BY. Turbine now orders by the distinct columns in an inner query and applies the user'sorderByin an outer wrapper. (distinct+ vectororderBythrows a clear validation error instead of emitting broken SQL.)- Top-level
selectcombined withwithno longer drops the relation from the result type. When a generated entity interface declares optional relation props, the with-key was also akeyof Tand the select-narrowingPicksilently removed it, forcing users to dropselectand over-fetch. The with-clause keys are now unioned back into the result type (andomitcan no longer strip a relation thewithclause populates). Compile-time regression tests included.
Added
npx turbine doctor, missing-FK-index advisor. Turbine loadswithrelations as correlated subqueries: the child table is probed once per parent row, so an unindexed FK column costs a full table scan per parent, pathological on large tables, and invisible on the ORMs people migrate from (batchedIN (ids)loading pays a missing index only once, so those schemas routinely lack FK indexes).doctorintrospects the database, derives every column set relations will probe (hasMany/hasOne child FKs, belongsTo reference keys, many-to-many junction keys), reports the unindexed ones sorted by table row count with the exactCREATE INDEXstatement, and--fixwrites a ready-to-apply migration. Measured on a production-shaped dataset: one missing FK index turned a 659-parent query into 659 sequential scans of a 357K-row table (17.8s); with the index the same correlated query ran in 62ms, faster than the batched equivalent (92ms).- Dev-mode missing-index warning. In non-production, the first query that builds a relation subquery over an unindexed FK logs a one-time warning naming the relation, the table/columns, and the exact index DDL (only when the schema metadata actually carries index info, so
defineSchema-only users see no false positives). relationLoadStrategy: 'join' | 'batched', opt-in batched relation loading, as a client-level default (TurbineConfig) or per-query onfindMany/findFirst/findUnique.'batched'runs the base query withoutjson_aggsubqueries, then one flat follow-up per relation (WHERE fk = ANY($1), chunked at 1,000 keys) and stitches client-side, results are deep-equal to the join strategy (verified by integration tests running both). Useful when FK indexes are missing or result sets are huge (flat rows transfer leaner than nested JSON). Sibling relations and key chunks load concurrently (keys travel as oneANY($1)array parameter, chunked at 32K). Honors per-relationwhere/select/omit/orderBy/nestedwith; per-relationlimitis applied client-side per parent. Transaction-safe (follow-ups run on the same connection). Measured on a production-shaped worst case (8,814 parents × 6 relation trees, unindexed FKs, WAN link): join strategy 6.5s,batched~1.9s, roughly 2× faster than a leading batched-loader ORM's equivalent query (~3.7s) on the same data.- Implicit
ison bare to-one relation filters (Prisma-compatible).where: { vendor: { name: { contains: 'x' } } }now works without the explicitiswrapper;is: null/isNot: nullcompile to NOT EXISTS / EXISTS.WhereClausetypes to-many relation props assome/every/nonefilters and to-one props as bare-or-is/isNot. - Relation filters inside
with.whereand at any nesting depth. A relation'swith … wherecan now filter by the relation's own relations (with: { items: { where: { stage: { is: { active: true } } } } }), and relation filters recurse throughsome/none/every/is/isNotwithOR/AND/NOTsupport at every level. jsonEncoding: 'positional', opt-in lean wire encoding forwithrelations (Postgres-only, default'object'unchanged). Relation subqueries emitjson_agg(json_build_array(…))instead ofjson_build_object('key', …), so key names stop being repeated in every nested object of every row; ledger_entries are mapped back to keys client-side and parsed output is byte-identical to the object encoding (integration tests assert deep-equality under both). Measured on a 14-column hasMany relation: 39% fewer wire bytes and ~13% faster end-to-endfindMany, the win grows with column count and result size. Composes with everything (select/omit, ordered/limited relations, hasOne/belongsTo, m2m, nested trees);relationLoadStrategy: 'batched'simply bypasses it (no JSON aggregation there).benchmarks/json-encoding-bench.tsreproduces the numbers.
0.25.0 (2026-07-06)
Added
turbineHttpnow accepts your generated client type for fully typed accessors.turbineHttp(pool, SCHEMA)returned the baseTurbineClient, so the generated typed accessors (db.users,db.posts, …) were invisible to TypeScript on the serverless/edge path, you had to cast at the call site. It now takes a backward-compatible type parameter:turbineHttp<TurbineClient>(pool, SCHEMA)(passing your generated client type) gives the exact same typed accessors as the TCP-pathturbine()factory, no cast. The runtime object is unchanged, the base constructor already materializes those accessors per schema table, and existing untyped calls keep working (default = base client). Caught on the serverless/edge path. (#30)
0.24.0 (2026-07-06)
First-run fixes from a fresh Next.js app on 0.23.2 (#28). Five papercuts that made the first-run experience worse than it should be, a silent empty generate, two rejected column types, doc drift, undocumented prereqs, and an inaccurate serial type. All fixed; the only behavior change is the serial mapping (below), which is safe for existing databases.
Changed
serialnow emitsSERIAL(int4), notBIGSERIAL(int8), behavior change for NEW pushes. Aserialprimary key was typednumberbut, being int8, read back over the wire as a string for large values, the generated type lied.serialnow maps toSERIAL(int4), whose values fit in a JSnumberand are returned as numbers, so the type is accurate end-to-end. A newbigserialcolumn type covers 64-bit auto-increment keys (int8; large values still read back as string, now documented). Existing databases are unaffected:turbine push/migrate --autonever auto-narrow a live column's integer width, so aserialcolumn created asBIGSERIALbefore 0.24.0 is left exactly as-is. Only brand-newCREATE TABLEs getSERIAL. (#28)
Added
bigserial,timestamptz, andjsonbare now first-classdefineSchemacolumn types. The docs' type table listedtimestamptzandjsonb, butdefineSchemarejected them; they now work.timestamptzis an explicit spelling of the timezone-aware timestamp Turbine already emitted fortimestamp;jsonbis an explicit spelling of whatjsonalready emitted. Both aliases (timestamp,json) still work unchanged. (#28)TurbineConfigis now exported as an alias ofTurbineCliConfigfromturbine-orm/cli, matching what the docs import. (#28)turbine generate --allow-emptyescape hatch for the two new guards below.
Fixed
turbine generateno longer silently emits an empty client. Theschemaconfig field is the Postgres schema name (defaultpublic), but the docs told users to put their schema file path there, sogenerateintrospectedWHERE table_schema = './turbine/schema.ts', matched zero tables, and wrote an empty typed client with no error.generatenow errors (exit 1) whenschemalooks like a file path (with a hint pointing atschemaFile) and when introspection matches 0 tables. Pass--allow-emptyto override. (#28)- Clearer CLI error for the CommonJS case. When a project's
package.jsonlacks"type": "module", loading a.tsconfig/schema fails with Node's rawERR_REQUIRE_ESM; the CLI now appends a hint to add"type": "module". (#28)
Docs
USING-TURBINE-ORM.md§0 corrected: the config example usedschema: './turbine/schema.ts'(should beschema: 'public'+schemaFile:) andmigrations:(should bemigrationsDir:). Added a CLI prerequisites section documenting that the CLI needstsxinstalled and"type": "module"inpackage.json, with the exact error messages, neither is set bycreate-next-app. README quickstart updated to match. (#28)
0.23.2 (2026-07-03)
Fixed
- Published files no longer reference missing source maps.
sourceMap/declarationMapemitted//# sourceMappingURL=…comments in every publisheddist/*.js/*.d.ts, but thefilesallowlist excludes the.mapfiles themselves, Node ignores the dangling reference, but Next.js/turbopack's stricter loader logged a failed-to-map warning on every stack trace through Turbine. Maps are now emitted only for local builds and never referenced from published output. (#25, #26)
Changed
pgdependency bumped 8.20.0 → 8.22.0 (upstream fixes; no API change). (#13)- CI: actions/checkout 5 → 7, actions/setup-node 5 → 6; dev-dependency refresh. Docs site upgraded to Next 16 + Tailwind 4 with a real site-build CI gate, and the site's displayed version is now derived from the root
package.jsonat build time (can't drift from the published package). (#4, #12, #23, #27) - New docs page: Turbine + BataDB guide (
turbineorm.dev/batadb), typed Turbine over BataDB's edge HTTP driver or direct TCP, with the dual-transport pattern. (#24)
0.23.1 (2026-07-03)
Coordinated release with PowDB 0.8.0, the PowDB engine's optional peers now accept the newly published @zvndev/powdb-client@0.8.0 / @zvndev/powdb-embedded@0.8.0, and the full test suite runs green against those exact published artifacts (1113 passing / 0 failing).
Changed
- Widened the PowDB optional-peer range to
^0.7.1 || ^0.8.0for both@zvndev/powdb-clientand@zvndev/powdb-embedded, ahead of the coordinated PowDB0.8.0release. The engine surface Turbine uses (query/write/tx/returning) is unchanged in0.8.0, Turbine does not callapplyRetainedUnits, so the widening is safe and additive. Both remain optional peers (peerDependenciesMetaunchanged); a defaultnpm i turbine-ormstill installs onlypg.
Fixed
- CJS build now compiles under TypeScript 6.0.
tsconfig.cjs.jsonsetsignoreDeprecations: "6.0"for itsmodule: CommonJS/moduleResolution: node10pairing, which TS 6.0 otherwise rejects as a hard error (TS5107). ThetypescriptdevDependency floor moves to^6.0.3so the option is recognized. Emitteddist/output is byte-identical to the previous toolchain (verified by a fulldistdiff); no runtime or API change.
0.23.0 (2026-06-29)
PowDB Phase B, server-generated PKs, many-to-many, nested writes, composite-key upsert, plus a correctness fix for relation filters. All PowDB-only; the four SQL engines are untouched and npm i turbine-orm still installs only pg.
Fixed
- Relation filters (
some/none/every) no longer return stale results on PowDB (correctness, affects 0.22.0). PowDB's executor caches anin (<subquery>)result by plan shape, ignoring the literal, so a second relation filter of the same shape with a different value returned the first query's rows (reproduced against the raw embedded addon, no Turbine). Turbine no longer emits an IN-subquery for relation filters: it resolves the inner predicate to a literal key list (resolveRelationFilters) and filters within (<list>), which is always correct. This covers hasMany / hasOne / belongsTo (the 0.22.0 shapes) and the new manyToMany filters, at every nesting level, onfindMany/findUnique/update/delete/count/aggregate/groupBy. Trades one extra round-trip per relation filter for correctness. (Reported upstream to PowDB; the SQL engines were never affected, they use realEXISTS/json_agg.)
Added
- Server-generated / auto-increment primary keys. New
ColumnMetadata.isGeneratedflag distinguishes a DB-assigned PK (serial /IDENTITY/ PowDBauto) from a client-side default. On PowDB,powqlSchemaDDLnow emits theautomodifier (unique auto id: int) for a generated int PK andcreate/createManylet the engine assign the id (read back viareturning) instead of synthesizing a client UUID. Introspection sets the flag fromnextval(/is_identity; the code generator emits it. No change to the SQL engines, the flag is additive and they already omit undefined PKs and rely onRETURNING. - many-to-many nested reads,
with: { tags: true }on a junction relation now loads through the junction (batched, chunked at 1,000 keys), with correct empty-array semantics and nestedwhere/with. - many-to-many relation filters,
where: { tags: { some/none/every } }through a junction table. - Nested writes on PowDB, relation ops in
create/updatedata (create,connect,connectOrCreate,disconnect,set,delete,update,upsertfor hasMany/hasOne/belongsTo) now run through the shared nested-write engine as one flat top-level transaction (PowDB is single-writer / no savepoints, so the whole tree commits or rolls back together). Same coverage as the SQL engines. - Composite-key upsert on PowDB, PowQL's native
upsert … on .coltakes a single conflict column, so a composite PK now falls back to an atomic reselect-or-write transaction. - Live
powdb-integrationcoverage for every item above (6 new tests against the real embedded addon), plus build-only DDL tests for theautomodifier and the composite-PK fix.
Changed
powqlSchemaDDLno longer marks each column of a composite PK individuallyunique. PowDB has no composite-unique constraint (itsuniqueis single-column), and per-columnuniquewrongly forbade, e.g., a member having two tags. Composite-PK columns are nowrequiredonly; a single-column PK still getsunique.
Still unsupported on PowDB (throws UnsupportedFeatureError / E017)
- Composite-key relation filters and composite-key m2m (PowQL has no tuple-
in(a,b) in (…)), nested writes insidecreateMany/upsert(usecreate/update), and the unchanged Postgres-only set: pgvector, LISTEN/NOTIFY, RLSsessionContext, cursor streaming.
0.22.0 (2026-06-28)
New engine: PowDB. Turbine now runs on PowDB, a single-node embedded database with its own query language (PowQL, not SQL), behind the same findMany / with / where / create API. PowDB is the only engine that runs both in-process (embedded) and over a network client against the same data. Postgres remains the default and primary target; PowDB is an additive optional-peer subpath export, npm i turbine-orm still installs only pg.
Added
turbine-orm/powdb,await turbinePowDB(target, schema, options?). Because PowQL shares no surface with SQL, this is not aDialect: a parallelPowqlInterface(PowQL generator with the same public method surface asQueryInterface) is wired in via the newqueryInterfaceFactoryseam, leaving the four SQL engines byte-identical.- Two transports. Embedded (in-process) via the native addon
@zvndev/powdb-embedded, and networked via@zvndev/powdb-clientagainst apowdb-server. Both are optional peer dependencies (^0.7.1), loaded by dynamicimport(), neither is pulled by a default install. - Embedded durability control.
turbinePowDB({ embedded, syncMode: 'full' | 'normal' | 'off', memoryLimit })exposes PowDB 0.7.1'ssetSyncMode/openWithMemoryLimit. WithsyncMode: 'normal', embedded writes drop ~440× (fsync off the commit path) and beat SQLite oncreate(0.009 vs 0.016 ms p50),update(0.008 vs 0.012),createMany(0.278 vs 1.197), and nestedwith, while keeping a real storage engine, indexes, and WAL.syncMode/memoryLimitare feature-detected; using them on a pre-0.7.1 addon raises a clearConnectionError. - Honest capability surface. PowDB writes use the
reselectstrategy with client-assigned UUID PKs (PowDB generates no IDs), N+1 relation loaders (nojson_agg, keys chunked at 1,000), single-writer transactions (no nesting), and code-defined schemas viadefineSchema(no wire introspection). Not-yet-built capabilities throwUnsupportedFeatureError(E017): many-to-many relation filters/nested reads, composite-key relations/reads/upsert, nested writes, cursor pagination /findManyStream, JSON/array/full-text/pgvector filters and vector ordering, plus the Postgres-only trio (pgvector, LISTEN/NOTIFY, RLSsessionContext).
- Two transports. Embedded (in-process) via the native addon
/engines#powdbdocs, a full PowDB section (both transports,syncMode, the embedded-beats-SQLite benchmark, the E017 list, and the platform-binary caveat), plus PowDB coverage in the README "Database engines" section andbenchmarks/CROSS-ENGINE-RESULTS.md.src/test/powdb.integration.test.ts, 10 tests exercising the real embedded addon (gated viaskipGateso the unit lane stays green without it) and wired to a new in-processpowdb-integrationCI job (live addon on Linux, no container).
Fixed
- Empty-
whereguard now gates on the compiled PowQL filter, mirroring the SQL path,{ OR: [] }/{ AND: [] }/{ NOT: {} }/{ OR: [{ field: undefined }] }can no longer bypass the mass-mutation guard onupdateMany/deleteMany. - Single-writer transaction model hardened. A re-entrant or concurrent
db.$transactionon the networked transport used to hang forever on PowDB's global write lock; a pool-levelactiveTransactionguard (both transports) now throwsE017immediately. Nestedtx.$transactionlikewise throws (no savepoints). - Embedded driver errors are now typed. Embedded addon failures (every one tagged
GenericFailure) are mapped by message shape to the right Turbine error (E010not-null,E003type/parse,E008unique), and addon load/shape failures raise a typedConnectionError.
Notes
- Platform binaries (embedded).
@zvndev/powdb-embedded0.7.1 ships prebuilt binaries for darwin-arm64 and linux-glibc (x64/arm64); other platforms (musl/Alpine, Windows, Intel macOS) build from source at install. The networked transport has no such constraint. musl/Intel-mac prebuilts are tracked upstream for PowDB 0.7.2.
0.21.0 (2026-06-26)
Multi-dialect: SQLite, MySQL, and SQL Server engines. Turbine is still Postgres-first, but the query/result core is now engine-agnostic behind a dialect/driver seam, and three new engines ship as additive subpath exports. Drivers are optional peer dependencies, npm i turbine-orm still installs only pg.
Added
turbine-orm/sqlite,turbineSqlite(path | ':memory:', schema). Zero new dependency via Node's built-innode:sqlite(Node ≥ 22.5;better-sqlite3is a documented fallback for older Node). Single-query nestedwithviajson_group_array/json_object(with ajson()subresult wrap so nested trees aren't double-encoded),RETURNINGwrites (SQLite ≥ 3.35),PRAGMA-based introspection, and savepoint-nested transactions. Runs in-process, its full integration suite executes against:memory:in the normal unit lane.turbine-orm/mysql,turbineMysql(config | pool, schema). Optional peermysql2; MySQL 8.0+ (enforced on connect). NestedwithviaJSON_ARRAYAGG/JSON_OBJECT. MySQL has noRETURNING, so writes use the newreselectstrategy (execute, then re-fetch the row);INSERT … ON DUPLICATE KEY UPDATEupserts;information_schemaintrospection;GET_LOCKmigration locking.createManyreturns[](rows are inserted; re-query if you need them).turbine-orm/mssql,turbineMssql(config | pool, schema). Optional peermssql. Nestedwithvia a dedicatedFOR JSON PATHgenerator; writes return rows viaOUTPUT/MERGE;OFFSET … FETCHpaging;INFORMATION_SCHEMA+sys.*introspection;sp_getapplocklocking./enginesdocs page with a per-engine capability matrix, plus a "Database engines" section in the README and CLAUDE.md.- E017
UnsupportedFeatureError, thrown when a Postgres-only feature (pgvector, LISTEN/NOTIFY, RLSsessionContext) is invoked on an engine whose capability flag reports it unsupported, rather than failing with a confusing driver error.
Changed
- The
Dialectcontract became a real multi-engine seam,resultStrategy(returning/reselect/output), aTurbineDriverdriver abstraction (everyBEGIN/COMMIT/SAVEPOINT/isolation/set_configliteral now routes through the dialect),DialectIntrospector, and additive SQL hooks (wrapJsonSubresult,aggSupportsInlineOrderBy,castAggregate,buildInClause,buildRelationSubquery,buildLimitOffset,buildUpdate/buildDeleteStatement). PostgreSQL output is byte-identical and unchanged, engines that don't define a hook fall back to the exact prior SQL. - Engine drivers (
mysql2,mssql) are devDependencies + optional peerDependencies; the root runtime dependency set remains exactlypg. A CIpack-smokecheck verifies each engine subpath imports with its driver absent, and newmysql:8/ SQL Server 2022 CI service-container jobs run the real integration suites.
Fixed
- MySQL optimistic-lock conflicts now throw
OptimisticLockError. On thereselectpath the version-checked UPDATE was followed by a re-fetch onwhereonly (no version predicate), so a conflict silently returned the stale row. The conflict is now detected from the UPDATE's affected-row count, identical behavior to theRETURNING/OUTPUTengines.
Also in this release (product-review sprint)
- Repositioning + onboarding fixes: README and landing now lead with the "safety bundle" (read-only Studio, PII-safe errors, one dependency, checksummed migrations). Fixed copy-paste-breaking docs: serverless
SCHEMAcasing (the generator now also emits a lowercaseschemaalias), the non-existentdb.$queryRaw(→db.sql/db.raw),timeoutMs→timeout, and thewithRetry()"built-in" claim. - Security: closed a LOW stored-XSS gap in the Observe dashboard (
row.model/row.actionnow escaped); hardened the Studio/Observe token check to SHA-256 +crypto.timingSafeEqual. - Docs + tests: new nested-writes, optimistic-locking, and framework-recipes pages; Studio security-perimeter tests (401/403/429/READ-ONLY).
0.20.0 (not released)
There is no 0.20.0. The version was reserved for a product-review sprint whose work was folded into 0.21.0 instead, so the line jumps 0.19.2 to 0.21.0. Nothing was published to npm under 0.20.0 and no entry is missing from this file.
0.19.2 (2026-06-10)
Patch release from a full product review + gold-standard audit, closes a HIGH silent-wrong-rows hole in the SQL cache, makes two documented-but-broken behaviors actually work, and brings the docs/site in line with what the library does.
Fixed
where-key order can no longer cross-bind parameters on a warm SQL cache (HIGH). The cache fingerprint sortedwherekeys, but the SQL-build and cache-hit param-collect paths iterated object-insertion order, so two queries with the same fields in different key order shared one cached SQL string but pushed parameters in different ledger_entries.findMany({ where: { tenantId, id } })followed byfindMany({ where: { id, tenantId } })could execute the cached SQL with the values swapped, silently returning the wrong row (a cross-tenant-leak class when the permuted fields are same-typed). Key enumeration is now canonicalized (sorted) across every fingerprint/build/collect triple, top-levelwhere, relation-filter sub-wheres (some/every/none), alias wheres, nestedwithrelation order, cursor, and thefindUniquesimple (plain-equality) path. 14 regression tests warm the cache then assert each column binds its own value. Array members (OR/AND/NOT) remain positional.{ equals: value }works as plain equality on any column.where: { email: { equals: 'a@b.com' } }, documented in the README and the most common operator a migrating Prisma user reaches for, previously threwValidationErrorbecauseequalswas treated only as a JSONB filter key. It now compiles to"col" = $n(and{ equals: null }toIS NULL), parameterized, on the build path, the cache-hit path, and relation filters, while JSON/JSONB columns keep their existing containment behavior.- Nested-write input types are now real. The exported
NestedCreateOp/NestedUpdateOp/ConnectOrCreateOptypes were referenced by zero code,create({ data })was typed asPartial<T>so thecreate/connect/connectOrCreate/update/upsert/disconnect/deleterelation ops the runtime already supported were invisible to TypeScript.CreateArgs/UpdateArgsare now generic over the target's relations and surface the full nested-write op palette (matchingnested-write.tsexactly), recursively, via the sameRelationDescriptorbrand that powerswithinference. Untyped clients collapse to the oldPartial<T>, so nothing breaks; generated clients get typed nested writes with no generator changes.
Changed
- Middleware docs no longer teach a silently-broken soft-delete pattern. The README, the
$useJSDoc, and the site/queries+/observabilitypages showed middleware mutatingparams.args.whereto injectdeletedAt: null, which does nothing, because SQL is generated before middleware runs. Replaced with working patterns (query timing, result transformation) plus an explicit "params.argsis a read-only snapshot" warning, and the soft-delete recipe is now an explicitwherefilter / scoped helper. - New docs:
/studioand/observabilitypages. Studio (the flagship read-only-Studio differentiator) and the$observe()/$on('query')/npx turbine observeobservability surface were undocumented on the site; both now have full pages, in the sidebar and sitemap, with the security model spelled out. /queriesdocumentscursor,take, anddistinct; benchmarks page carries a measurement-vintage caveat; homepage repositioned around Postgres-native depth (pgvector, RLS, LISTEN/NOTIFY, full-text) with a side-by-side pgvector comparison, since single-query nested relations are now table stakes across ORMs. Bundle-size claims corrected to the measured ~31 kB (main) / ~22 kB (edge) brotli.- CLI help completed.
turbine observeand themigrate create --auto/--allow-drift/--stepflags are now in--help; column alignment fixed.
Errors
- Site
/errorspage now documents E015OptimisticLockErrorand E016ExclusionConstraintError(they shipped in 0.15.0 / 0.18.0 but were never added to the page), and thecheck-error-codesCI gate, which had silently failed since 0.15.0 because its known-classes list was missing both, passes again.
Tooling / CI
- DB-gated integration tests now report as skipped (via a
skipGate()helper) instead of silently passing 0 tests whenDATABASE_URLis unset. test:unitis now a glob instead of a hand-maintained file list (new test files can no longer be silently dropped from the release gate);test:watchwatches the full suite.npm auditis a hard gate on production deps (--audit-level=high); the YugabyteDB integration job moved from per-PR to a nightly cron (pinned image) to stop burning ~6 min of red CI per push; newpack-smokejob installs the built tarball and verifies ESM/CJS/CLI/serverless;release.ymlgained a post-publish smoke test and skips publishing a version that already exists on npm.- Added
SECURITY.mdsupported-versions refresh,.github/dependabot.yml, dead-code removal (isReadOnlyStatement, orphaned Studio CSS), and a CLAUDE.md architecture/LOC sync. Backfilled git tags for v0.11.0–v0.14.0 and v0.16.0 (published to npm without tags).
0.19.1 (2026-06-09)
Patch release fixing everything found by the v0.19.0 post-release audit, most importantly a broken new-user CLI happy path and two remaining silent-wrong-rows holes in the query builder.
Fixed
- CLI can load its own scaffolded
turbine.config.tsagain (HIGH).turbine initscaffolds TypeScript files, but every subsequent command failed on current Node: the loader calledmodule.register('tsx/esm', …), which tsx rejects with "tsx must be loaded with --import instead of --loader" on every Node version that hasmodule.register(), and the bare catch misreported it as "tsx is not installed", so following the suggested fix didn't help. The loader now uses tsx's supported programmatic API (tsx/esm/apiregister(), with amodule.registerfallback only for pre-4.0 tsx), and when registration genuinely fails the CLI reports the real underlying error (newfailedstatus) instead of misdiagnosing.turbine initalso notes the tsx requirement in Next Steps when tsx isn't installed. Verified end-to-end against the built CLI in a clean project. - The v0.19.0 unknown-operator guard no longer has a cache-hit bypass (HIGH). The guard ran only in
buildWhereClause(the cache-miss path); once an equality query warmed the SQL cache for a field set, a misspelled operator (e.g.startWith) flowed throughcollectWhereParamsunguarded and executedcol = $1with the operator object as the value, silently returning wrong rows, the exact bug 0.19.0 headlined as fixed. The guard now runs on both the build and param-collect paths (sharedassertBindableEqualityValue), and unmatched plain objects fingerprint distinctly from equality (key:obj(...)vskey:eq) so they can never share a cache entry. The same guard now also covers relation-filter sub-wheres (some/every/none). Regression tests warm the cache with equality before asserting the throw. - Nested relation
wherenow supports the full scalar filter surface (HIGH).with: { posts: { where: … } }was equality-only on the server: operator objects were bound as literal values (silent zero/wrong rows) andORproducedUnknown column "OR", while the Studio Query tab offered the full operator palette at every nesting level. Relationwhere(hasMany, belongsTo/hasOne, and manyToMany) now supports operator objects (gt/gte/lt/lte/not/in/notIn/contains/startsWith/endsWith+mode: 'insensitive'),null(IS NULL), andOR/AND/NOTcombinators, fully parameterized against the relation alias, mirrored on the cache-hit param-collect path, with shape-awarewith-fingerprinting (an equality where and an operator where can no longer share a cached SQL string). Misspelled operators in nested wheres throw the sameValidationErroras top-level ones. - The operator guard no longer rejects class-instance equality values.
where: { data: Buffer.from(…) }on abyteacolumn threwUnknown operators "0", "1"…(and Decimal-style wrappers on numeric columns likewise). Only plain object literals, the actual typo shape, throw now; Buffers, Dates, arrays, and other class instances bind as values, consistently on both cold and cached paths. limit: 0/orderBy: {}on a to-many relation no longer corrupt the query.limit: 0took the wrapped-subquery path but skipped the LIMIT clause (truthiness vs!== undefinedmismatch), silently dropping nested relations;orderBy: {}rendered a danglingORDER BY.limit: 0now renders a realLIMIT $n(empty array result) andorderBywith no defined entries is treated as absent, on both hasMany and manyToMany, build and collect paths.- Studio's Query tab now respects
--schema./api/builderran unqualified SQL resolved via the connection'ssearch_path, so with--schema acmethe Data tab readacme.userswhile the Query tab silently readpublic.users. The builder transaction now pinsset_config('search_path', $1, true)to the configured schema. npx turbine --versionprints the version again. Via thenode_modules/.bin/turbinesymlink the package-root walk started in the consumer's tree and never found turbine-orm's package.json; the path is nowrealpathSync-resolved first.turbine init's example schema is now valid. The scaffold usedtype: 'timestamptz', whichdefineSchemarejects (the valid name istimestamp, which maps to TIMESTAMPTZ).new TurbineClient(config)without schema metadata fails fast with an actionableValidationErrorpointing atturbine generate, instead of an opaqueTypeError: Cannot read properties of undefined (reading 'tables').
Changed
- README/docs caught up with the v0.19.0 Studio. The npm README still documented the removed raw-SQL tab, SQL saved queries, the dead SELECT/WITH-only parser, and the pre-0.17.0
SET LOCALtimeout form. README, site docs (/cli,/compatibility,/transactions), CLAUDE.md, and thestudio.tsheader now describe the ORM-native reality, the security headline is "no SQL input surface at all". Site version strings (homepage hero, docs sidebar) now come from a singlesite/lib/version.tsinstead of being hardcoded per page. - Legacy raw-SQL saved queries are dropped with a console notice when Studio loads
.turbine/studio-queries.json, instead of silently (the file isn't rewritten until a new query is saved, so the entries remain recoverable). npm run buildcleansdist/first. The 0.19.0 tarball shipped ~286 kB of stale compiled artifacts from the long-deletedsrc/query.ts.- Missing public type exports added:
WhereClause,WhereOperator,WhereValue,HavingClause, andMiddlewareFnare now importable from the package root (previouslyimport type { WhereClause } from 'turbine-orm'failed with TS2305 and there was no deep-import escape hatch).
Tests
- New suite
where-guard-cache-and-relation-where(28 tests: cache-warm bypass regressions, Buffer/class-instance equality, nested-relation operator/combinator SQL + param alignment + cache-hit parity,limit: 0/orderBy: {}edge cases).relation-limit-paramand the new suite added totest:unit(the former had been missing from the list, part of why the cache bypass shipped green). Full suite: 1251 passing, 1 skipped, 0 failures (incl. live-DB integration).
0.19.0 (2026-06-09)
Studio goes ORM-native, plus two correctness fixes to the query builder. Studio no longer has a raw-SQL surface, every query is composed visually in Turbine ORM and previewed as the exact findMany call you'd write. Two query-builder bugs are fixed, one of which silently returned wrong data.
Changed
- Studio is now ORM-native (
src/cli/studio.ts,src/cli/studio-ui.html). The raw-SQL tab, its/api/queryendpoint, and SQL-kind saved queries are gone. The default (and only) authoring surface is the visual Query composer, afindManybuilder that drills into relations (with) recursively to any depth, picking fields (select/omit), filters (where), andorderBy/limitat every level, with a live TypeScript preview to copy into your code. Tabs are now Query / Data / Schema.orderBy/limitcontrols are hidden for to-one relations (they always resolve to a single row). Saved queries are builder-only; legacy raw-SQL entries are dropped on load. Polish: system font stack (no external font fetch / CSP violation),204favicon,reltuplesrow estimates clamped to ≥ 0.
Fixed
- Unknown
whereoperators now throw instead of silently returning wrong rows. A misspelled operator (e.g.startWithforstartsWith, or any unrecognized key) previously fell through to plain equality,col = $1with the operator object as the value, quietly returning zero/wrong rows with no error. The WHERE builder now throwsValidationErrorfor any plain object on a non-JSON column that matches no known filter shape, naming the offending key(s) and listing supported operators. JSON/JSONB column equality is unaffected.orderByon an unknown field andselect/omitpassed as an array (instead of the{ field: true }object) now produce the same[turbine]+ "Known fields" error format. limiton a to-one relation no longer crashes the query. Awithclause on abelongsTo/hasOnerelation that carried alimit(e.g.with: { author: { limit: 10 } }) pushed the limit value as a parameter but rendered a literalLIMIT 1, leaving an orphaned, untyped$Nthat Postgres rejected withcould not determine data type of parameter $1(and shifting every later placeholder). To-one relations now ignorelimitentirely on both the SQL-build and param-collect paths;hasMany/manyToManyare unchanged.
Tests
- New suites:
relation-limit-param(parameter/placeholder alignment, including the to-one-limit regression) and expandedoperator-validation(misspelled/unknown operators, empty filter objects, orderBy + select/omit shape). Full suite: 1223 passing, 1 skipped, 0 failures (incl. live-DB integration).
0.18.0 (2026-06-08)
Feature release: aggregate filtering, a typed raw-SQL escape hatch, many-to-many + self-relations, pgvector similarity search, RLS session context, and LISTEN/NOTIFY realtime. This is also the first npm release to carry the 0.17.0 release-readiness fixes below (0.17.0 was tagged in the changelog but never published).
Added
groupByHAVING, filter aggregate groups with ahavingclause:groupBy({ by: ['userId'], _count: true, having: { _count: { gt: 1 } } }), or filter on a column aggregate viahaving: { viewCount: { _sum: { gte: 100 } } }. Supports_count/_sum/_avg/_min/_maxwith operatorsgt/gte/lt/lte/in/notIn(a bare number is shorthand for equality). HAVING params continue the WHERE param numbering; every value is bound as$N. Unknown columns or operators throwValidationErrorbefore any SQL is built.- Typed raw SQL,
db.sql<T>(src/typed-sql.ts), a typed escape hatch alongsidedb.raw:db.sql<{ id: number; name: string }>`SELECT id, name FROM users WHERE id = ${id}`returnsT[]when awaited,.one()returnsT | null, and.scalar<V>()returns the first column of the first row ornull. Every${value}is bound as a$Nparameter, injection payloads become data, never SQL. - Many-to-many through junction tables (
buildManyToManySubqueryinsrc/query/builder.ts),generateauto-detects pure junction tables (exactly two single-column FKs forming a two-column PK, no payload columns) and adds amanyToManyrelation to both endpoints, loadable viafindMany({ with: { tags: true } })with nestedwhere/orderBy/limit. Composite-key junctions supported. Junctions carrying payload columns stay ordinaryhasMany(by design). For non-pure or hand-built junctions, declare them in a code-first schema viamanyToMany: [{ name, target, through, sourceKey, targetKey, references? }](applyManyToManyRelations/ManyToManyDefinsrc/schema-builder.ts). - Self-relations, a self-referencing FK (e.g.
categories.parent_id → categories.id) introspects to abelongsTo+ ahasManyon the same table, queryable as nestedparent/childrentrees at arbitrary depth. EachbuildRelationSubquery()call allocates a fresh alias, so parent and child references never collide. A lone self-FK auto-names thebelongsTofor the singular table and thehasManyfor the table. - pgvector similarity search (
VectorFilter/VectorOrderByinsrc/query/types.ts), KNN ranking viaorderBy: { embedding: { distance: { to: number[], metric: 'cosine', direction?: 'asc' | 'desc' } } }, and distance WHERE filtering viawhere: { embedding: { distance: { to, metric: 'l2', lt: 0.3 } } }(lt/lte/gt/gte). Metricsl2/cosine/ipmap to<->/<=>/<#>. The query vector is bound as$n::vector. Non-number elements, NaN/Infinity, unknown metrics, and distance ops on non-vector columns all throwValidationError. Requires the pgvector extension + avectorcolumn. - RLS session context (
$transactionsessionContext+$withSessioninsrc/client.ts),db.$transaction(fn, { sessionContext: { 'app.current_tenant': id } })applies each entry asSELECT set_config(name, value, true)afterBEGIN, so Postgres RLS policies usingcurrent_setting()filter rows per transaction (the GUC auto-resets on commit). Values may be string/number/boolean.db.$withSession(ctx, fn)is the single-purpose shorthand. Invalid setting names throwValidationErrorand roll the transaction back before any query runs. - LISTEN/NOTIFY realtime (
src/realtime.ts,$listen/$notifyinsrc/client.ts),const sub = await db.$listen('channel', (payload) => { ... })subscribes on a dedicated connection (requires a persistent pool; not available over serverless HTTP drivers),await db.$notify('channel', 'msg')publishes in one round-trip (works everywhere), andawait sub.unsubscribe()issuesUNLISTEN. Channel names are validated as plain identifiers; the payload is bound as a parameter and delivered to the handler as a string.disconnect()force-releases open subscriptions.
Tests
- Full suite: 1208 passing, 1 skipped (pgvector live assertions, extension-gated, skipped, not failed, when the
vectorextension is unavailable), 0 failures. - New suites:
group-by-having,typed-sql,many-to-many,self-relation,pgvector,rls-session,realtime. Each pairs build-only SQL/parameterization assertions (no DB) withDATABASE_URL-gated integration coverage against bootstrapped, isolated tables.
Docs
- README: added typed-SQL (
db.sql<T>), groupBy HAVING, RLS session-context, and LISTEN/NOTIFY subsections under Usage Examples; a new "Vector search (pgvector)" section; many-to-many and self-relation examples in the relations content; and Many-to-many / Vector search / LISTEN/NOTIFY rows in the Comparison table.
0.17.0 (2026-06-06)
Release-readiness pass: two correctness fixes, the PII-safe error guarantee made real, honest footprint numbers, and a size gate that measures something.
Fixed
- Studio is no longer broken on plain PostgreSQL (CRITICAL). Every Studio data/query request issued
SET LOCAL statement_timeout = $1, which Postgres rejects (SETdoes not accept bind parameters), so each query 500'd withsyntax error at or near "$1". The CockroachDB/YugabyteDB adapters had the same flaw. All now useSELECT set_config('statement_timeout', $1, true), the parameterizable transaction-local form. The unit test mocked the pool and never sent the SQL to a real server, so the bug shipped green; a new integration test (studio-timeout-integration) runs the real SQL against a live connection. UniqueConstraintErrorand other constraint errors no longer leak row values (CRITICAL). The error.messageunconditionally appended Postgres's rawdetailstring (e.g.Key (email)=(alice@x.com) already exists.), contradicting the documented "PII-safe, never the actual user data, safe to log" guarantee. The rawdetailis now only appended inverbosemode; in the defaultsafemode the message carries column/constraint/key names only. Structured fields (.columns,.column,.constraint) and.causestill expose full detail for programmatic use. Applies to E008/E009/E010/E011/E016.- belongsTo nested writes with a NOT NULL foreign key now work (HIGH).
posts.create({ data: { …, user: { connect/create/connectOrCreate } } })previously inserted the parent row before setting the FK (via a follow-up UPDATE), failing the NOT NULLuser_idconstraint on the initial INSERT. belongsTo relations are now resolved before the parent INSERT and their FK folded into it. The hasMany direction is unchanged. New integration coverage exercises all three belongsTo ops.
Changed
- Honest footprint claims. The README/package description previously headlined "~110 KB" and contrasted it with "Prisma's 1.6 MB WASM", but that compared a minified bundle against an unpacked install (Turbine's own unpacked install is ~1.7 MB). Claims are now led by the true, durable differentiator, one dependency, no WASM engine, with correctly-labeled bundle figures (~27 KB brotli main entry, ~19 KB edge).
size-limitnow measures the real bundled import graph (@size-limit/esbuild) instead of the 2.6 KB barrel file, so the gate guards an actual number. Limits: 35 kB main, 25 kB edge.exportsmap now liststypesfirst in each conditional export so TypeScript resolves declarations correctly under all module-resolution modes.
Added
@types/pgis now a runtime dependency, Turbine's public.d.tsre-exportspgtypes, so strict consumers (skipLibCheck: false) no longer hitTS7016.
Tests
- Full suite: 1127 passing against a live database (0 failures). New regression tests: belongsTo nested-write integration (3), studio statement-timeout integration (3), PII-safe constraint-error messages (4).
test:coveragenow runs the full unit set (it had drifted to a stale ~20-file subset that leftnested-write.ts/observe.tsnear-uncovered, failing the gate at 69.6% functions). Coverage now passes: lines 78%, functions 83%, branches 86%.
Docs
SECURITY.mdsupported-versions table updated (was stuck at 0.5.x/0.6.x). README error-code list extended to E016. README full-text-search note corrected (thesearchfilter shipped in 0.15).CONTRIBUTING.mdarchitecture tree updated to thesrc/query/submodule split.CLAUDE.mdcoverage thresholds and seed-dataset sizes corrected. Benchmark results dated and version-caveated (measured on 0.7.1; core read path unchanged).
0.16.0 (2026-05-18)
Feature release: observability, nested write update/upsert, is/isNot relation filters, cursor pagination tests, Neon guide.
Added
- Event emitter,
db.$on('query', fn)anddb.$off('query', fn)fire after every query with SQL text, params, duration, model, action, and row count. Param redaction in safe mode. Listener errors never crash queries. - Observability module,
db.$observe({ connectionString })buffers per-minute aggregated metrics (count, avg, p50, p95, p99, errors) and flushes to a dedicated_turbine_metricstable in a separate database. Non-blocking (fire-and-forget), 1-connection pool, configurable retention. Auto-starts fromTURBINE_OBSERVE_URLenv var. turbine observeCLI, local read-only dashboard for viewing query metrics. Same security model as Studio (loopback binding, 192-bit token, HttpOnly cookies, CSP, X-Frame-Options: DENY). Dark-theme SVG charts, top models table, error rates, time range selector.- Nested write
update,{ posts: { update: { where: { id: 1 }, data: { title: 'new' } } } }inupdate()context. Array form supported. BelongsTo derives where from parent FK automatically. - Nested write
upsert,{ posts: { upsert: { where: { id: 1 }, create: {...}, update: {...} } } }. Checks existence, creates with FK injection or updates. is/isNotrelation filters, for to-one relations (belongsTo/hasOne):where: { author: { is: { name: 'Alice' } } }. Generates EXISTS/NOT EXISTS subqueries.- Neon guide,
/neonpage on turbineorm.dev: "Turbine + Neon in 60 Seconds" covering install, generate, Node.js + serverless connections, migrations, and why Turbine on Neon.
Fixed
- All Biome lint violations resolved,
npm run lintnow exits clean.
Tests
- 812 unit tests (up from 711), all passing.
- Added test suites:
event-emitter(10),observe(12),nested-write-update-upsert(15),is-isNot-filter(6),cursor-pagination(7),client-branches(17).
0.15.0 (2026-05-17)
Feature release: select/omit type narrowing, optimistic locking, full-text search, retry utility, security hardening.
Added
- Select/omit compile-time type narrowing,
findMany,findUnique,findFirstacceptselectandomitargs that narrow the return type at compile time viaQueryResult<T, R, W, S, O>. Preserveswithrelation additions while narrowing base entity fields. - Optimistic locking,
update({ optimisticLock: { field: 'version', expected: 3 } })auto-increments the version field and throwsOptimisticLockError(E015) on concurrent modification. - Full-text search,
TextSearchFiltertype withsearch,config, andlanguageoptions. Generatesto_tsvector @@ plainto_tsquerySQL with injection protection. - Retry utility,
withRetry(fn, opts)anddb.$retry(fn, opts)with exponential backoff + jitter. Only retries errors markedisRetryable(deadlocks, serialization failures). ExclusionConstraintError(E016), maps pg error code 23P01 viawrapPgError().- SQL safety property tests, 22 injection payloads verified against WHERE, UPDATE SET, and CREATE SQL generation.
- Migrate from Drizzle documentation page at
/migrate-from-drizzle.
Security
- Studio: Added
Content-Security-Policyheader, rate limiting (100 req/60s per token), ESCAPE clause on ILIKE queries, 10KB query length limit. - Adapters:
statementTimeout()now returns parameterized{ sql, params }instead of interpolated strings.
Changed
- Release workflow supports
workflow_dispatchwith dry-run mode and auto-tag creation. - Git tags synced through v0.10.0.
Tests
- 711 unit tests (up from 686), all passing.
- Added test suites:
optimistic-lock,retry,text-search,sql-safety-property.
0.14.0 (2026-05-10)
Dialect-owned type metadata for future database packages.
Added
- Added optional
Dialect.typeToTypeScript()and a PostgreSQL-backed implementation so PostgreSQL introspection can route generated TypeScript types through the dialect contract without source-breaking query-only dialect implementers. - Added optional
Dialect.arrayType()and wired PostgreSQL introspection/query fallback bulk-insert casts through the dialect contract. - Added dialect-neutral
dialectType,arrayType, anddialectTypesschema metadata aliases while preserving the existingpgType,pgArrayType, andpgTypesfields for compatibility.
Tests
- Added dialect contract coverage for type mapping and metadata serialization.
0.13.3 (2026-05-10)
Final integration fixture-size assertion patch.
Fixed
- Updated the remaining stream-ordering integration assertion to respect the documented 8-user seed fixture while still verifying ordering and limit behavior.
Tests
- Targets the single remaining failure from GitHub CI run
25619796677(findManyStreamordering).
0.13.2 (2026-05-10)
Integration-suite stabilization for the v0.13 patch recovery.
Fixed
- Prevented unknown
withrelations from sharing the no-relation SQL cache key before relation validation runs. - Updated legacy integration expectations for safe not-found messages and seeded fixture sizes.
Tests
- GitHub CI run
25619387838passed build, typecheck, lint, coverage, unit, error-code, and security jobs; this patch targets the remaining integration-only failures.
0.13.1 (2026-05-10)
CI hardening patch for the v0.13 dialect-hook release.
Fixed
- Generated the Studio UI fixture before typecheck and test scripts so clean CI checkouts can resolve
studio-ui.generated.js. - Changed an internal Postgres bulk-insert dialect guard to throw
ValidationError, preserving Turbine error-code enforcement. - Corrected integration cursor pagination expectations for the seeded 8-user fixture.
- Fixed the query cache fingerprint for
isEmpty: truevsisEmpty: falsearray filters and now emitscardinality(...)checks that exclude empty arrays from non-empty queries. - Updated the CI package-size gate to parse npm output robustly and match the current published tarball size budget.
Tests
- CI failure triage covered typecheck, build, unit, coverage, error-code, and integration logs from GitHub Actions run
25619209599.
0.13.0 (2026-05-10)
DDL and migration dialect hooks for future MySQL/SQLite packages.
Added
- Extended the
Dialectcontract with schema DDL builders for column types, column definitions, table creation, primary keys, and indexes. - Added migration tracking SQL builders so dialect packages can own
_turbine_migrationsDDL and applied-migration record queries. schemaToSQL()/schemaToSQLString()now accept an optional dialect for build-time DDL generation while preserving PostgreSQL as the default.- Added MySQL-style build-only regression coverage for backtick DDL,
BIGINT AUTO_INCREMENT,DATETIME,JSON, FK indexes, and MySQL-shaped migration tracking SQL.
Changed
- The root
turbine-ormpackage remains PostgreSQL-only at runtime. This release removes another dialect-package blocker; it does not ship MySQL/SQLite drivers, introspection, or migration execution. - Exported schema DDL dialect input types and
SchemaSqlOptionsfor future dialect packages.
Tests
- 681 unit tests, 0 failures.
- Lint, typecheck, and build clean.
0.12.0 (2026-05-10)
DML dialect hooks for future MySQL/SQLite packages.
Added
- Extended the
Dialectcontract with DML SQL builders forINSERT, bulk insert, upsert, andRETURNINGclauses. - PostgreSQL's default dialect now owns the existing
RETURNING *,UNNEST(...), andON CONFLICTgeneration instead of hardcoding those primitives inQueryInterface. - Bulk insert dialect builders now return both SQL and params so non-Postgres dialects can use row-major
VALUESparams while PostgreSQL keeps column-arrayUNNESTparams. - Added MySQL-style build-only regression coverage for DML output: backtick identifiers,
?placeholders,VALUES (?, ?), (?, ?),ON DUPLICATE KEY UPDATE, and no Postgres-onlyRETURNING/UNNEST/ON CONFLICT.
Changed
- No public Postgres import or runtime behavior changes. This is still foundation work for dialect packages, not MySQL/SQLite GA support.
- Exported DML dialect input/result types for future dialect packages.
Tests
- 679 unit tests, 0 failures.
- Lint, typecheck, and build clean.
0.11.0 (2026-05-10)
Dialect interface foundation for MySQL/SQLite expansion.
Added
- Dialect contract (
src/dialect.ts) with PostgreSQL implementation and public exports for future@turbine-orm/mysql/@turbine-orm/sqlitepackages. - Query builder dialect seam: identifiers, placeholders, nested relation JSON aggregation, case-insensitive LIKE, JSON contains/path operations, and relation correlation now route through the active dialect while preserving PostgreSQL output by default.
- Internal
TurbineConfig.dialectoption so dialect packages can inject their SQL primitive implementation without forking the public client shape. - Dialect regression tests proving a MySQL-style dialect can emit backtick identifiers,
?placeholders,JSON_OBJECT,JSON_ARRAYAGG,JSON_CONTAINS, and non-ILIKEinsensitive search.
Changed
- PostgreSQL remains the default and
turbine-ormimports are unchanged. This release is a compatibility-preserving foundation step, not a MySQL/SQLite GA release. - Test scripts now include the dialect contract suite.
Tests
- 678 unit tests, 0 failures.
- Lint, typecheck, and build clean.
0.10.0 (2026-05-09)
Database adapters, composite FK support, marketing rewrite, full hardening pass.
Added
- Database adapter system (
src/adapters/): pluggableDatabaseAdapterinterface for PG-compatible databases. Shipscockroachdb(table-based locking, introspection overrides, transaction_timeout syntax),yugabytedb(distributed table locks), and no-opalloydb/timescaleadapters. New./adapterssubpath export. - Composite foreign key support:
introspect.tsnow groups FK rows by constraint name.RelationDef.foreignKey/referenceKeyacceptstring | string[]. NewbuildCorrelation()utility generates AND-joined equality clauses. Code generation emits array literals for composite FKs. - Compatibility docs page (
/compatibility): CockroachDB, YugabyteDB, AlloyDB, Timescale connection guides and feature matrices. - Multi-DB architecture plan (
docs/MULTI_DB_PLAN.md): design doc for future MySQL/SQLite/SQL Server as separate@turbine-orm/*packages. - Dynamic OG + Twitter images: Next.js
ImageResponseroutes for social sharing previews. - JSON-LD structured data on landing page (SoftwareApplication schema).
- Per-page canonical URLs and Twitter card metadata across all docs.
upsertandgroupBydocumentation in API reference.- 56 new tests for database adapters (CockroachDB, YugabyteDB, pg-compat).
- 64 new tests for coverage gaps:
client-coverage.test.ts(middleware, timeouts, external pools, SAVEPOINTs) andschema-diff.test.ts(all schemaDiff patterns). - 10 new tests for relation filter field validation.
- 14 new tests for composite FK correlation and introspection.
Fixed
- Validation gap in
buildSubWhereForRelation(medium-severity security finding): now validates column existence against target table metadata, throwsValidationErrorwith field name and available columns. - Multi-column FK introspection bug: composite FKs no longer split into separate single-column relations.
- Dead
/roadmaplink in Prisma migration page removed. - Pipeline API inconsistency across docs unified to
db.pipeline(...). - Sidebar accessibility: Escape key closes mobile menu, aria-expanded + aria-labels added.
- Removed 7 unused imports caught during lint cleanup.
Changed
- Landing page messaging rewritten: leads with "110 KB. One dep." and actual differentiators (Studio, PII-safe errors, migrations). Feature section titled "What Prisma and Drizzle don't ship."
- README top section reframed around real moat; json_agg moved to "How it works" supporting section.
- Comparison tables expanded (9 rows, install size + Studio first).
migrate.tsandstudio.tsnow accept optionalDatabaseAdapterfor pluggable locking and timeout strategies. Fully backwards-compatible.- Hero badge changed from
v0.9topre-1.0. - Cleaned 6 vestigial empty route directories from
site/app/.
Tests
- 674 unit tests, 0 failures (up from 530 in v0.9.2).
- Lint: 0 warnings, 0 errors (72 warnings suppressed with targeted
biome-ignorecomments including justification). - TypeScript: strict check clean.
0.9.2 (2026-04-14)
Docs + positioning patch. Sharpens the landing-page/README pitch around the
real differentiators (one runtime dep, Studio, code-first + DB-first in one
CLI, first-class edge runtimes) and demotes json_agg as a headline feature
since Drizzle and Prisma 7 both use it. Ships four new site doc pages, a
contributor seed script, and two new internal design docs (dialect roadmap +
full DX reference).
Docs
- New site pages:
/relations(deep-dive onwith, nested options, relation filters, payload-size warnings with concrete numbers),/transactions($transactioncallback form, isolation, timeouts, nested SAVEPOINTs, retry loops forDeadlockError/SerializationFailureError,pipeline()semantics),/serverless(Neon / Vercel Postgres / Cloudflare Hyperdrive / Supabase walkthroughs,PgCompatPoolcontract, edge memory budget table),/migrate-from-prisma(promoted fromdocs/with an 8-step checklist and schema translation example). - Landing hero + README rewritten to lead with "one runtime dependency,"
built-in Studio, code-first + DB-first in the same CLI, and edge runtime
support.
json_aggmoved to the last feature rather than the first. - Sidebar + sitemap updated to include the four new pages.
Internal docs
docs/NEXT-INTEGRATIONS.md, post-v1.0 dialect roadmap. Tier 1: CockroachDB (~1 engineer-week, PG-wire already compatible) and MySQL (4–6 weeks asturbine-orm/mysqlsubpath). Tier 2: SQLite (3 weeks; also wins internal CI speed). Tier 3 skip: SQL Server (engineering cost too high, audience already captured by TypeORM), MongoDB (philosophical mismatch). Tier 4 declined: PowDB (custom binary wire protocol, no SQL, nojson_agg, noinformation_schema, revisit when PG-wire compat layer ships).docs/USING-TURBINE-ORM.md, 19-section full DX reference covering schema / client / reads / where / with / writes / transactions / pipeline / streaming / raw SQL / errors / CLI / migrations / Studio / testing / deployment / intentional non-features. Each section includes port notes for building a similar TypeScript client against another database.
Contributor DX
scripts/seed-test-db.sh+scripts/docker-compose.yml, one-command Postgres seed for the integration-test database (throwaway Docker Compose on port 54329, benchmark seeder on first run).CONTRIBUTING.mdupdated with the new path..c8rc.json, scope comment added (whycli/,generate.ts,introspect.ts,serverless.ts,index.tsare excluded) and thresholds raised: lines 57→65, functions 64→70, statements 57→65, branches 80→82.
No runtime changes. 530/530 unit tests pass.
0.9.1 (2026-04-10)
Docs + tests patch. Restores accurate messaging around deep with-clause
type inference (shipped since 0.7.1) and locks in the end-to-end inference
path with compile-time assertions. No runtime changes, WithResult,
RelationDescriptor, and the generator's branded *Relations output were
already in place and correct; this release just stops claiming otherwise in
the README/landing page and adds a regression guard.
Tests
- End-to-end compile-time assertions for deep
withinference through real call sites (src/test/with-inference.test.ts). The existing tests only verifiedWithResultin isolation; the new sections 8 and 9 exercisefindMany/findUnique/findFirst/findUniqueOrThrowat 1/2/3 nested levels via both explicit type arguments and plain call-site literal inference (users.findMany({ with: { posts: { with: { comments: ... } } } })). If inference regresses at the user-facing signature,tsx --testnow exits non-zero because the test file fails to typecheck. 530/530 unit tests pass.
Docs
- README + site/app/page.mdx: removed the "deep
withtype inference lands in v1.0" caveat that slipped into 0.9.0 and replaced it with an accurate description of the shipped feature. Deep inference has been working end-to-end since 0.7.1 via the recursiveWithResultmapped type and the generator'sRelationDescriptor-branded*Relationsinterfaces - the 0.9.0 README was wrong, not the runtime. - CLAUDE.md "Type System" section corrected: removed the stale "Current
limitation:
withclause return types do not reflect included relations at the type level" paragraph (a pre-0.7.1 artifact) and replaced it with an accurate architecture note coveringTypedWithClause,WithResult,RelationDescriptor, andApplyCardinality.
0.9.0 (2026-04-09)
Studio Premium. Turbine now ships a premium, read-only Studio web UI, the
only Postgres ORM with a Studio your DBA will approve. Loopback-bound by
default, random per-process auth token, every query runs inside
BEGIN READ ONLY + SET LOCAL statement_timeout = '30s', and a strict
SELECT/WITH parser blocks statement stacking. No mutations, no writes, no way
around the transaction guard, the posture is unchanged from 0.8.0 and a
product review (8.1/10) found zero CRITICAL or HIGH vulnerabilities.
Added
- Premium Studio UI with Data / Schema / SQL / Builder tabs, a
single-file embedded HTML/CSS/JS bundle served by the CLI
turbine studiocommand, matching the turbineorm.dev dark theme. - Cmd+K command palette for fast navigation across tables, tabs, and saved queries.
- Saved queries, named SQL snippets persisted to
.turbine/studio-queries.jsonand surfaced in the SQL tab and command palette. - Visual query composer (Builder tab) with live TypeScript preview -
pick a table, compose
where/orderBy/with/limitvisually, and watch the matchingdb.table.findMany(...)code render in real time. - Full-text search across table rows, the Data tab now supports
substring search across every text column via a
searchquery parameter. - Sortable tables, JSON modal, toasts, keyboard shortcuts, every data table is column-sortable; JSON/JSONB cells open in a full-screen modal; toast notifications confirm saves/errors; keyboard shortcuts for tab switching, row navigation, and query execution.
- Four new backend endpoints:
/api/builder(preview SQL for a visual composer payload),/api/saved-queries(GET/POST/DELETE), and asearchquery parameter on/api/tables/:name. - CLI flag smoke test (
src/test/cli-flags.test.ts), locks in everyturbine studioflag (--port,--host,--no-open) against the argument parser.
Changed
- Migration advisory lock ID is now derived from the database name via
FNV-1a, fixes cluster-wide contention when two databases on the same
Postgres cluster both run
turbine migrate upconcurrently. The previous implementation used a static lock ID, so a migration in database A would block a migration in database B. Lock ID is now a stable per-database 32-bit FNV-1a hash ofcurrent_database()(top bit cleared for positiveint4, matching Postgres advisory-lock semantics). - README Studio section rewritten to headline read-only as a design feature, not a limitation. The old "Turbine Studio is planned but not yet available" bullet under Limitations has been removed.
Fixed
- Two
anyleaks insrc/schema-builder.tspublic signatures, the column definition builder now exposes precise generic types end-to-end. - Search endpoint parameter-index bug in
/api/tables/:name, the search clause was using a stale$Nindex when combined with pagination params. - Biome template-literal lint errors in
src/query.tssurfaced by the new biome rules shipped in 2.4.10.
Security
- Studio posture unchanged from v0.8.0. Loopback default (
127.0.0.1, loud warning on non-loopback binds), 24-byte random hex token generated per process,SameSite=StrictHttpOnlyauth cookies, every query wrapped inBEGIN READ ONLY+SET LOCAL statement_timeout = '30s', SELECT/WITH-only parser that strips comments and rejects non-trailing semicolons (blocks statement stacking), and security headers (X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy: no-referrer). Product review scored the surface 8.1/10 and found zero CRITICAL or HIGH vulnerabilities.
0.8.0 (2026-04-09)
Added
- Real Postgres extended-query pipeline protocol.
pipeline()now uses the wire-level pipeline protocol (parse/bind/describe/execute/sync in a single TCP flush) on connections that support it, viapipeline-submittable.ts. Falls back to sequential execution for HTTP drivers, mocks, and other non-TCP connections. Verified 2.58× speedup over sequential on Neon. - SQL template caching with shape-keyed fingerprinting. Queries with the
same WHERE/WITH/ORDER BY structure (same keys and operators, different values)
reuse cached SQL text. FNV-1a 64-bit hashing generates deterministic prepared
statement names. LRU cache at 1,000 entries. Cache hit/miss stats via
queryInterface.cacheStats(). - Prepared statement support. When
preparedStatements: true(default for owned pools), queries use pg's{ name, text, values }object form. Postgres caches the execution plan after the first call. Disable per-client or viaTURBINE_DISABLE_PREPARED=1env var. Automatically disabled for external pools (serverless drivers). - Streaming speculative first fetch.
findManyStreamnow issues aLIMIT batchSize+1first query. If the result fits in one batch, rows are yielded directly withoutDECLARE CURSORoverhead. Only large result sets escalate to server-side cursors. - Default
batchSizeincreased from 100 to 1000. Reduces FETCH round-trips from 500 to 50 for a 50K-row drain, closing the streaming performance gap. parseNestedRowshort-circuit. Empty hasMany ('[]'), null belongsTo ('null',null), and pre-parsed arrays skipJSON.parseentirely.PipelineError(TURBINE_E014) with per-query result status array (.results),.failedIndex, and.failedTagfor diagnosing partial pipeline failures in non-transactional mode.PipelineOptionstype withtransactional(default true) andtimeoutfields. Non-transactional mode uses per-query Sync for error isolation.pipelineSupported(pool)public probe, check at runtime whether a pool supports the real pipeline protocol.TurbineConfigflags:preparedStatements(boolean),sqlCache(boolean).- New benchmark scenarios: pipeline (5-query dashboard batch) and hot findUnique (500× same shape, rotating IDs).
- 88 new unit tests (pipeline-submittable: 12, sql-cache: 54, stream-and-parse: 19, pipeline integration: 3). 486 tests total.
Changed
- Benchmark results updated. With SQL caching, prepared statements, and
streaming optimizations, Turbine now wins or ties 6/8 scenarios on Neon.
L2 nested reads: 1.59× faster than Drizzle. Streaming 50K rows: at parity
with Prisma (~3.2 s), 1.49× faster than Drizzle. See
benchmarks/RESULTS.md.
Docs
- Benchmarks reconciled against a real pooled database. The
README benchmark table and the "Turbine is fastest in every
scenario" framing dated from a local Postgres run; a full three-way
head-to-head against Prisma 7.6 (with
relationJoins) and Drizzle 0.45 on Neon (US-East, pooled, PostgreSQL 17.8) shows all three ORMs land within ~5 ms of each other on every read scenario because network latency dominates. Turbine'sfindManyStreamis actually ~1.5× slower than keyset pagination for drain-all workloads because ofBEGIN/DECLARE/CLOSE/COMMIToverhead. README, strategic plan, and thestreaming-csvexample have all been rewritten to pitch Turbine on architectural merits (one dep, edge import swap, typed errors,withinference) rather than speed. Full writeup:benchmarks/RESULTS.md. - Added
benchmarks/seed-neon.tsso the benchmark harness is fully reproducible against any Postgres endpoint (Neon, Vercel, local). benchmarks/bench.tsgained two new scenarios: streaming (drain 50K rows three ways) and atomic counter (view_count + 1).
0.7.1 (2026-04-07)
This release is a hardening + DX pass on top of 0.7.0. CLI now reliably loads
TypeScript schema files, error messages are safer by default, two new typed
errors cover transient Postgres failures, composite primary keys are first
class, and the with clause is fully type-inferred at any nesting depth.
Added
DeadlockError(TURBINE_E012) andSerializationFailureError(TURBINE_E013), both exposeisRetryable: truefor safe automatic retry on Postgres40P01and40001sqlstates. Surfaced throughwrapPgError()at every query chokepoint.- Composite primary keys:
defineSchema()now accepts a table-levelprimaryKey: ['col1', 'col2']field. The DDL generator emits aCONSTRAINT ... PRIMARY KEY (col1, col2)and the typedfindUniqueaccepts the composite key as an object. - Deep
with-clause type inference: theWithResultmapped type now recurses through arbitrarily nestedwithclauses, sodb.users.findMany({ with: { posts: { with: { comments: true } } } })narrows the return type toUser & { posts: (Post & { comments: Comment[] })[] }without manual assertions. - Typed
TransactionClienttable accessors: thetxargument inside$transaction(async (tx) => ...)now exposes the sametx.users/tx.poststyped accessors as the top-level client. Generated clients emit a typedTransactionClientsubclass alongside the mainTurbineClientsubclass. - Atomic update operator types in generated
*Updateinterfaces: numeric fields now allow{ increment | decrement | multiply | divide | set: number }at the type level, matching the runtime behaviour shipped in 0.6.2. findManyunlimited-query warning:findManycalls without alimit(and nodefaultLimitconfigured) now emit a one-time warning per table. Disable withwarnOnUnlimited: falseinTurbineConfig.- Strict operator validation: JSONB-only operators (
hasKey,path) and array-only operators (has,hasEvery,hasSome) now throwValidationErrorwhen applied to columns of the wrong Postgres type, instead of silently generating broken SQL. $transactiontimeout cleanup: when a transaction exceeds itstimeout, Turbine now destroys the underlying connection rather than returning it to the pool, freeing the slot immediately.- WHERE Operator Reference in README, every operator (equality, sets, comparison, string, relation, array, combinators) with a one-line description and example.
- Prisma migration guide at
docs/migrate-from-prisma.md, API mapping table, side-by-sidefindManyexample, and notes on the differences (include->with, code-first schema, typed errors, edge support). - Four serverless example apps under
examples/:neon-edge(Neon on Vercel Edge),cloudflare-worker(Hyperdrive +pg),vercel-postgres(@vercel/postgreson the Next.js app router), andsupabase(directpgto Supabase). Each is self-contained withschema.ts, entrypoint,package.json, and a setup README.
Fixed
- CLI
turbine pushfailed on.tsschema files: the loader now registerstsx/tsmas needed before importing the schema module, sonpx turbine push --schema ./schema.tsworks without a manual loader flag. - README contradicted runtime on atomic update operators: the "no incremental updates" bullet under Limitations falsely claimed
{ count: { increment: 1 } }was unsupported. Atomic operators have shipped since 0.6.2, bullet removed and a worked example added under Usage Examples. NotFoundErrorno longer leakswherevalues into error messages by default. Messages are now[turbine] findUniqueOrThrow on "users" found no recordwith the originalwherestill attached on the error object for programmatic inspection. Opt back into the verbose form witherrorMessages: 'verbose'inTurbineConfigif you need the previous behaviour.
Docs
- README: corrected stale
70KBpackage size in the Next.js example to~110KB(matches the v0.6.3 fix). - Next.js example rewritten to use the generated typed accessor (
db.users.findMany) instead of the untypeddb.table<User>('users')lookup.
0.7.0 (2026-04-07)
This release is a quality + reach overhaul driven by a full product review and gold-standard OS audit. Biggest new capability: Turbine now runs on the edge via any pg-compatible driver (Neon, Vercel Postgres, Cloudflare Hyperdrive, etc.) without bundling a single extra dependency.
Added
- Serverless / edge support (
turbine-orm/serverless): newturbineHttp(pool, schema)factory andPgCompatPool/PgCompatPoolClient/PgCompatQueryResultinterfaces. Plug in@neondatabase/serverless,@vercel/postgres, or any other pg-API compatible pool and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, and similar environments. - New
TurbineConfig.pooloption, pass an external pg-compatible pool and Turbine will route all queries through it instead of creating its ownpg.Pool.disconnect()is a no-op for externally-owned pools. - New public subpath export:
turbine-orm/serverless(both ESM and CJS). with-clause type inference: optional second type parameterQueryInterface<T, R>surfaces included relations at the type level. Generated clients now emit{Entity}Relationsinterfaces, sodb.users.findMany({ with: { posts: true } })narrows the return type to includeposts: Post[].- New exports:
TypedWithClause,WithResult,PgCompatPool,PgCompatPoolClient,PgCompatQueryResult,turbineHttp,TurbineHttpOptions.
Changed
serverless.tsrewritten: the old custom HTTP-proxy protocol (which required a nonexistent Turbine proxy server) is gone. It is replaced with a thin, driver-agnostic factory that binds any pg-compatible pool to a schema. No new runtime dependencies.TurbineClient.statsnow returns zeros for pools that don't expose connection counts (HTTP drivers), instead ofundefined.pg.types.setTypeParser(20, ...)registration is now skipped when Turbine is given an external pool, prevents Turbine from mutating global state owned by the external driver.
Tests
- 308 unit tests passing (up from 254).
- New test file:
src/test/serverless.test.ts, 9 tests covering external pool integration, transaction routing, lifecycle ownership, and error propagation via a mockPgCompatPool. - New test file:
src/test/pipeline.test.ts, 5 tests coveringexecutePipeline: BEGIN/COMMIT wrapping, transform ordering, ROLLBACK on failure, parameter passing, and empty-input short-circuit.
Docs
- README: new "Serverless / Edge" section with Neon, Supabase, and Vercel Postgres examples.
src/serverless.ts: extensive JSDoc covering supported drivers, limitations over HTTP (streaming cursors, LISTEN/NOTIFY), and full usage examples for Neon on Vercel Edge and Cloudflare Workers.
0.6.3 (2026-04-07)
Security
- SSL/TLS support: Added
ssloption toTurbineConfigfor secure connections to cloud providers (RDS, Supabase, Neon, etc.) - Aggregate column aliases now quoted via
quoteIdent()inbuildGroupBy()andbuildAggregate(), prevents potential SQL syntax injection - Column validation added to
buildAggregate()matchingbuildGroupBy(), rejects unknown field names findManyStream()batch size coerced to safe positive integer
Changed
- README repositioned: Tagline and "Why Turbine?" section now lead with streaming, typed errors, pipeline, and middleware as primary differentiators. json_agg presented as shared approach rather than unique feature
- Removed stale "no WASM" claims about Prisma (Prisma 7 dropped Rust engine in Jan 2026)
- Package size claim corrected from "70KB" to "~110KB" (actual npm pack size)
pg.types.setTypeParser(20, ...)moved from module scope intoTurbineClientconstructor with once-guard, fixes incorrectsideEffects: falsein package.json
Tests
- 254 unit tests passing
- Shared test helpers extracted to
src/test/helpers.ts
0.6.2 (2026-04-06)
Added
- Typed constraint-violation errors:
UniqueConstraintError,ForeignKeyError,NotNullViolationError,CheckConstraintError, pg sqlstate codes (23505/23503/23502/23514) are translated automatically at every query chokepoint (CRUD, raw, transactions, pipelines, streaming) wrapPgError()helper translates pg driver errors into typed Turbine errors withcausechaining preserved for stack traces- Atomic update operators:
update/updateManynow support Prisma-style{ increment, decrement, multiply, divide, set }operators for race-free counter updatesawait db.posts.update({ where: { id: 5 }, data: { viewCount: { increment: 1 } } }) - New exported types
UpdateInput<T>andUpdateOperatorInput<V>with conditionalV extends numbernarrowing,incrementon a non-numeric column is a compile-time error - Operator detection uses strict single-key rule to avoid collisions with JSON column payloads
Changed
NotFoundErrornow carries query context:findFirstOrThrow,findUniqueOrThrow,update,delete,upsert, andcreatenow throwNotFoundErrorwith{table, where, operation}fields and Prisma-style messages:[turbine] findUniqueOrThrow on "users" found no record matching where: {"id":1}NotFoundErrorconstructor accepts either a string (back-compat) or{table?, where?, operation?, cause?, message?}options object- Removed dead
havingfield fromGroupByArgsinterface (was silently ignored)
Tests
- 514 integration tests + 239 unit tests, all passing
- 19 new tests for atomic update operators including 10-way concurrent atomicity proof
- 10 new NotFoundError unit tests covering back-compat, format, fields, override, cause chains
- New test file:
src/test/update-operators.test.ts
0.6.1 (2026-04-06)
Added
- Streaming cursors:
findManyStream()returnsAsyncGenerator<T>backed by PostgreSQLDECLARE CURSOR, constant memory for large result sets - Configurable
batchSize(default: 100) for internal FETCH batching - Streaming supports all
findManyoptions:where,orderBy,limit,with(nested relations) - Early termination via
breakautomatically cleans up cursor and connection - Next.js example app (
examples/nextjs/), server-rendered demo with nested relations, code blocks, and streaming showcase - Auto-diff migrations:
npx turbine migrate create <name> --autogenerates UP + DOWN SQL from schema diff - Schema diff now detects DEFAULT value changes (SET DEFAULT / DROP DEFAULT)
- Schema diff now detects UNIQUE constraint changes (ADD / DROP CONSTRAINT)
- Schema diff generates reverse SQL for all operations (for DOWN migrations)
- Type changes now include USING clause for safe casting
- Fresh benchmark suite against Prisma 7.6 and Drizzle 0.45 (
benchmarks/) - README benchmarks updated with current numbers, Turbine 1.4–1.9x faster across all scenarios
- Reproducible benchmark harness:
cd benchmarks && npm install && npx prisma generate && npx tsx bench.ts
Changed
schemaDiff()now returnsreverseStatementsalongsidestatementscreateMigration()accepts optionalautoContentfor pre-populated UP/DOWN- README benchmark section replaced with fresh Prisma 7 / Drizzle v2 results (was Prisma 5.x / Drizzle v1)
- Comparison section updated to reflect all three ORMs now using single-query approaches
0.6.0 (2026-04-05)
Security
- CRITICAL: Fixed shell injection in seed command, replaced
execSyncstring interpolation withexecFileSyncarray args - Migration tracking table name now quoted via
quoteIdent()at all SQL interpolation sites - DEFAULT value validation rejects strings containing semicolons and SQL statement keywords
- Connection string redaction (
redactUrl()) applied to all CLI error output paths
Added
- Column validation in
orderBy, throwsValidationErrorfor unknown column names - Column validation in
groupBy, throwsValidationErrorfor unknown column names - Runtime type validation in
defineSchema(), throws for invalid column types not in TYPE_MAP - JSON parse warning in nested relation parsing, warns instead of silently falling back
- Error handling section in README with typed error examples and error code reference
- 20 new unit tests (validation, DEFAULT edge cases, schema type checks), 171 total
Changed
- README messaging: "Prisma-inspired API" replaces "Prisma-compatible API"
- README tagline leads with Postgres-native positioning instead of speed claims
- Benchmark section now notes results are against Prisma 5.x / Drizzle v1 with context about modern versions
- Comparison table updated: Prisma now shown as "1 query (LATERAL JOIN + json_agg, since v5.8)"
- package.json description updated to factual positioning: "Postgres-native TypeScript ORM"
noExplicitAnylint rule changed from "off" to "warn" in biome.json- "Why Turbine?" section rewritten to lead with architectural simplicity, not speed claims
Fixed
- Stale "Prisma sends 3 separate queries" claim in README (Prisma 7+ uses single query)
- Stale "2-3x faster than Prisma" claim in package.json description
- Stale benchmark context in query.ts header comment
0.5.0 (2026-03-28)
Security
- DDL identifier quoting via
quoteIdent()on all CREATE/ALTER/DROP statements - DEFAULT value validation against strict allowlist
- Path traversal protection on
--outflag - Shell escaping for seed file paths
json_build_objectkey escaping
Added
- Full migration engine:
turbine migrate create/up/down/status- Advisory locking for concurrent migration safety
- Checksum validation for drift detection
- Per-migration transactions with rollback on failure
- LRU cache (1,000 entries) for SQL query templates
- Case-insensitive LIKE support (
mode: 'insensitive'on string filters) - Per-query timeout option
- Configurable
defaultLimitandwarnOnUnlimitedfor findMany - CJS output alongside ESM (dual publishing)
- Pre-computed column type lookups (O(1) instead of O(n))
- 89 unit tests (schema-builder + migrations)
Changed
- Node.js requirement lowered from >= 22 to >= 18
- Test runner changed from
node --experimental-strip-typestotsx numerictype now consistently maps tostring(removed runtime parser)- Middleware JSDoc clarifies that args are captured before middleware runs
Fixed
- Test suite was completely broken (import resolution mismatch)
numeric/biginttype mismatch between generated types and runtimeprocess.exit(0)in integration tests killed parallel test runner- CLI
showVersion()was hardcoded to v0.3.0 - Test files leaked into npm package via
dist/cjs/test/
0.4.0 (2026-03-26)
Added
findFirst,findFirstOrThrow,findUniqueOrThrowquery methods- Middleware system (
db.$use()) for query interception
0.3.0 (2026-03-25)
Added
- Initial public release
- Schema introspection from
information_schema+pg_catalog - Type generation (entity interfaces, create/update types, relation types)
- Query builder with
json_aggnested relations (L2-L4 depth) - 18+ WHERE operators (gt, gte, lt, lte, in, notIn, contains, startsWith, endsWith, OR, AND, NOT)
- JSONB operators (contains, equals, path, hasKey)
- Array operators (has, hasEvery, hasSome, isEmpty)
- Transactions with nested SAVEPOINTs and isolation levels
- Pipeline batching
- Raw SQL tagged templates
- Schema builder (
defineSchema()) with TypeScript objects - CLI: init, generate, push, migrate, seed, status
Rendered from the repository's CHANGELOG.md at build time (currently through v0.92.0).