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

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