@avytrix/nestjs-drizzle-crud 0.0.0-stage → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENT.md +513 -0
  2. package/AGENTS.template.md +31 -0
  3. package/CHANGELOG.md +565 -0
  4. package/CLAUDE.template.md +18 -0
  5. package/LICENSE +21 -0
  6. package/README.md +1307 -2
  7. package/dist/controller/crud-controller.factory.d.ts +28 -0
  8. package/dist/controller/crud-controller.factory.js +279 -0
  9. package/dist/controller/crud-controller.factory.js.map +1 -0
  10. package/dist/core/abstract/sql-base-crud.service.d.ts +230 -0
  11. package/dist/core/abstract/sql-base-crud.service.js +2899 -0
  12. package/dist/core/abstract/sql-base-crud.service.js.map +1 -0
  13. package/dist/core/context/crud-context.d.ts +6 -0
  14. package/dist/core/context/crud-context.js +27 -0
  15. package/dist/core/context/crud-context.js.map +1 -0
  16. package/dist/core/events/crud-event-bus.d.ts +7 -0
  17. package/dist/core/events/crud-event-bus.js +30 -0
  18. package/dist/core/events/crud-event-bus.js.map +1 -0
  19. package/dist/core/interfaces/crud-service.interface.d.ts +54 -0
  20. package/dist/core/interfaces/crud-service.interface.js +3 -0
  21. package/dist/core/interfaces/crud-service.interface.js.map +1 -0
  22. package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +39 -0
  23. package/dist/core/interfaces/drizzle-crud-config.interface.js +3 -0
  24. package/dist/core/interfaces/drizzle-crud-config.interface.js.map +1 -0
  25. package/dist/core/interfaces/events.interface.d.ts +39 -0
  26. package/dist/core/interfaces/events.interface.js +3 -0
  27. package/dist/core/interfaces/events.interface.js.map +1 -0
  28. package/dist/core/interfaces/query.interface.d.ts +112 -0
  29. package/dist/core/interfaces/query.interface.js +3 -0
  30. package/dist/core/interfaces/query.interface.js.map +1 -0
  31. package/dist/core/interfaces/scope.interface.d.ts +3 -0
  32. package/dist/core/interfaces/scope.interface.js +3 -0
  33. package/dist/core/interfaces/scope.interface.js.map +1 -0
  34. package/dist/core/interfaces/sql-crud-config.interface.d.ts +87 -0
  35. package/dist/core/interfaces/sql-crud-config.interface.js +3 -0
  36. package/dist/core/interfaces/sql-crud-config.interface.js.map +1 -0
  37. package/dist/core/types/sql.types.d.ts +7 -0
  38. package/dist/core/types/sql.types.js +3 -0
  39. package/dist/core/types/sql.types.js.map +1 -0
  40. package/dist/core/utils/cursor.d.ts +2 -0
  41. package/dist/core/utils/cursor.js +44 -0
  42. package/dist/core/utils/cursor.js.map +1 -0
  43. package/dist/core/utils/merge-config.d.ts +2 -0
  44. package/dist/core/utils/merge-config.js +22 -0
  45. package/dist/core/utils/merge-config.js.map +1 -0
  46. package/dist/core/utils/query-parser.d.ts +4 -0
  47. package/dist/core/utils/query-parser.js +538 -0
  48. package/dist/core/utils/query-parser.js.map +1 -0
  49. package/dist/decorators/crud-service.decorator.d.ts +4 -0
  50. package/dist/decorators/crud-service.decorator.js +10 -0
  51. package/dist/decorators/crud-service.decorator.js.map +1 -0
  52. package/dist/decorators/index.d.ts +1 -0
  53. package/dist/decorators/index.js +6 -0
  54. package/dist/decorators/index.js.map +1 -0
  55. package/dist/exceptions/crud.exceptions.d.ts +46 -0
  56. package/dist/exceptions/crud.exceptions.js +93 -0
  57. package/dist/exceptions/crud.exceptions.js.map +1 -0
  58. package/dist/index.d.ts +23 -0
  59. package/dist/index.js +61 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/mcp/server.d.ts +8 -0
  62. package/dist/mcp/server.js +236 -0
  63. package/dist/mcp/server.js.map +1 -0
  64. package/dist/modules/drizzle-connection.d.ts +12 -0
  65. package/dist/modules/drizzle-connection.js +73 -0
  66. package/dist/modules/drizzle-connection.js.map +1 -0
  67. package/dist/modules/drizzle-crud.module.d.ts +11 -0
  68. package/dist/modules/drizzle-crud.module.js +165 -0
  69. package/dist/modules/drizzle-crud.module.js.map +1 -0
  70. package/dist/openapi/api-crud.decorators.d.ts +10 -0
  71. package/dist/openapi/api-crud.decorators.js +216 -0
  72. package/dist/openapi/api-crud.decorators.js.map +1 -0
  73. package/dist/test-utils/base-crud.spec-helper.d.ts +64 -0
  74. package/dist/test-utils/base-crud.spec-helper.js +119 -0
  75. package/dist/test-utils/base-crud.spec-helper.js.map +1 -0
  76. package/dist/test-utils/index.d.ts +2 -0
  77. package/dist/test-utils/index.js +8 -0
  78. package/dist/test-utils/index.js.map +1 -0
  79. package/dist/test-utils/test-factory.d.ts +29 -0
  80. package/dist/test-utils/test-factory.js +81 -0
  81. package/dist/test-utils/test-factory.js.map +1 -0
  82. package/package.json +110 -5
package/CHANGELOG.md ADDED
@@ -0,0 +1,565 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@avytrix/nestjs-drizzle-crud` (published as `nestjs-drizzle-crud` up to 3.5.0) are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ Scope note: the 3.0.x line is PostgreSQL-first. MySQL-specific paths (non-`RETURNING`
9
+ restore, `ER_DUP_ENTRY` mapping, dialect locking) are tracked separately and unchanged here.
10
+
11
+ ## [3.6.0] - 2026-11-02
12
+
13
+ Fixes from the second audit. The first one is a security fix: upgrade if you use `scope`.
14
+
15
+ ### Security
16
+
17
+ - Related tables now follow the tenant scope. Loaded relations, the joins behind relation columns,
18
+ relation filters (`some`, `none`, `every` and nested columns) and `counts` only see related rows
19
+ of the caller's tenant when the related table has the scope columns. Before, a row pointing at
20
+ another tenant's row (a plain foreign key allows it) loaded that row, and a filter on it
21
+ answered questions about it.
22
+
23
+ ### Added
24
+
25
+ - `createCrudController` takes `validation`, the validator options for the bulk bodies. Pass the
26
+ same options as your global `ValidationPipe`.
27
+
28
+ ### Fixed
29
+
30
+ - `PATCH /bulk` validates `data` against `dto.update`, the check a single `PATCH` already had.
31
+ - Route decorators that wrap the handler now run. Before, the factory dropped the wrapped handler.
32
+ - Bulk ids are parsed like `:id`, so a bad id is a 400 before any statement runs.
33
+ - A filter nested deeper than 32 objects is a 400. A thousand nested `not` groups used to
34
+ overflow the stack and answer with a 500.
35
+ - A batch past the bound-parameter limit (60,000 values: rows times columns for `massCreate` and
36
+ `massUpsert`, ids for the id based methods, values in an `IN` filter) is a 400 that names the
37
+ limit, instead of a driver protocol error and a 500.
38
+ - A missing required value (`23502`) is a 400 that names the column.
39
+ - MySQL `ER_DUP_ENTRY` is a 409, and foreign key errors are a 400 on a write and a 409 on a delete.
40
+ - `upsert` no longer updates a row in the trash: the conflict writes nothing and is a 404.
41
+ - With `useReturning: false`, a scoped `upsert` no longer reads back another tenant's row.
42
+ - `restore` on a live row is a 404 before any hook or event runs. It used to succeed and bump
43
+ `updated_at` and the version.
44
+ - `?relation[some]=` and `[none]=` with no column need the relation, or one of its columns, in
45
+ `filterableColumns`.
46
+ - A nested child row cannot set its own generated primary key.
47
+
48
+ ## [3.5.2] - 2026-11-02
49
+
50
+ ### Fixed
51
+
52
+ - `softDelete`, `restore`, `massSoftDelete` and `massRestore` on an entity without soft delete,
53
+ and full-text search on MySQL (`fullTextSearch()` or `search.mode: "fullText"`), now throw a
54
+ `BadRequestException`. They were a plain `Error`, which reached the client as a 500.
55
+ - `options.counts` now applies the tenant scope to the related rows it counts, when the related
56
+ table has the scope column. Before, a parent's count included rows of other tenants.
57
+
58
+ ### Changed
59
+
60
+ - `sortOrder` now defaults to `"asc"` in `findAll` and `findByCursor`, so a bare
61
+ `sortBy: "name"` sorts the same way as `?sortBy=name` over HTTP. Pass `sortOrder: "desc"` to
62
+ keep the old order.
63
+
64
+ ## [3.5.1] - 2026-11-02
65
+
66
+ ### Changed
67
+
68
+ - The package is now published as `@avytrix/nestjs-drizzle-crud`. The code is the same as 3.5.0.
69
+ Update the dependency and the imports:
70
+
71
+ ```sh
72
+ pnpm remove nestjs-drizzle-crud && pnpm add @avytrix/nestjs-drizzle-crud
73
+ ```
74
+
75
+ ```ts
76
+ import { CrudService } from "@avytrix/nestjs-drizzle-crud";
77
+ ```
78
+
79
+ The MCP server runs with `npx -p @avytrix/nestjs-drizzle-crud nestjs-drizzle-crud-mcp`.
80
+ The unscoped `nestjs-drizzle-crud` package is deprecated and gets no new releases.
81
+
82
+ ## [3.5.0] - 2026-11-02
83
+
84
+ The P2 features from the November 2026 audit (M10 to M18). Each one is covered by specs that run real
85
+ SQL against PGlite (`test/pglite.integration.spec.ts`), and the controller factory, scoping,
86
+ locking, OpenAPI document, counts and quantified filters also run over real HTTP in the example
87
+ app.
88
+
89
+ ### Added
90
+
91
+ - **Relations on writes.** `create`, `update`, `upsert`, `restore` and every mass write take
92
+ `options.relations` and `options.counts` and return the rows with them loaded, through the same
93
+ batched loaders as a read: one query per relation for the whole batch. Ten rows from `massCreate`
94
+ with `relations: ['author']` cost one `INSERT` and one `SELECT`.
95
+ - **Nested create.** With a relation listed in the new `writableRelations` allowlist, a create body
96
+ can carry it: an array for a has-many (one multi-row `INSERT`), an object for a belongs-to
97
+ (inserted first, its key set on the row), and ids or objects for a many-to-many (new targets in
98
+ one `INSERT`, links in one more). Everything runs in one transaction and needs `RETURNING`.
99
+ - **`attach`, `detach` and `sync`** for many-to-many relations: `attach(id, 'tags', [1, 2])`. Set
100
+ based (a fixed number of statements whatever the number of ids), already-linked ids are skipped,
101
+ and an unknown or soft-deleted target is a 400 with a 404 entry per id. Each returns the row with
102
+ the relation loaded.
103
+ - **`createCrudController(Service, options)`**, a controller factory. It registers list (offset,
104
+ or keyset with `?cursor=`), trash, get, create, update, delete, soft delete, restore and four bulk
105
+ routes, all through `parseQuery`. The `:id` param is parsed from the primary key column
106
+ (ParseIntPipe for an integer, ParseUUIDPipe for a uuid) unless `idType` says otherwise. Routes can
107
+ be turned on or off one by one and take their own decorators, so guards and roles fit in. Bulk
108
+ routes are off by default. `service.parseId(raw)` and `service.idKind()` are public.
109
+ - **OpenAPI for list queries.** `@ApiCrudQuery(source)` documents `page`, `limit`, `sortBy`,
110
+ `sortOrder`, `cursor`, `count`, `withCount`, `search`, `fields`, `with`, `counts`, the trash flags
111
+ and one parameter per filterable column with its type. `@ApiCrudList(source)` adds the page
112
+ envelope with a row schema built from the table. `@nestjs/swagger` is an optional peer, loaded
113
+ only when one of these decorators runs.
114
+ - **Tenant and ownership scoping.** `scope: (ctx) => ({ tenant_id: ctx.tenantId })` (or a fixed
115
+ object) is ANDed into every read, count, cursor page, `countBy`, `fullTextSearch`, relation count,
116
+ write, bulk write, upsert, soft delete, restore, force delete and attach/detach/sync, and written
117
+ onto every row a create or upsert inserts (and onto nested child rows that have the column).
118
+ Another tenant's id is a 404 everywhere. The context comes from `options.context` or from
119
+ `CrudContext.run(...)` / `CrudContext.middleware(...)`, which keep it in `AsyncLocalStorage` for
120
+ the request. A missing context value fails closed with `MissingScopeException` (403); a resolver
121
+ that returns `null`, or `skipScope: true`, runs unscoped on purpose.
122
+ - **Module-level events.** `hooks.enableGlobalHooks: true` now does what it says: every service
123
+ raises a before and an after event on the injectable `CrudEventBus` for each write, bulk and upsert
124
+ included. Subscribe with `hooks.subscribers` in `forRoot` or by injecting the bus. A bulk call
125
+ raises one event with `bulk: true` and every id, so audit trails and cache busting stay set based.
126
+ A before listener that throws stops the write; an after listener that throws inside a transaction
127
+ rolls it back. `skipEvents: true` opts a call out.
128
+ - **Optimistic locking.** `versionColumn: 'version'` starts rows at 1 and adds one on every update,
129
+ soft delete, restore, upsert conflict and bulk write. An update, soft delete, restore or delete
130
+ that names a stale version (`options.expectedVersion`, or the version key in an update body) is a
131
+ 409 `StaleVersionException`. Mass writes take `expectedVersions: { [id]: version }` and report a
132
+ 409 entry per stale id.
133
+ - **Relation counts and grouped counts.** `options.counts: ['posts']` (or `{ posts: { status:
134
+ 'published' } }`) puts `_count: { posts: 3 }` on every row of `find`, `findOne`, `findAll`,
135
+ `findByCursor` and the writes above, with one `GROUP BY` query per relation for the whole result.
136
+ Soft-deleted related rows are not counted. `countBy(column, filters)` returns `[{ value, count }]`,
137
+ largest group first, for a column of the table or of a top-level belongs-to.
138
+ - **`withCount: false`** on `findAll` (and `?withCount=false` in the parser) skips the `COUNT(*)`.
139
+ The envelope then has `total: null` and a `hasMore` flag, from fetching one row past the page.
140
+ - **Deeper relation filters.** A relation filter can go on through the related table's own
141
+ relations at any depth: `{ state: { country: { code: 'IN' } } }`, or `?state[country][code]=IN`.
142
+ Below the first joined belongs-to, each level is an `EXISTS` subquery. Collections take `some`,
143
+ `none` and `every` (`{ posts: { none: { status: 'draft' } } }`), compiled to `IN` / `NOT EXISTS`
144
+ subqueries that never multiply rows, inside `or` / `and` / `not` groups and in `count`.
145
+ - **New exports:** `createCrudController`, `CrudContext`, `CrudEventBus`, `ApiCrudQuery`,
146
+ `ApiCrudList`, `MissingScopeException`, `StaleVersionException`, and the types
147
+ `CrudControllerOptions`, `CrudControllerInstance`, `CrudRouteName`, `CrudRouteOptions`,
148
+ `CrudEvent`, `CrudEventPhase`, `CrudSubscriber`, `CrudWriteOperation`, `CrudScope`,
149
+ `CrudScopeValues`, `CrudContextValue`, `CollectionFilter`, `CollectionQuantifier`, `CountByRow`,
150
+ `CrudPage`, `UncountedCrudPage`, `RelationCounts`, `ApiCrudSource` and `ApiCrudQueryOptions`.
151
+ New config keys: `scope`, `versionColumn`, `events`, `writableRelations`. New options: `counts`,
152
+ `context`, `skipScope`, `skipEvents`, `expectedVersion`, `expectedVersions`, and
153
+ `PaginationOptions.withCount`.
154
+
155
+ ### Changed
156
+
157
+ These change behaviour or types that existing code may rely on. Check each one before upgrading.
158
+
159
+ - **`findAll` is overloaded on `withCount`.** With a literal `withCount: false` it returns
160
+ `UncountedCrudPage<T>` (`total: null`), without one `CrudPage<T>` (`total: number`), and with a
161
+ `withCount` typed as `boolean` (which `ParsedCrudQuery.pagination` is) `total` is
162
+ `number | null`. Code that passes `q.pagination` from `parseQuery` straight to `findAll` and then
163
+ does arithmetic on `total` needs a `total !== null` check. The runtime result is unchanged unless
164
+ `withCount: false` is passed.
165
+ - **`withCount` and `counts` are reserved query parameters.** The parser no longer treats them as
166
+ filters. A column with one of those names can still be filtered from code.
167
+ - **Writes read `options.relations` and `options.counts`.** They used to be ignored on `create`,
168
+ `update`, `upsert` and the mass writes. They are now checked against the allowlists (with
169
+ `select` and `search`) before anything is written, and loaded onto the result.
170
+ - **A relation filter can name a relation of the related table**, and a collection filter whose
171
+ keys are all `some`, `none` or `every` is a quantifier. Both used to be a 400 ("Unknown filter
172
+ column"). A related table that really has a column called `some`, `none` or `every` keeps the old
173
+ meaning.
174
+ - **`DRIZZLE_CRUD_CONFIG.hooks`** carries only `enableGlobalHooks` (a boolean); `subscribers` stay
175
+ on the raw config.
176
+
177
+ ### Fixed
178
+
179
+ - **`hooks.enableGlobalHooks` did nothing** (BUG-21). It now wires the module's `CrudEventBus` into
180
+ every service.
181
+
182
+ ### Deprecated
183
+
184
+ - Nothing new. `hooks.enableGlobalHooks`, deprecated as a no-op in 3.3.1, is no longer deprecated.
185
+
186
+ ## [3.4.0] - 2026-11-02
187
+
188
+ The P1 features from the November 2026 audit. Every one is covered by specs that run real SQL
189
+ against PGlite (`test/pglite.integration.spec.ts`) and by e2e specs over real HTTP in the example
190
+ app.
191
+
192
+ ### Added
193
+
194
+ - **A query string parser.** `service.parseQuery(query)` and the standalone `parseCrudQuery(query,
195
+ table, options)` turn
196
+ `?age[gte]=18&status=active&region[name]=Asia&or[0][code]=IN&sortBy=-name&page=2&limit=20&fields=id,name&with=region&search=jo`
197
+ into `{ filters, pagination, options, isCursor }`. Values are coerced by Drizzle column type:
198
+ integers and other numbers to `number`, `bigint` to `bigint`, booleans, dates to `Date` (an
199
+ invalid one is a 400), `numeric` checked and kept as an exact string, and text, uuid and enum left
200
+ as strings. An unknown column, relation, operator, sort column, `fields` entry or `with` path is a
201
+ 400. It reads both Express 5's flat `age[gte]` keys and `qs` nested objects.
202
+ - **Per-entity allowlists.** `filterableColumns`, `sortableColumns`, `selectableColumns`,
203
+ `searchableColumns` and `loadableRelations` limit what a caller may filter, sort, select, search
204
+ and eager-load; `writableColumns` limits what create, update and upsert may write. Anything
205
+ outside a set list is a 400, in the service and in the parser. Unset lists allow everything.
206
+ `skipAllowlists: true` skips them for a trusted call, and `findOne` where keys are not checked.
207
+ - **Trash view and purge.** `withDeleted: true` and `onlyDeleted: true` work on every read (`find`,
208
+ `findOne`, `findAll`, `findByCursor`, `count`, `exists`, `fullTextSearch`). `forceDelete(id)` and
209
+ `massForceDelete(ids)` hard-delete rows whether they are soft-deleted or not.
210
+ - **Upsert.** `upsert(dto, { conflictTarget, update })` and `massUpsert(rows, ...)` insert or update
211
+ in one statement: `ON CONFLICT DO UPDATE` on Postgres, `ON DUPLICATE KEY UPDATE` on MySQL. A clash
212
+ on another unique key is a 409, bad input a 400, and the same key twice in one `massUpsert`
213
+ (`21000`) a 400.
214
+ - **`and`, `or` and `not` filter groups.** `{ or: [{ status: 'a' }, { author: { name: 'Bob' } }],
215
+ not: { tags: { name: 'x' } } }`. Groups nest, hold column, relation and collection filters, work
216
+ inside a relation filter, and apply to `count`.
217
+ - **New filter operators.** `notIn`, `between`, `startsWith`, `endsWith` and `contains` (escaped,
218
+ case-insensitive unless `sql.caseSensitive` is on), `iStartsWith`, `iEndsWith` and `iContains`
219
+ (always case-insensitive), and on PostgreSQL `arrayContains`, `arrayOverlaps` and `jsonContains`.
220
+ A pattern operator on a non-text column, an array operator on a non-array column, or a PostgreSQL
221
+ only operator on MySQL is a 400.
222
+ - **Keyset pagination.** `findByCursor(filters, { cursor, limit, sortBy, count }, options)` returns
223
+ `{ data, limit, nextCursor, total? }`. The cursor is base64url JSON of the last row's sort values
224
+ plus the primary key, so pages stay stable under inserts and deep pages cost the same as the
225
+ first. It works with every sort, filter, relation and `select`, keeps timestamp microseconds and
226
+ follows the database's NULL ordering. The count only runs with `count: true`. A cursor that is not
227
+ valid, or was made for another sort, is a 400.
228
+ - **Multi-column and relation sorts from the caller.** `sortBy` takes a list: `['region.name',
229
+ '-name']` or `[{ column, order }]`, and a comma separated string means the same. A dotted entry
230
+ sorts by a top-level belongs-to column, joined for the data query only. A collection column is a
231
+ 400.
232
+ - **`bulkMode: 'perItem'`** keeps the 3.3 behaviour of one single-row call per item, in its own
233
+ savepoint.
234
+ - **New exports:** `parseCrudQuery`, and the types `CrudFilter`, `FilterGroup`, `FilterOperators`,
235
+ `FilterOperator`, `FilterScalar`, `ColumnFilter`, `SortInput`, `CursorPaginationOptions`,
236
+ `CursorPage`, `CrudAllowlists`, `UpsertOptions`, `BulkMode`, `ParseCrudQueryOptions` and
237
+ `ParsedCrudQuery`. The Drizzle helpers `between`, `not` and `notInArray` are re-exported.
238
+
239
+ ### Changed
240
+
241
+ These change behaviour that existing code may rely on. Check each one before upgrading.
242
+
243
+ - **Mass writes are set-based.** `massUpdate`, `massSoftDelete`, `massRestore` and `massDelete` cost
244
+ one `SELECT` that checks every id plus one `UPDATE` or `DELETE ... WHERE id IN (...)`, and
245
+ `massCreate` one multi-row `INSERT`, instead of one or two queries per id. The
246
+ `BulkOperationException` body is unchanged: when the statement fails, the batch is replayed item
247
+ by item inside a savepoint that is rolled back, only to name the failing items. Hooks still run
248
+ once per item, but the `after*` hooks now run once the whole set is written instead of after each
249
+ item. Duplicate ids are written once. Pass `bulkMode: 'perItem'` for the old behaviour.
250
+ - **Create and update ignore the columns the package manages.** Without `writableColumns`, the soft
251
+ delete column is dropped from a create or update body, and the primary key and the `createdAt`
252
+ column from an update body. An update whose only key was one of these is now a 400 ("there are no
253
+ columns to update"). To write them on purpose, list them in `writableColumns`.
254
+ - **`PaginationOptions.sortBy` is `SortInput`** (`string | Array<string | SortColumn>`). Code that
255
+ passes a string is unaffected; code that reads `sortBy` back as a `string` needs a narrowing
256
+ check.
257
+ - **`between` is an operator now.** `{ col: { between: [a, b] } }` used to be a 400 ("Unknown filter
258
+ operator").
259
+ - `buildOrderBy` takes a `SortInput`, and `buildFilterConditions` and `buildCollectionFilter` take
260
+ an optional `options` argument, for subclasses that call them.
261
+
262
+ ### Fixed
263
+
264
+ - **Mass operations cost a fixed number of queries** (BUG-16). `massUpdate` over 10 ids was 20
265
+ queries; it is now 2.
266
+ - **The soft delete guide's trash and purge recipes work** (BUG-17). They used raw Drizzle because
267
+ `findAll({ deleted_at: { isNotNull: true } })` always returned nothing; the guide now uses
268
+ `onlyDeleted` and `forceDelete`.
269
+
270
+ ## [3.3.1] - 2026-11-02
271
+
272
+ Fixes from the November 2026 audit. Every fix below is proven by a spec that runs real SQL against
273
+ PGlite (`test/pglite.integration.spec.ts`) and failed before the change.
274
+
275
+ ### Fixed
276
+ - **String filters on integer, uuid and enum columns no longer return a 500.** Query strings send
277
+ every value as text, and with the default `caseSensitive: false` a filter such as
278
+ `{ author_id: '1' }` became `lower(author_id)`, which Postgres rejects. `lower()` now only wraps
279
+ text, varchar and char columns; every other column compares with `=` and Postgres casts the value.
280
+ Relation and collection filters get the same fix.
281
+ - **Date strings work on date and timestamp columns.** `{ publishedAt: { gt: '2024-01-01' } }` used
282
+ to crash inside the driver (`value.toISOString is not a function`). The string is now turned into
283
+ a `Date`; a value that is not a date is a 400.
284
+ - **A soft-deleted belongs-to parent is no longer returned.** The `LEFT JOIN` for an eager load or a
285
+ relation filter now carries `deleted_at IS NULL` in its `ON` clause, so the parent comes back as
286
+ `null` and `{ author: { name: 'Bob' } }` does not match a deleted author.
287
+ - **`findOne` can no longer hand back an arbitrary row.** `findOne({ email: undefined })` used to run
288
+ `LIMIT 1` with no condition and return the first row in the table. It is now a 400, as is a key
289
+ that is not a column. `null` now means `IS NULL` instead of being skipped. An operator object works
290
+ as it does in `findAll`.
291
+ - **A soft-delete or timestamp column the table does not have is caught at startup.** With
292
+ camelCase keys (`deletedAt: timestamp('deleted_at')`) the default config used to break every read
293
+ with a SQL syntax error, and `updatedAt` silently never moved. The service now throws when it is
294
+ built, naming the key to use: `softDelete.column is "deleted_at", but the table has no column
295
+ under that key. The column "deleted_at" is declared under the key "deletedAt"; set
296
+ softDelete.column to "deletedAt".`
297
+ - **One bad item in a bulk call no longer fails every item after it.** Each item now runs in its own
298
+ savepoint, so `massCreate([ok, duplicate, ok, ok])` reports the duplicate alone instead of two more
299
+ `25P02` "current transaction is aborted" errors. The whole call still rolls back. The items still
300
+ run one query (or two) at a time; set-based bulk writes are planned separately.
301
+ - **Bulk errors reach the HTTP response.** `BulkOperationException` now answers with
302
+ `{ statusCode, error, message, errors: [{ index | id, status, message }] }`, one entry per failed
303
+ item with the status that item alone would have produced. An error that is not an HTTP exception
304
+ shows as `status: 500` with a generic message, so SQL never leaks.
305
+ - **`{ isNull: false }` means `IS NOT NULL`.** The value used to be ignored. `isNull` and `isNotNull`
306
+ also accept the strings `"true"` and `"false"`; anything else is a 400.
307
+ - **Filters the package does not understand are rejected instead of dropped.** A new `eq` operator
308
+ is supported, a `Date` value is an equality match instead of an empty operator object, and an
309
+ unknown column, relation column or operator is a 400. Each of these used to return every row.
310
+ - **Bad input is a 400 on reads too.** Postgres `22P02` (wrong type, unknown enum label), `22007` and
311
+ `22008` (bad dates) now map to `ValidationFailedException` on every path, including `find`,
312
+ `findAll`, `count` and `fullTextSearch`. `fullTextSearch` checks its column names (an unknown one
313
+ used to be a SQL syntax error), and `update` with nothing to set is a 400 instead of a raw
314
+ "No values to set".
315
+ - **`fullTextSearch` always paginates.** Without a pagination argument it returned every match while
316
+ the envelope said `limit: 20`. The resolved page and limit now always apply, and the data and count
317
+ queries run together.
318
+ - **A partial pagination override keeps the other half.** `pagination: { defaultLimit: 5 }` used to
319
+ replace the whole object, leaving `maxLimit` undefined and the query with no limit at all.
320
+ `softDelete`, `timestamps`, `pagination` and `sql` now merge one level deep at every config layer:
321
+ `forRoot` defaults, `@CrudService`, `forFeature` and the service constructor.
322
+ - **Search and the `ilike` operator emit `LIKE` on MySQL**, which has no `ILIKE`. MySQL's default
323
+ collations already compare case-insensitively.
324
+ - **Pages are stable.** The primary key is now always the last `ORDER BY` column, so rows that tie on
325
+ the sort columns, or a list with no sort at all, can no longer repeat or skip between pages.
326
+ - **A row lock works with an eager belongs-to.** Locks are now `FOR UPDATE OF <base table>` (and the
327
+ same for `share` and `no key update`). Postgres used to refuse with `FOR UPDATE cannot be applied
328
+ to the nullable side of an outer join`.
329
+ - **Nested collections key on their own table.** A nested has-many or many-to-many used the root
330
+ service's `primaryKey` as its default `parentKey`, so a root keyed on `uid` loaded
331
+ `author.posts` as `[]`. A nested level now defaults to its host table's own primary key column.
332
+ - **Types in public signatures are exported.** `PaginationOptions`, `OperationOptions`,
333
+ `SqlSearchOptions`, `SqlSearchMode` and `BulkOperationResult` come from the package root, along
334
+ with the new `CrudFeatureConfig` and `BulkOperationItemError`.
335
+ - `CrudFeature.config` is typed as `CrudFeatureConfig` instead of `Record<string, any>`, so a typo in
336
+ a key no longer compiles.
337
+
338
+ ### Changed
339
+ These fixes change behaviour that existing code may rely on. Check each one before upgrading.
340
+ - **An unknown filter key is a 400.** `findAll` and `count` used to drop keys that were not columns,
341
+ so passing a whole query DTO through worked even with `page` or `sortBy` in it. Strip non-filter
342
+ fields first. The same goes for an unknown column inside a relation or collection filter, and for
343
+ an unknown operator. `null` and `undefined` values are still skipped.
344
+ - **`findOne` with no usable condition, or an unknown key, is a 400.** It used to return the first
345
+ row in the table.
346
+ - **`findOne({ col: null })` matches `IS NULL`.** It used to skip the key.
347
+ - **A configured soft-delete or timestamp key the table lacks stops the app at startup.** This
348
+ includes the project defaults: with `defaults.timestamps` on (the default), every table needs
349
+ `created_at` and `updated_at` keys, or the entity must set its own keys or turn timestamps off
350
+ with `timestamps: undefined`. Before, a missing timestamp key was silently ignored.
351
+ - **Every `findAll` and `fullTextSearch` ends with `ORDER BY <primary key> ASC`.** A list with no
352
+ sort configured used to come back in whatever order Postgres chose; it now comes back by primary
353
+ key.
354
+ - **`update(id, {})` is a 400**, also when timestamps are on. It used to bump `updated_at` alone.
355
+ - **`{ isNull: false }` returns the non-null rows** instead of the null ones.
356
+ - **`BulkOperationException` has a different response body.** `message` is unchanged; `errors` is
357
+ new. The `errors` property on the exception still holds the original errors.
358
+ - **Read errors from bad input are 400s.** `find('abc')` on an integer key used to be a 500.
359
+ - Row locks now carry `OF <table>`. A subclass that relied on locking joined rows must take that
360
+ lock itself.
361
+
362
+ ### Deprecated
363
+ - `sql.jsonSupport`, `sql.enableFullTextSearch`, `hooks.enableGlobalHooks` and `primaryKeyType`.
364
+ Nothing reads them, so they have no effect. They are still accepted and will be removed in the
365
+ next major version. `primaryKeyType` is now optional. The docs no longer claim that `jsonSupport`
366
+ enables JSON handling or that `enableFullTextSearch` gates `fullTextSearch()`.
367
+ - `DatabaseConnectionException` and `TransactionException`. Nothing in the package throws them.
368
+
369
+ ## [3.3.0] - 2026-08-10
370
+
371
+ ### Added
372
+ - **Has-many, many-to-many and nested relations, all eager loaded without N+1.** Declare
373
+ `relations: { states: { table: states, type: 'hasMany', foreignKey: 'country_id' } }` and pass
374
+ `options.relations: ['states']` to `find`, `findOne` or `findAll`. Each has-many relation costs
375
+ exactly one extra query for the whole result set (`WHERE country_id IN (...)`), no matter how many
376
+ parent rows come back, and the relations run concurrently. A page of 50 countries with their
377
+ states is 3 queries: data, count, children. A parent with no children gets `[]`, never `undefined`.
378
+ - **Many-to-many through a join table.** Declare
379
+ `{ table: tags, type: 'manyToMany', through: countryTags, throughLocalKey: 'country_id', throughForeignKey: 'tag_id' }`
380
+ and the collection loads in one batched query per relation
381
+ (`SELECT ... FROM country_tags JOIN tags ... WHERE country_id IN (...)`). The join-table key is
382
+ used to group the rows and stripped before they are returned.
383
+ - **Nested relations, any depth, with a dotted path.** Put a `relations` map on a relation and
384
+ address it with `options.relations: ['states', 'states.cities']` or `['state.country']`. Each
385
+ nesting level costs one more batched query for the whole level, so a page of 50 countries with
386
+ their states and every state's cities is 4 queries. Asking only for `state.country` pulls in
387
+ `state` automatically.
388
+ - **`orderBy` on a collection relation.** `{ type: 'hasMany', foreignKey: 'country_id', orderBy: [{ column: 'name', order: 'asc' }] }`
389
+ orders the children in SQL. An unknown column is warned about and skipped, as with `defaultSort`.
390
+ - **Collections are never joined.** Filtering by a child column
391
+ (`findAll({ states: { name: 'Kerala' } })`, `findAll({ tags: { name: 'himalayan' } })`) compiles to
392
+ `countries.id IN (SELECT ... WHERE ...)`, so parent rows are never duplicated and `total` and
393
+ pagination stay correct. Belongs-to relations keep using a `LEFT JOIN`.
394
+ - **`options.select` now narrows relation columns with a dotted path, at any depth.**
395
+ `select: ['id', 'name', 'region.code', 'states.cities.name']` projects only those columns, for the
396
+ base table, a joined belongs-to relation and a batched collection alike. Listing only relation
397
+ columns leaves the parent row complete. The parent key, the child foreign key and any key a nested
398
+ level needs are added back automatically, because the grouping needs them.
399
+ - **An MCP server ships with the package.** `npx nestjs-drizzle-crud-mcp` serves the package docs
400
+ (`AGENT.md`, `README.md`, `CHANGELOG.md`) to any MCP client through three tools: `list_docs`,
401
+ `get_doc` and `search_docs`. It speaks JSON-RPC over stdio, still has zero dependencies, and works
402
+ with Claude Code, Codex, Cursor, Copilot, Gemini CLI, Windsurf, Zed and Cline.
403
+ - **`AGENTS.template.md`** for every agent that is not Claude Code. It carries the same pointer to
404
+ `AGENT.md` and lists the filename each tool reads. `CLAUDE.template.md` is unchanged.
405
+ - Exported the `RelationType` (`'belongsTo' | 'hasMany' | 'manyToMany'`), `RelationNode` and
406
+ `SelectTree` types.
407
+
408
+ ### Changed
409
+ - A relation with a missing key column now throws at startup: belongs-to requires `localKey`,
410
+ has-many requires `foreignKey`, many-to-many requires `through`, `throughLocalKey` and
411
+ `throughForeignKey`. Nested relations are validated too, and the message carries the full path
412
+ (`Relation "states.cities" is belongsTo and requires a localKey`). It used to load nothing quietly,
413
+ which read as bad data rather than bad config.
414
+ - `RelationConfig.localKey` is optional, since has-many relations use `foreignKey` instead. Existing
415
+ belongs-to configs are unaffected.
416
+
417
+ ## [3.2.2] - 2026-08-09
418
+
419
+ ### Fixed
420
+ - **Restored the foreign-key mapping that 3.2.1 lost.** `ForeignKeyViolationException` and
421
+ `EntityInUseException` are exported again, and a Postgres `23503` is translated by direction:
422
+ **400** when `create`/`update` reference a parent row that does not exist (the offending column
423
+ is parsed out of the driver `detail`), **409** when a `delete` is blocked by rows that still
424
+ reference this one. Non-FK errors on the delete path still bubble unchanged. Anyone who upgraded
425
+ from 3.2.0 to 3.2.1 and started seeing 500s on bad references should move to this release.
426
+ - Covered by six specs in `test/bug-fixes.spec.ts`, including the wrapped `DrizzleQueryError`
427
+ shape and the missing-`detail` fallback.
428
+
429
+ ## [3.2.1] - 2026-07-17
430
+
431
+ ### Changed
432
+ - Packaging and documentation only: `homepage` now points at
433
+ [crud.viliha.com](https://crud.viliha.com), and the package and repository READMEs link the
434
+ docs site, npm and GitHub. No runtime code changed.
435
+
436
+ ### Known issue
437
+ - **The foreign-key mapping added in 3.2.0 is missing from this build.** The move to the
438
+ Turborepo monorepo carried over a source tree from before 3.2.0, so `ForeignKeyViolationException`
439
+ and `EntityInUseException` are not exported and a `23503` bubbles as a 500 again. **Fixed in
440
+ 3.2.2**, so upgrade rather than pinning here. The 3.1.1 and 3.2.0 entries below were lost in the
441
+ same move and are restored here from the published tarballs.
442
+
443
+ ## [3.2.0] - 2026-07-09
444
+
445
+ ### Added
446
+ - **Foreign-key violations (Postgres `23503`) now map to a typed HTTP error instead of a raw 500**,
447
+ and the direction decides the status:
448
+ - `create`/`update` referencing a parent row that does not exist → **400**
449
+ `ForeignKeyViolationException` (a client-supplied reference that points at nothing is bad input).
450
+ The offending column is parsed out of the driver `detail` (e.g. `language_id`).
451
+ - `delete` blocked because other rows still reference this one (`ON DELETE RESTRICT`/`NO ACTION`)
452
+ → **409** `EntityInUseException`.
453
+ - Both exceptions are exported from the package root.
454
+
455
+ ### Changed
456
+ - `delete` now wraps its statement execution so a `23503` on the delete side is translated (was
457
+ previously left to bubble as a 500). Non-FK errors still bubble unchanged.
458
+ - Reverses the prior deliberate choice to let FK violations bubble: a client-supplied FK is a
459
+ recoverable bad value, not a programming error. NOT-NULL violations are still left to bubble.
460
+
461
+ ## [3.1.1] - 2026-07-03
462
+
463
+ ### Changed
464
+ - Package metadata only, no code changes: repository and issues URLs now point to
465
+ [myviliha/viliha-drizzle-crud](https://github.com/myviliha/viliha-drizzle-crud), and the
466
+ homepage points to the docs site at [crud.viliha.com](https://crud.viliha.com/).
467
+
468
+ ## [3.1.0] - 2026-06-29
469
+
470
+ ### Added
471
+ - **`findAll`/`count` now support `options.search`.** Callers can pass a search term and a list of
472
+ table columns, e.g. `{ search: { term: "na", columns: ["name", "code"] } }`. The default mode is
473
+ cross-column `ILIKE '%term%'`; `mode: "fullText"` compiles a PostgreSQL
474
+ `to_tsvector(...) @@ plainto_tsquery(...)` condition. The search predicate is applied to both the
475
+ data query and the count query, so paginated `total` stays correct.
476
+
477
+ ## [3.0.4] - 2026-06-21
478
+
479
+ Found while probing the package over real HTTP from the `drizzle-pkg-demo` consumer
480
+ (`scripts/probe.sh`). PostgreSQL-first. Verified by 56 jest specs + 56 HTTP e2e assertions.
481
+
482
+ ### Fixed
483
+ - **Bad client input on `create`/`update` now maps to 400, not a raw 500.** A value that is too
484
+ long for its column (Postgres `22001`), fails a `CHECK` constraint (`23514`), or is numerically
485
+ out of range (`22003`) previously surfaced as an unhandled `DrizzleQueryError` → 500 Internal
486
+ Server Error. These are now translated to `ValidationFailedException` (400), mirroring the
487
+ existing unique-violation → 409 mapping. NOT-NULL/FK violations are intentionally left to bubble
488
+ (they usually indicate a programming/config error, not a recoverable bad value).
489
+
490
+ ## [3.0.3] - 2026-06-20
491
+
492
+ Correctness fix found while validating the package from a fresh NestJS consumer
493
+ (see `drizzle-pkg-demo`). PostgreSQL-first. Verified by 53 jest specs + 47 HTTP e2e assertions.
494
+
495
+ ### Fixed
496
+ - **Case-insensitive string filters are now EXACT, not wildcard patterns.** A plain string
497
+ value in a `findAll`/`count` filter (e.g. `{ name: "foo" }`) is documented as a
498
+ case-insensitive *exact* match, but compiled to `ilike(column, value)`. So a value containing
499
+ `%`, `_` or `\` was silently treated as a `LIKE` pattern and returned non-matching rows
500
+ (e.g. `{ title: "a_b" }` matched `"aXb"`; `{ title: "a%" }` matched everything). It now compiles
501
+ to `lower(column) = lower(value)`, so those characters are treated as literal data. The explicit
502
+ `{ like }` / `{ ilike }` operators are unchanged for intentional pattern matching.
503
+
504
+ ## [3.0.2] - 2026-06-19
505
+
506
+ Residual-edge hardening found by an end-to-end HTTP probe of 3.0.1 (all LOW severity;
507
+ no behavior change to the happy path). Verified by 35 HTTP assertions on Neon + 49 jest specs.
508
+
509
+ ### Changed
510
+ - **`fullTextSearch` now returns the same envelope as `findAll`**, `{ data, total, page, limit }`
511
+ instead of `{ data, total }`. `page`/`limit` are the resolved (clamped) values; the query is
512
+ still only constrained when the caller passes `pagination`. Callers paginating search results
513
+ can now read back the page/limit they got.
514
+
515
+ ### Fixed
516
+ - **Invalid `sortOrder` now fails fast (400).** `?sortOrder=<anything but asc/desc>` previously
517
+ fell through to ascending silently; it now throws `BadRequestException`, mirroring the
518
+ unknown-`sortBy` check added in 3.0.1.
519
+ - **Non-finite numeric filter operands return 400, not 500.** A `{ gt: NaN }` / `{ lte: Infinity }`
520
+ operand on `gt`/`gte`/`lt`/`lte` (e.g. from an unguarded `Number('abc')` in a controller) reached
521
+ SQL and surfaced as an opaque `DatabaseException` (500). It is now rejected as
522
+ `BadRequestException`. Non-number operands (dates, strings) are unaffected.
523
+
524
+ ## [3.0.1] - 2026-06-19
525
+
526
+ Correctness pass: typed exceptions now map to real HTTP status codes, plus several read-path fixes.
527
+ (PostgreSQL-first.)
528
+
529
+ ### Fixed
530
+ - **Exceptions extend `HttpException`** so Nest maps them automatically:
531
+ `EntityNotFoundException` → 404, `DuplicateEntityException` → 409,
532
+ `ValidationFailedException` / `BulkOperationException` → 400,
533
+ `Database*` / `TransactionException` → 500. Previously every error surfaced as a generic 500.
534
+ - **Duplicate-key translation.** A Postgres unique violation (SQLSTATE `23505`, on either
535
+ `err.code` or the Drizzle-wrapped `err.cause.code`) is converted to `DuplicateEntityException`
536
+ (409), parsing the offending field/value from the driver `detail`.
537
+ - **`fullTextSearch` excludes soft-deleted rows** (added the `IS NULL` guard on the soft-delete
538
+ column to both the data and count queries, matching `findAll`/`count`).
539
+ - **Pagination clamping.** `page < 1`, `limit <= 0`, and `limit > maxLimit` are clamped via a shared
540
+ `resolvePagination` helper. No more negative `OFFSET` or `LIMIT 0`.
541
+ - **Unknown sort column fails fast.** A caller-supplied `sortBy` not on the table throws
542
+ `BadRequestException`; an unknown column in a configured `defaultSort` is warned and skipped.
543
+ - **`restore()` returns the restored row** on the Postgres `RETURNING` path (no longer re-`find()`s,
544
+ which would have excluded the just-restored row via the soft-delete filter).
545
+
546
+ ### Added
547
+ - **Row-level lock options** on Postgres reads (`find`/`findOne`/`findAll`): `lock: 'update' | 'share'`
548
+ and `forNoKeyUpdate` apply `.for(...)`. No-op on MySQL.
549
+
550
+ ## [3.0.0] - 2026-06-19
551
+
552
+ - First release under the `nestjs-drizzle-crud` name with the 3.x API surface
553
+ (`DrizzleCrudModule.forRoot`/`forRootAsync`/`forFeature`, `SqlBaseCrudService`).
554
+
555
+ ## Earlier (≤ 2.2.0)
556
+
557
+ Published as `@quybquang/nestjs-drizzle-crud`. Notable: 2.2.0 added a configurable module-level
558
+ default sort; 2.1.2 documented the timestamps feature. See git history for details.
559
+
560
+ [3.1.0]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.1.0
561
+ [3.0.4]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.0.4
562
+ [3.0.3]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.0.3
563
+ [3.0.2]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.0.2
564
+ [3.0.1]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.0.1
565
+ [3.0.0]: https://www.npmjs.com/package/nestjs-drizzle-crud/v/3.0.0
@@ -0,0 +1,18 @@
1
+ <!--
2
+ nestjs-drizzle-crud — AI agent rules for apps that consume the package.
3
+
4
+ Copy this file to your PROJECT ROOT as CLAUDE.md:
5
+
6
+ cp node_modules/nestjs-drizzle-crud/CLAUDE.template.md ./CLAUDE.md
7
+
8
+ For any other agent (Codex, Cursor, Copilot, Gemini CLI, Windsurf, Zed,
9
+ Cline, Aider), use AGENTS.template.md instead. It lists the filename each
10
+ tool reads.
11
+
12
+ The line below loads the agent guide that ships inside the package, so
13
+ AGENT.md stays the single source of truth. You never copy the rules
14
+ themselves, only this pointer. If your agent tool doesn't resolve `@`
15
+ imports, open node_modules/nestjs-drizzle-crud/AGENT.md and paste it here.
16
+ -->
17
+
18
+ @node_modules/nestjs-drizzle-crud/AGENT.md
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Suman Bonakurthi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.