Changelog
Release-by-release behavior changes for nestjs-drizzle-crud. Mirrors CHANGELOG.md shipped in the npm tarball.
This page mirrors the CHANGELOG.md shipped in the npm tarball. Feature pages describe current behavior; this page is the historical record. If something here disagrees with a feature page, the feature page wins (and the changelog is the bug).
[3.3.1] - 2026-11-02
Fixes from the November 2026 audit. Every fix below is proven by a spec that runs real SQL against PGlite (test/pglite.integration.spec.ts) and failed before the change.
Fixed
- String filters on integer, uuid and enum columns no longer return a 500. Query strings send every value as text, and with the default
caseSensitive: falsea filter such as{ author_id: '1' }becamelower(author_id), which Postgres rejects.lower()now only wraps text, varchar and char columns; every other column compares with=and Postgres casts the value. Relation and collection filters get the same fix. - Date strings work on date and timestamp columns.
{ publishedAt: { gt: '2024-01-01' } }used to crash inside the driver (value.toISOString is not a function). The string is now turned into aDate; a value that is not a date is a 400. - A soft-deleted belongs-to parent is no longer returned. The
LEFT JOINfor an eager load or a relation filter now carriesdeleted_at IS NULLin itsONclause, so the parent comes back asnulland{ author: { name: 'Bob' } }does not match a deleted author. findOnecan no longer hand back an arbitrary row.findOne({ email: undefined })used to runLIMIT 1with no condition and return the first row in the table. It is now a 400, as is a key that is not a column.nullnow meansIS NULLinstead of being skipped. An operator object works as it does infindAll.- A soft-delete or timestamp column the table does not have is caught at startup. With camelCase keys (
deletedAt: timestamp('deleted_at')) the default config used to break every read with a SQL syntax error, andupdatedAtsilently never moved. The service now throws when it is built, naming the key to use:softDelete.column is "deleted_at", but the table has no column under that key. The column "deleted_at" is declared under the key "deletedAt"; set softDelete.column to "deletedAt". - One bad item in a bulk call no longer fails every item after it. Each item now runs in its own savepoint, so
massCreate([ok, duplicate, ok, ok])reports the duplicate alone instead of two more25P02"current transaction is aborted" errors. The whole call still rolls back. The items still run one query (or two) at a time; set-based bulk writes are planned separately. - Bulk errors reach the HTTP response.
BulkOperationExceptionnow answers with{ statusCode, error, message, errors: [{ index | id, status, message }] }, one entry per failed item with the status that item alone would have produced. An error that is not an HTTP exception shows asstatus: 500with a generic message, so SQL never leaks. { isNull: false }meansIS NOT NULL. The value used to be ignored.isNullandisNotNullalso accept the strings"true"and"false"; anything else is a 400.- Filters the package does not understand are rejected instead of dropped. A new
eqoperator is supported, aDatevalue is an equality match instead of an empty operator object, and an unknown column, relation column or operator is a 400. Each of these used to return every row. - Bad input is a 400 on reads too. Postgres
22P02(wrong type, unknown enum label),22007and22008(bad dates) now map toValidationFailedExceptionon every path, includingfind,findAll,countandfullTextSearch.fullTextSearchchecks its column names (an unknown one used to be a SQL syntax error), andupdatewith nothing to set is a 400 instead of a raw "No values to set". fullTextSearchalways paginates. Without a pagination argument it returned every match while the envelope saidlimit: 20. The resolved page and limit now always apply, and the data and count queries run together.- A partial pagination override keeps the other half.
pagination: { defaultLimit: 5 }used to replace the whole object, leavingmaxLimitundefined and the query with no limit at all.softDelete,timestamps,paginationandsqlnow merge one level deep at every config layer:forRootdefaults,@CrudService,forFeatureand the service constructor. - Search and the
ilikeoperator emitLIKEon MySQL, which has noILIKE. MySQL's default collations already compare case-insensitively. - Pages are stable. The primary key is now always the last
ORDER BYcolumn, so rows that tie on the sort columns, or a list with no sort at all, can no longer repeat or skip between pages. - A row lock works with an eager belongs-to. Locks are now
FOR UPDATE OF <base table>(and the same forshareandno key update). Postgres used to refuse withFOR UPDATE cannot be applied to the nullable side of an outer join. - Nested collections key on their own table. A nested has-many or many-to-many used the root service's
primaryKeyas its defaultparentKey, so a root keyed onuidloadedauthor.postsas[]. A nested level now defaults to its host table's own primary key column. - Types in public signatures are exported.
PaginationOptions,OperationOptions,SqlSearchOptions,SqlSearchModeandBulkOperationResultcome from the package root, along with the newCrudFeatureConfigandBulkOperationItemError. CrudFeature.configis typed asCrudFeatureConfiginstead ofRecord<string, any>, so a typo in a key no longer compiles.
Changed
These fixes change behaviour that existing code may rely on. Check each one before upgrading.
- An unknown filter key is a 400.
findAllandcountused to drop keys that were not columns, so passing a whole query DTO through worked even withpageorsortByin it. Strip non-filter fields first. The same goes for an unknown column inside a relation or collection filter, and for an unknown operator.nullandundefinedvalues are still skipped. findOnewith no usable condition, or an unknown key, is a 400. It used to return the first row in the table.findOne({ col: null })matchesIS NULL. It used to skip the key.- A configured soft-delete or timestamp key the table lacks stops the app at startup. This includes the project defaults: with
defaults.timestampson (the default), every table needscreated_atandupdated_atkeys, or the entity must set its own keys or turn timestamps off withtimestamps: undefined. Before, a missing timestamp key was silently ignored. - Every
findAllandfullTextSearchends withORDER BY <primary key> ASC. A list with no sort configured used to come back in whatever order Postgres chose; it now comes back by primary key. update(id, {})is a 400, also when timestamps are on. It used to bumpupdated_atalone.{ isNull: false }returns the non-null rows instead of the null ones.BulkOperationExceptionhas a different response body.messageis unchanged;errorsis new. Theerrorsproperty on the exception still holds the original errors.- Read errors from bad input are 400s.
find('abc')on an integer key used to be a 500. - Row locks now carry
OF <table>. A subclass that relied on locking joined rows must take that lock itself.
Deprecated
sql.jsonSupport,sql.enableFullTextSearch,hooks.enableGlobalHooksandprimaryKeyType. Nothing reads them, so they have no effect. They are still accepted and will be removed in the next major version.primaryKeyTypeis now optional. The docs no longer claim thatjsonSupportenables JSON handling or thatenableFullTextSearchgatesfullTextSearch().DatabaseConnectionExceptionandTransactionException. Nothing in the package throws them.
[3.3.0] - 2026-08-10
Added
- Has-many relations, eager loaded without N+1. Declare
relations: { states: { table: states, type: 'hasMany', foreignKey: 'country_id' } }and passoptions.relations: ['states']tofind,findOneorfindAll. Each has-many relation costs exactly one extra query for the whole result set (WHERE country_id IN (...)) no matter how many parent rows come back, and the relations run concurrently. A page of 50 countries with their states is 3 queries: data, count, children. A parent with no children gets[], neverundefined. See Relations. - Many-to-many through a join table. Declare
{ table: tags, type: 'manyToMany', through: countryTags, throughLocalKey: 'country_id', throughForeignKey: 'tag_id' }and the collection loads in one batched query per relation (SELECT ... FROM country_tags JOIN tags ... WHERE country_id IN (...)). The join-table key is used to group the rows and stripped before they are returned. - Nested relations, any depth, with a dotted path. Put a
relationsmap on a relation and address it withoptions.relations: ['states', 'states.cities']or['state.country']. Each nesting level costs one more batched query for the whole level, so a page of 50 countries with their states and every state's cities is 4 queries. Asking only forstate.countrypulls instateautomatically. orderByon a collection relation.{ type: 'hasMany', foreignKey: 'country_id', orderBy: [{ column: 'name', order: 'asc' }] }orders the children in SQL. An unknown column is warned about and skipped, as withdefaultSort.- Collections are never joined. Filtering by a child column (
findAll({ states: { name: 'Kerala' } }),findAll({ tags: { name: 'himalayan' } })) compiles tocountries.id IN (SELECT ... WHERE ...), so parent rows are never duplicated andtotaland pagination stay correct. Belongs-to relations keep using aLEFT JOIN. options.selectnow narrows relation columns with a dotted path, at any depth.select: ['id', 'name', 'region.code', 'states.cities.name']projects only those columns, for the base table, a joined belongs-to relation and a batched collection alike. Listing only relation columns leaves the parent row complete. The parent key, the child foreign key and any key a nested level needs are added back automatically, because the grouping needs them. See Column selection.- An MCP server ships with the package.
npx nestjs-drizzle-crud-mcpserves the package docs to any MCP client through three tools:list_docs,get_docandsearch_docs. It speaks JSON-RPC over stdio, still has zero dependencies, and works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, Windsurf, Zed and Cline. See AI agents and MCP. AGENTS.template.mdfor every agent that is not Claude Code. It carries the same pointer toAGENT.mdand lists the filename each tool reads.CLAUDE.template.mdis unchanged.- Exported the
RelationType('belongsTo' | 'hasMany' | 'manyToMany'),RelationNodeandSelectTreetypes.
Changed
- A relation with a missing key column now throws at startup: belongs-to requires
localKey, has-many requiresforeignKey, many-to-many requiresthrough,throughLocalKeyandthroughForeignKey. Nested relations are validated too, and the message carries the full path (Relation "states.cities" is belongsTo and requires a localKey). It used to load nothing quietly, which read as bad data rather than bad config. RelationConfig.localKeyis optional, since collection relations use their own keys instead. Existing belongs-to configs are unaffected.
[3.2.2] - 2026-08-09
Fixed
- Restored the foreign-key mapping that 3.2.1 lost.
ForeignKeyViolationExceptionandEntityInUseExceptionare exported again, and a Postgres23503is translated by direction: 400 whencreateorupdatereference a parent row that does not exist (the offending column is parsed out of the driverdetail), 409 when adeleteis blocked by rows that still reference this one. Non-FK errors on the delete path still bubble unchanged. If you upgraded from 3.2.0 to 3.2.1 and started seeing 500s on bad references, move to this release.
[3.2.1] - 2026-07-17
Changed
- Packaging and documentation only:
homepagenow points at crud.viliha.com, and the package and repository READMEs link the docs site, npm and GitHub. No runtime code changed.
Known issue
- The foreign-key mapping added in 3.2.0 is missing from this build. The move to the Turborepo monorepo carried over a source tree from before 3.2.0, so
ForeignKeyViolationExceptionandEntityInUseExceptionare not exported and a23503bubbles as a 500 again. Fixed in 3.2.2, so upgrade rather than pinning here. The 3.1.1 and 3.2.0 entries below were lost in the same move and are restored here from the published tarballs.
[3.2.0] - 2026-07-09
Added
- Foreign-key violations (Postgres
23503) map to a typed HTTP error instead of a raw 500, and the direction decides the status. Acreateorupdatethat references a parent row which does not exist gives 400ForeignKeyViolationException, with the offending column parsed out of the driverdetail. Adeleteblocked because other rows still reference this one (ON DELETE RESTRICT/NO ACTION) gives 409EntityInUseException. Both exceptions are exported from the package root.
Changed
deletewraps its statement execution so a23503on the delete side is translated; it previously bubbled as a 500. Non-FK errors still bubble unchanged.- This reverses the earlier choice to let FK violations bubble. A client-supplied foreign key is a recoverable bad value, not a programming error. NOT-NULL violations are still left to bubble.
[3.1.1] - 2026-07-03
Changed
- Package metadata only, no code changes: repository and issues URLs point to myviliha/viliha-drizzle-crud, and the homepage points to crud.viliha.com.
[3.1.0] - 2026-06-29
Added
findAll/countnow supportoptions.search. Callers can pass a search term and a list of table columns, e.g.{ search: { term: "na", columns: ["name", "code"] } }. The default mode is cross-columnILIKE '%term%';mode: "fullText"compiles a PostgreSQLto_tsvector(...) @@ plainto_tsquery(...)condition. The search predicate is applied to both the data query and the count query, so paginatedtotalstays correct.
[3.0.4] - 2026-06-21
Found while probing the package over real HTTP from the drizzle-pkg-demo consumer. PostgreSQL-first.
Fixed
- Bad client input on
create/updatenow maps to 400, not a raw 500. A value that is too long for its column (Postgres22001), fails aCHECKconstraint (23514), or is numerically out of range (22003) previously surfaced as an unhandledDrizzleQueryError→ 500 Internal Server Error. These are now translated toValidationFailedException(400), mirroring the existing unique-violation → 409 mapping.
[3.0.3] - 2026-06-20
Correctness fix found while validating the package from a fresh NestJS consumer. PostgreSQL-first.
Fixed
- Case-insensitive string filters are now EXACT, not wildcard patterns. A plain string value in a
findAll/countfilter (e.g.{ name: "foo" }) is documented as a case-insensitive exact match, but compiled toilike(column, value). ILIKE interprets%,_and\in the value as wildcards / escapes, so an equality filter silently became aLIKEpattern and returned non-matching rows (e.g.{ title: "a_b" }matched"aXb";{ title: "a%" }matched everything). It now compiles tolower(column) = lower(value), so those characters are treated as literal data. The explicit{ like }/{ ilike }operators are unchanged for intentional pattern matching.
[3.0.2] - 2026-06-19
Residual-edge hardening found by an end-to-end HTTP probe of 3.0.1 (all LOW severity; no behavior change to the happy path).
Changed
fullTextSearchnow returns the same envelope asfindAll,{ data, total, page, limit }instead of{ data, total }.page/limitare the resolved (clamped) values; the query is still only constrained when the caller passespagination. Callers paginating search results can now read back the page / limit they got.
Fixed
- Invalid
sortOrdernow fails fast (400).?sortOrder=<anything but asc/desc>previously fell through to ascending silently; it now throwsBadRequestException, mirroring the unknown-sortBycheck added in 3.0.1. - Non-finite numeric filter operands return 400, not 500. A
{ gt: NaN }/{ lte: Infinity }operand ongt/gte/lt/lte(e.g. from an unguardedNumber('abc')in a controller) reached SQL and surfaced as an opaqueDatabaseException(500). It is now rejected asBadRequestException. Non-number operands (dates, strings) are unaffected.
[3.0.1] - 2026-06-19
Correctness pass: typed exceptions now map to real HTTP status codes, plus several read-path fixes. PostgreSQL-first.
Fixed
- Exceptions extend
HttpExceptionso Nest maps them automatically:EntityNotFoundException→ 404,DuplicateEntityException→ 409,ValidationFailedException/BulkOperationException→ 400,Database*/TransactionException→ 500. Previously every error surfaced as a generic 500. - Duplicate-key translation. A Postgres unique violation (SQLSTATE
23505, on eithererr.codeor the Drizzle-wrappederr.cause.code) is converted toDuplicateEntityException(409), parsing the offending field / value from the driverdetail. fullTextSearchexcludes soft-deleted rows (added theIS NULLguard on the soft-delete column to both the data and count queries, matchingfindAll/count).- Pagination clamping.
page < 1,limit <= 0, andlimit > maxLimitare clamped via a sharedresolvePaginationhelper. No more negativeOFFSETorLIMIT 0. - Unknown sort column fails fast. A caller-supplied
sortBynot on the table throwsBadRequestException; an unknown column in a configureddefaultSortis warned and skipped. restore()returns the restored row on the PostgresRETURNINGpath (no longer re-find()s, which would have excluded the just-restored row via the soft-delete filter).
Added
- Row-level lock options on Postgres reads (
find/findOne/findAll):lock: 'update' | 'share'andforNoKeyUpdateapply.for(...). No-op on MySQL. See Row-level locks.
[3.0.0] - 2026-06-19
- First release under the
nestjs-drizzle-crudname with the 3.x API surface (DrizzleCrudModule.forRoot/forRootAsync/forFeature,SqlBaseCrudService).
Earlier (≤ 2.2.0)
Published as @quybquang/nestjs-drizzle-crud. Notable: 2.2.0 added a configurable module-level default sort; 2.1.2 documented the timestamps feature. See git history for details.