@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/README.md CHANGED
@@ -1,3 +1,1308 @@
1
- # Temporary Holding Version
1
+ # nestjs-drizzle-crud
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![docs](https://img.shields.io/badge/docs-crud.avytrix.dev-E0234E)](https://crud.avytrix.dev)
4
+ [![npm](https://img.shields.io/npm/v/@avytrix/nestjs-drizzle-crud?color=E0234E)](https://www.npmjs.com/package/@avytrix/nestjs-drizzle-crud)
5
+ [![license](https://img.shields.io/npm/l/@avytrix/nestjs-drizzle-crud)](./LICENSE)
6
+ [![Sponsor @avytrix](https://img.shields.io/badge/Sponsor-%40avytrix-db61a2?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/avytrix)
7
+
8
+ **nestjs-drizzle-crud is a NestJS + Drizzle ORM CRUD library: extend one base class and every table gets type-safe find, create, update, delete, filtering, pagination, relations and soft delete, on PostgreSQL or MySQL.**
9
+
10
+ ```bash
11
+ pnpm add @avytrix/nestjs-drizzle-crud drizzle-orm
12
+ ```
13
+
14
+ > **Renamed.** From 3.5.1 the package is `@avytrix/nestjs-drizzle-crud`; the unscoped `nestjs-drizzle-crud` is deprecated. Change the dependency and the imports, nothing else changes.
15
+
16
+ ```typescript
17
+ // app.module.ts: configure the connection once
18
+ DrizzleCrudModule.forRoot({
19
+ dialect: 'postgresql',
20
+ connectionString: process.env.DATABASE_URL,
21
+ schema,
22
+ });
23
+
24
+ // users.service.ts: a full CRUD service is an empty subclass
25
+ export class UsersService extends SqlBaseCrudService<
26
+ User, CreateUserDto, UpdateUserDto, CrudFilter
27
+ > {}
28
+
29
+ // users.module.ts: bind it to a table
30
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]);
31
+ ```
32
+
33
+ **[Read the docs at crud.avytrix.dev](https://crud.avytrix.dev)**, starting with the [quick start](https://crud.avytrix.dev/docs/quick-start/). Also on [npm](https://www.npmjs.com/package/@avytrix/nestjs-drizzle-crud) and [GitHub](https://github.com/avytrix/nestjs-drizzle-crud).
34
+
35
+ Built and maintained by **[Avytrix Technologies](https://avytrix.dev/?utm_source=nestjs-drizzle-crud&utm_medium=docs&utm_campaign=readme)**. Need help shipping? [Hire the team behind the library](https://crud.avytrix.dev/docs/commercial-support/).
36
+
37
+ ## ❤️ Sponsoring is what keeps this package free
38
+
39
+ A CRUD layer sounds simple until you own it in production: dialect differences,
40
+ driver result shapes, error codes that have to become the right HTTP status,
41
+ counts that stay correct once filters and joins are in play. This package
42
+ absorbs that work and gives it away under MIT.
43
+
44
+ Keeping it current with NestJS, Drizzle and TypeScript releases is ongoing, and
45
+ sponsors are what make keeping it free sustainable.
46
+
47
+ **Even $1 a month helps.** It funds bug fixes, new guides, and the next release.
48
+
49
+ > Sponsors get a spot on the [sponsor page](https://crud.avytrix.dev/docs/sponsor/)
50
+ > and our thanks.
51
+
52
+ ### 👉 [Sponsor on GitHub →](https://github.com/sponsors/avytrix) &nbsp;·&nbsp; thank you 🙏
53
+
54
+ ---
55
+
56
+ ## Table of contents
57
+
58
+ - [Features](#features)
59
+ - [Installation](#installation)
60
+ - [Quick start](#quick-start)
61
+ - [Configuration](#configuration)
62
+ - [Defining services](#defining-services)
63
+ - [API reference](#api-reference)
64
+ - [Filtering](#filtering)
65
+ - [Query strings: parseQuery](#query-strings-parsequery)
66
+ - [Allowlists](#allowlists)
67
+ - [Pagination & sorting](#pagination--sorting)
68
+ - [Cursor pagination](#cursor-pagination)
69
+ - [Relations](#relations)
70
+ - [Column selection](#column-selection)
71
+ - [Primary keys (serial / uuid)](#primary-keys-serial--uuid)
72
+ - [Soft delete](#soft-delete)
73
+ - [Timestamps](#timestamps)
74
+ - [Bulk operations](#bulk-operations)
75
+ - [Upsert](#upsert)
76
+ - [Relations on writes](#relations-on-writes)
77
+ - [Controller factory](#controller-factory)
78
+ - [OpenAPI (Swagger)](#openapi-swagger)
79
+ - [Multi-tenancy: scope](#multi-tenancy-scope)
80
+ - [Events and global hooks](#events-and-global-hooks)
81
+ - [Optimistic locking](#optimistic-locking)
82
+ - [Counts and grouped counts](#counts-and-grouped-counts)
83
+ - [Transactions](#transactions)
84
+ - [Full-text search (PostgreSQL)](#full-text-search-postgresql)
85
+ - [Lifecycle hooks & validation](#lifecycle-hooks--validation)
86
+ - [Testing](#testing)
87
+ - [For AI agents / LLM tools](#for-ai-agents--llm-tools)
88
+ - [Support](#support)
89
+
90
+ ---
91
+
92
+ ## Features
93
+
94
+ - 🚀 **CRUD**: `find`, `findOne`, `findAll`, `create`, `update`, `delete`, and more
95
+ - 🧩 **Configure once**: `forRoot()` owns the connection; services only declare a table
96
+ - ⚡ **Type-safe**: generics over your entity, create/update DTOs and filter types
97
+ - 🗃️ **PostgreSQL & MySQL**: dialect-aware (`RETURNING` vs `insertId`)
98
+ - 🔄 **Soft delete**: opt-in, with `restore`, a trash view (`onlyDeleted`) and `forceDelete`
99
+ - 📦 **Bulk operations**: one statement per mass create/update/delete, inside a transaction, plus `upsert`
100
+ - 🔍 **Filtering**: comparison, `in`/`notIn`, `between`, escaped `contains`/`startsWith`, array and JSON operators, `or`/`not` groups
101
+ - 🧾 **Query string parsing**: `parseQuery` turns `?age[gte]=18&sortBy=-name` into typed filters
102
+ - 🛡️ **Allowlists**: limit what a client may filter, sort, select and write
103
+ - 📜 **Cursor pagination**: `findByCursor` with an opaque `nextCursor`
104
+ - 🔎 **Full-text search**: PostgreSQL `tsvector`/`tsquery`
105
+ - 🔗 **Relations**: belongs-to, has-many, many-to-many and nested eager loading with no N+1, filtering through relation paths with `some` / `none` / `every`, and relations on writes (nested create, `attach` / `detach` / `sync`)
106
+ - 🧭 **Controller factory**: `createCrudController(Service)` wires list, get, create, update, delete, trash, restore and bulk routes, with the id pipe picked from the key
107
+ - 📘 **OpenAPI**: `@ApiCrudList()` documents every list parameter and filterable column (optional `@nestjs/swagger` peer)
108
+ - 🏢 **Multi-tenancy**: a `scope` keeps every read and write inside the caller's tenant; another tenant's id is a 404
109
+ - 📣 **Events**: module-level before/after events for every write, bulk included, for audit trails and cache busting
110
+ - 🔒 **Optimistic locking**: a version column, and a stale write is a 409
111
+ - 🔢 **Counts**: `_count` for collections and `countBy(column)`, batched; `withCount: false` skips `COUNT(*)`
112
+ - 🎯 **Column selection**: ask for the columns you need, relation columns included
113
+ - 🤖 **AI agents**: rules templates for each agent tool, and a bundled MCP server
114
+ - 🔑 **Primary keys**: `serial` / `int` / `bigint` / `bigserial` / `uuid`
115
+ - 🪝 **Hooks & validation**: `before*`/`after*` hooks, `validateCreate`/`validateUpdate`
116
+ - 🧪 **Test utilities**: mock db/table/entity factories
117
+
118
+ ---
119
+
120
+ ## Installation
121
+
122
+ ```bash
123
+ pnpm add @avytrix/nestjs-drizzle-crud
124
+ # or
125
+ npm install @avytrix/nestjs-drizzle-crud
126
+ # or
127
+ yarn add @avytrix/nestjs-drizzle-crud
128
+ ```
129
+
130
+ Peer dependencies (install the ones you use):
131
+
132
+ ```bash
133
+ # always
134
+ pnpm add @nestjs/common @nestjs/core drizzle-orm reflect-metadata
135
+
136
+ # PostgreSQL (also required if you use `connectionString` with dialect 'postgresql')
137
+ pnpm add postgres
138
+
139
+ # MySQL
140
+ pnpm add mysql2
141
+
142
+ # Only for ApiCrudQuery / ApiCrudList (OpenAPI docs)
143
+ pnpm add @nestjs/swagger
144
+ ```
145
+
146
+ > Using npm or yarn? Swap `pnpm add` for `npm install` / `yarn add`.
147
+
148
+ > **pnpm note:** `postgres` is an optional peer dependency and `mysql2` is not a peer
149
+ > at all. pnpm enforces peer deps strictly, so if you build the connection from a
150
+ > `connectionString` make sure `postgres` is installed in your app; for MySQL you
151
+ > install `mysql2` yourself and pass the `db` you build with it.
152
+
153
+ > `postgres` is an **optional** peer dependency. It's only needed when you let the
154
+ > module build the connection from a `connectionString` for the `postgresql` dialect.
155
+ > If you pass a pre-built `db` instead, you don't need it.
156
+
157
+ ---
158
+
159
+ ## Quick start
160
+
161
+ ### 1. Define your Drizzle schema
162
+
163
+ ```typescript
164
+ // db/schema.ts
165
+ import { pgTable, serial, varchar } from 'drizzle-orm/pg-core';
166
+
167
+ export const users = pgTable('users', {
168
+ id: serial('id').primaryKey(),
169
+ name: varchar('name', { length: 100 }).notNull(),
170
+ email: varchar('email', { length: 255 }).notNull().unique(),
171
+ });
172
+
173
+ export const schema = { users };
174
+ export type User = typeof users.$inferSelect;
175
+ ```
176
+
177
+ ### 2. Configure the module once
178
+
179
+ ```typescript
180
+ // app.module.ts
181
+ import { Module } from '@nestjs/common';
182
+ import { DrizzleCrudModule } from '@avytrix/nestjs-drizzle-crud';
183
+ import { schema } from './db/schema';
184
+ import { UsersModule } from './users/users.module';
185
+
186
+ @Module({
187
+ imports: [
188
+ DrizzleCrudModule.forRoot({
189
+ dialect: 'postgresql',
190
+ connectionString: process.env.DATABASE_URL,
191
+ schema,
192
+ }),
193
+ UsersModule,
194
+ ],
195
+ })
196
+ export class AppModule {}
197
+ ```
198
+
199
+ The connection is created here, exposed globally, and closed automatically on
200
+ application shutdown (when the module built it from a `connectionString`).
201
+
202
+ ### 3. Create a service
203
+
204
+ ```typescript
205
+ // users/users.service.ts
206
+ import { SqlBaseCrudService } from '@avytrix/nestjs-drizzle-crud';
207
+ import type { User } from '../db/schema';
208
+
209
+ export interface CreateUserDto { name: string; email: string }
210
+ export interface UpdateUserDto { name?: string; email?: string }
211
+ export interface UserFilterDto { name?: string; email?: string }
212
+
213
+ export class UsersService extends SqlBaseCrudService<
214
+ User,
215
+ CreateUserDto,
216
+ UpdateUserDto,
217
+ UserFilterDto
218
+ > {}
219
+ ```
220
+
221
+ ### 4. Bind the service to a table
222
+
223
+ ```typescript
224
+ // users/users.module.ts
225
+ import { Module } from '@nestjs/common';
226
+ import { DrizzleCrudModule } from '@avytrix/nestjs-drizzle-crud';
227
+ import { users } from '../db/schema';
228
+ import { UsersController } from './users.controller';
229
+ import { UsersService } from './users.service';
230
+
231
+ @Module({
232
+ imports: [
233
+ DrizzleCrudModule.forFeature([{ service: UsersService, table: users }]),
234
+ ],
235
+ controllers: [UsersController],
236
+ })
237
+ export class UsersModule {}
238
+ ```
239
+
240
+ ### 5. Use it in a controller
241
+
242
+ ```typescript
243
+ // users/users.controller.ts
244
+ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query } from '@nestjs/common';
245
+ import { CreateUserDto, UpdateUserDto, UsersService } from './users.service';
246
+
247
+ @Controller('users')
248
+ export class UsersController {
249
+ constructor(private readonly users: UsersService) {}
250
+
251
+ // ?status=active&age[gte]=18&sortBy=-created_at&page=2&limit=20
252
+ @Get()
253
+ findAll(@Query() query: Record<string, unknown>) {
254
+ const q = this.users.parseQuery(query);
255
+ return this.users.findAll(q.filters, q.pagination, q.options);
256
+ }
257
+
258
+ @Get(':id')
259
+ find(@Param('id', ParseIntPipe) id: number) {
260
+ return this.users.find(id);
261
+ }
262
+
263
+ @Post()
264
+ create(@Body() dto: CreateUserDto) {
265
+ return this.users.create(dto);
266
+ }
267
+
268
+ @Put(':id')
269
+ update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
270
+ return this.users.update(id, dto);
271
+ }
272
+
273
+ @Delete(':id')
274
+ remove(@Param('id', ParseIntPipe) id: number) {
275
+ return this.users.delete(id);
276
+ }
277
+ }
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Configuration
283
+
284
+ ### `DrizzleCrudModule.forRoot(config)`
285
+
286
+ | Field | Type | Description |
287
+ |---|---|---|
288
+ | `dialect` | `'postgresql' \| 'mysql'` | **Required.** Database dialect. |
289
+ | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL only). |
290
+ | `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
291
+ | `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
292
+ | `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
293
+ | `defaults.timestamps` | `boolean` | Auto-manage `created_at`/`updated_at` for all entities (default `true`). |
294
+ | `defaults.pagination` | `{ defaultLimit?, maxLimit? }` | Pagination defaults (default `{ 20, 100 }`). Either key alone keeps the other's default. |
295
+ | `defaults.sortOrder` | `'asc' \| 'desc'` | Opt-in default sort: entities **without** an explicit `defaultSort` fall back to ordering by their `created_at` column in this direction. Omit it and `findAll` orders by the primary key only. |
296
+ | `sql` | `{ caseSensitive?, useReturning? }` | Dialect tuning. `useReturning` defaults to `true` for PostgreSQL, `false` for MySQL. `jsonSupport` and `enableFullTextSearch` are deprecated and do nothing. |
297
+ | `hooks` | `{ enableGlobalHooks?, subscribers? }` | Module-level write events on the `CrudEventBus`. See [Events](#events-and-global-hooks). |
298
+
299
+ > **Provide exactly one of `connectionString` or `db`.** If your tables have no
300
+ > `created_at`/`updated_at`/`deleted_at` columns, set
301
+ > `defaults: { softDelete: false, timestamps: false }`. The soft-delete and
302
+ > timestamp settings name the **key** in your Drizzle table object; with
303
+ > camelCase keys (`deletedAt: timestamp('deleted_at')`) set them per entity:
304
+ > `softDelete: { column: 'deletedAt' }`,
305
+ > `timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }`. A key the
306
+ > table does not have stops the app at startup, and the message names the key
307
+ > to use.
308
+
309
+ **Build the connection yourself (any dialect, recommended for MySQL):**
310
+
311
+ ```typescript
312
+ import { drizzle } from 'drizzle-orm/postgres-js';
313
+ import postgres from 'postgres';
314
+
315
+ DrizzleCrudModule.forRoot({
316
+ dialect: 'postgresql',
317
+ db: drizzle(postgres(process.env.DATABASE_URL!), { schema }),
318
+ });
319
+ ```
320
+
321
+ ### `DrizzleCrudModule.forRootAsync(options)`
322
+
323
+ ```typescript
324
+ DrizzleCrudModule.forRootAsync({
325
+ imports: [ConfigModule],
326
+ inject: [ConfigService],
327
+ useFactory: (cfg: ConfigService) => ({
328
+ dialect: 'postgresql',
329
+ connectionString: cfg.get('DATABASE_URL'),
330
+ schema,
331
+ }),
332
+ });
333
+ ```
334
+
335
+ ### `DrizzleCrudModule.forFeature(entities)`
336
+
337
+ Registers one or more services and binds each to its table. Per-entity overrides
338
+ go in `config`:
339
+
340
+ ```typescript
341
+ DrizzleCrudModule.forFeature([
342
+ { service: UsersService, table: users },
343
+ {
344
+ service: PostsService,
345
+ table: posts,
346
+ config: {
347
+ primaryKey: 'uuid',
348
+ softDelete: { enabled: true, column: 'deleted_at' },
349
+ // Default ORDER BY for findAll() when the caller passes no sortBy.
350
+ defaultSort: [
351
+ { column: 'position', order: 'asc' },
352
+ { column: 'created_at', order: 'desc' }, // tiebreaker
353
+ ],
354
+ },
355
+ },
356
+ ]);
357
+ ```
358
+
359
+ Anything in `config` overrides the project defaults for that entity. The shape is
360
+ [`SqlCrudConfig`](#sqlcrudconfig) minus `db`/`dialect`.
361
+
362
+ ---
363
+
364
+ ## Defining services
365
+
366
+ The minimal service is an empty class. Connection, dialect and defaults are
367
+ injected by the module:
368
+
369
+ ```typescript
370
+ export class UsersService extends SqlBaseCrudService<
371
+ User, CreateUserDto, UpdateUserDto, CrudFilter
372
+ > {}
373
+ ```
374
+
375
+ Add custom behaviour by overriding hooks (see [Lifecycle hooks](#lifecycle-hooks--validation))
376
+ or add your own methods:
377
+
378
+ ```typescript
379
+ export class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto, UserFilterDto> {
380
+ findByEmail(email: string) {
381
+ return this.findOne({ email } as Partial<User>);
382
+ }
383
+
384
+ protected async validateCreate(data: CreateUserDto): Promise<void> {
385
+ if (!data.email.includes('@')) throw new Error('Invalid email');
386
+ }
387
+ }
388
+ ```
389
+
390
+ ---
391
+
392
+ ## API reference
393
+
394
+ `SqlBaseCrudService<T, CreateDto = Partial<T>, UpdateDto = Partial<T>, FilterDto = Partial<T>>`
395
+
396
+ ### Read
397
+
398
+ | Method | Returns | Notes |
399
+ |---|---|---|
400
+ | `find(id, options?)` | `Promise<T \| null>` | By primary key. Skips soft-deleted rows. |
401
+ | `findOne(where, options?)` | `Promise<T \| null>` | Exact values, `null` for `IS NULL`, or operator objects. |
402
+ | `findAll(filters?, pagination?, options?)` | `Promise<{ data: T[]; total: number; page: number; limit: number }>` | See [Filtering](#filtering) / [Pagination](#pagination--sorting). With `withCount: false`, `total` is `null` and `hasMore` is set. |
403
+ | `findByCursor(filters?, pagination?, options?)` | `Promise<{ data: T[]; limit: number; nextCursor: string \| null; total?: number }>` | Keyset pages. See [Cursor pagination](#cursor-pagination). |
404
+ | `exists(id, options?)` | `Promise<boolean>` | |
405
+ | `count(filters?, options?)` | `Promise<number>` | |
406
+ | `countBy(column, filters?, options?)` | `Promise<{ value; count }[]>` | Grouped counts, largest first. See [Counts](#counts-and-grouped-counts). |
407
+ | `parseQuery(query, options?)` | `ParsedCrudQuery` | See [Query strings](#query-strings-parsequery). |
408
+ | `parseId(raw)` | `Promise<unknown>` | Parses a route id by the key's type (ParseIntPipe, ParseUUIDPipe, bigint, string). |
409
+
410
+ ### Write
411
+
412
+ | Method | Returns | Notes |
413
+ |---|---|---|
414
+ | `create(data, options?)` | `Promise<T>` | Runs `validateCreate` → `beforeCreate` → insert → `afterCreate`. |
415
+ | `update(id, data, options?)` | `Promise<T>` | Throws `EntityNotFoundException` if missing. |
416
+ | `delete(id, options?)` | `Promise<boolean>` | Hard delete. |
417
+ | `softDelete(id, options?)` | `Promise<boolean>` | Requires soft delete enabled. |
418
+ | `restore(id, options?)` | `Promise<T>` | Clears the soft-delete column. |
419
+ | `forceDelete(id, options?)` | `Promise<boolean>` | Hard delete, soft-deleted or not. |
420
+ | `upsert(data, upsert?, options?)` | `Promise<T>` | Insert or update by a unique key. See [Upsert](#upsert). |
421
+ | `attach(id, relation, ids, options?)` | `Promise<T>` | Link many-to-many rows. See [Relations on writes](#relations-on-writes). |
422
+ | `detach(id, relation, ids?, options?)` | `Promise<T>` | Unlink some, or all when `ids` is left out. |
423
+ | `sync(id, relation, ids, options?)` | `Promise<T>` | Make the links exactly `ids`. |
424
+
425
+ ### Bulk (run inside a transaction)
426
+
427
+ | Method | Returns |
428
+ |---|---|
429
+ | `massCreate(data[], options?)` | `Promise<T[]>` |
430
+ | `massUpdate(ids[], data, options?)` | `Promise<T[]>` |
431
+ | `massSoftDelete(ids[], options?)` | `Promise<boolean>` |
432
+ | `massRestore(ids[], options?)` | `Promise<T[]>` |
433
+ | `massDelete(ids[], options?)` | `Promise<boolean>` |
434
+ | `massForceDelete(ids[], options?)` | `Promise<boolean>` |
435
+ | `massUpsert(data[], upsert?, options?)` | `Promise<T[]>` |
436
+
437
+ ### Search
438
+
439
+ | Method | Returns |
440
+ |---|---|
441
+ | `fullTextSearch(term, columns, pagination?, options?)` | `Promise<{ data: T[]; total: number; page: number; limit: number }>` (PostgreSQL only) |
442
+
443
+ `findAll` and `count` also accept `options.search` for list-style search where the
444
+ normal filters, pagination, sorting, relation filters, and `total` count should
445
+ all stay in one query path:
446
+
447
+ ```typescript
448
+ await service.findAll(
449
+ {},
450
+ { page: 1, limit: 20, sortBy: 'name', sortOrder: 'asc' },
451
+ { search: { term: 'na', columns: ['name', 'code'] } },
452
+ );
453
+ ```
454
+
455
+ By default this searches the configured columns with `ILIKE '%term%'` joined by
456
+ `OR`. For PostgreSQL full-text matching, pass `mode: 'fullText'`.
457
+
458
+ ### `SqlOperationOptions`
459
+
460
+ ```typescript
461
+ interface SqlOperationOptions {
462
+ transaction?: any; // run within an existing transaction
463
+ select?: string[]; // return only these columns; 'region.code' narrows a relation
464
+ relations?: string[]; // eager-load these configured relations (see Relations)
465
+ search?: {
466
+ term: string; // user search term
467
+ columns: string[]; // table column keys to search
468
+ mode?: 'ilike' | 'fullText'; // default: 'ilike'
469
+ };
470
+ hooks?: { skipBefore?: boolean; skipAfter?: boolean };
471
+ lock?: 'update' | 'share' | 'none';
472
+ forNoKeyUpdate?: boolean;
473
+ withDeleted?: boolean; // also return soft-deleted rows
474
+ onlyDeleted?: boolean; // only soft-deleted rows: the trash view
475
+ skipAllowlists?: boolean; // trusted, server-side call
476
+ bulkMode?: 'set' | 'perItem'; // mass writes: one statement, or one call per item
477
+ counts?: string[] | Record<string, true | CrudFilter>; // _count per collection
478
+ context?: Record<string, unknown>; // what a function `scope` reads
479
+ skipScope?: boolean; // trusted call, without the tenant scope
480
+ skipEvents?: boolean; // no module-level events for this call
481
+ expectedVersion?: number; // optimistic locking: 409 when stale
482
+ expectedVersions?: Record<string, number>; // the same per id, for mass writes
483
+ }
484
+ ```
485
+
486
+ > `relations` eager-loads relations declared in the entity's config. See
487
+ > [Relations](#relations) and [Column selection](#column-selection).
488
+
489
+ ---
490
+
491
+ ## Filtering
492
+
493
+ `findAll(filters)` / `count(filters)` accept an object keyed by column name.
494
+ `null`/`undefined` values are skipped. A key that is not a column or a
495
+ configured relation, or an unknown operator, is a 400 rather than being
496
+ dropped, so a typo can never return every row.
497
+
498
+ ```typescript
499
+ await service.findAll({
500
+ status: 'active', // string: exact match (case-insensitive on text columns)
501
+ author_id: '1', // strings are fine on integer, uuid and enum columns
502
+ role: ['admin', 'editor'], // array: IN (...)
503
+ age: { gte: 18, lt: 65 }, // comparison operators
504
+ created_at: { gte: '2025-01-01' }, // date strings are parsed; an invalid date is a 400
505
+ name: { startsWith: 'jo' }, // escaped pattern: % and _ are literal
506
+ archived_at: { isNull: false }, // IS NOT NULL
507
+ or: [{ team: { name: 'Core' } }, { role: 'admin' }], // OR group, relations included
508
+ not: { tags: { name: 'internal' } }, // NOT, here over a collection
509
+ });
510
+ ```
511
+
512
+ **Operators** (inside an object value):
513
+
514
+ | Operator | SQL |
515
+ |---|---|
516
+ | `eq` | `=` (exact, case-sensitive) |
517
+ | `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` |
518
+ | `neq` | `<>` |
519
+ | `like` / `ilike` | `LIKE` / `ILIKE` (`LIKE` on MySQL), **pass your own `%` wildcards** |
520
+ | `in` / `notIn` | `IN (...)` / `NOT IN (...)` |
521
+ | `between` | `BETWEEN a AND b`, `[from, to]` |
522
+ | `isNull` / `isNotNull` | `true` / `"true"` gives `IS NULL` / `IS NOT NULL`, `false` / `"false"` the opposite |
523
+ | `startsWith` / `endsWith` / `contains` | escaped `LIKE`, case-insensitive unless `sql.caseSensitive` |
524
+ | `iStartsWith` / `iEndsWith` / `iContains` | escaped `ILIKE` (`LIKE` on MySQL), always case-insensitive |
525
+ | `arrayContains` / `arrayOverlaps` | PostgreSQL arrays, `@>` / `&&` |
526
+ | `jsonContains` | PostgreSQL `jsonb`, `@>` |
527
+
528
+ `and`, `or` and `not` group filter objects and nest. Each member is a full
529
+ filter, so it can hold column, relation and collection filters.
530
+
531
+ `findOne(where)` takes exact values, `null` for `IS NULL`, or an operator
532
+ object. If every value is `undefined`, or a key is not a column, it throws a 400
533
+ instead of returning an arbitrary row.
534
+
535
+ > A bare string value is an **exact** match. On a text column with
536
+ > `sql.caseSensitive` set to `false` (the default) it compares
537
+ > `lower(column) = lower(value)`, so the match is
538
+ > case-insensitive and `%` / `_` / `\` in the value are treated as literal
539
+ > characters, not wildcards. For partial or pattern matching, use the explicit
540
+ > `like` / `ilike` operators and supply your own wildcards, or `startsWith` /
541
+ > `endsWith` / `contains`, which escape the value for you.
542
+
543
+ ---
544
+
545
+ ## Query strings: parseQuery
546
+
547
+ `service.parseQuery(query)` (or `parseCrudQuery(query, table, options)`) turns
548
+ an HTTP query object into the arguments `findAll` takes, coercing every value by
549
+ its Drizzle column type. An unknown column, operator, relation or sort column is
550
+ a 400 before any SQL runs.
551
+
552
+ ```typescript
553
+ // GET /users?status=active&age[gte]=18&team[name]=Core&or[0][role]=admin&or[1][role]=owner
554
+ // &sortBy=team.name,-created_at&page=2&limit=20&fields=id,name&with=team&search=jo
555
+ const q = this.users.parseQuery(query);
556
+ return q.isCursor
557
+ ? this.users.findByCursor(q.filters, q.pagination, q.options)
558
+ : this.users.findAll(q.filters, q.pagination, q.options);
559
+ ```
560
+
561
+ Numbers, bigints, booleans and dates are coerced (a bad one is a 400), `numeric`
562
+ is checked and kept as an exact string, and text, uuid and enum stay strings.
563
+ `withDeleted` / `onlyDeleted` are only read with `allowTrash: true`. Type the
564
+ service's filter generic as `CrudFilter`.
565
+
566
+ ---
567
+
568
+ ## Allowlists
569
+
570
+ Per entity, in `forFeature` config. Unset lists allow everything; anything
571
+ outside a set list is a 400, in the service and in `parseQuery`:
572
+
573
+ ```typescript
574
+ config: {
575
+ filterableColumns: ['status', 'role', 'team.name'],
576
+ sortableColumns: ['name', 'created_at', 'team.name'],
577
+ selectableColumns: ['id', 'name', 'email', 'team.name'], // never password_hash
578
+ searchableColumns: ['name', 'email'],
579
+ loadableRelations: ['team'],
580
+ writableColumns: ['name', 'email', 'role'],
581
+ }
582
+ ```
583
+
584
+ Set them on every entity a client can reach. Without `writableColumns`, create
585
+ and update ignore the soft delete column, and update ignores the primary key and
586
+ `createdAt`. `skipAllowlists: true` skips the lists for a trusted call.
587
+
588
+ ---
589
+
590
+ ## Pagination & sorting
591
+
592
+ ```typescript
593
+ await service.findAll(
594
+ {},
595
+ { page: 2, limit: 25, sortBy: 'created_at', sortOrder: 'desc' },
596
+ );
597
+ // → { data: [...], total: 240, page: 2, limit: 25 }
598
+ ```
599
+
600
+ `limit` is capped at `pagination.maxLimit`. `sortOrder` defaults to `'asc'`.
601
+ The primary key is always the last `ORDER BY` column, so rows that tie on the
602
+ sort column never repeat or go missing between pages.
603
+
604
+ `sortBy` also takes a list, ascending unless prefixed with `-`, and a dotted
605
+ entry sorts by a belongs-to column:
606
+
607
+ ```typescript
608
+ await service.findAll({}, { sortBy: ['team.name', '-created_at'] });
609
+ // LEFT JOIN teams ... ORDER BY teams.name ASC, created_at DESC, id ASC
610
+ ```
611
+
612
+ ### Default sort
613
+
614
+ When the caller passes no `sortBy`, `findAll` falls back to the entity's
615
+ `defaultSort`: an ordered list of columns used as the `ORDER BY` (primary
616
+ sort first, then tiebreakers). An explicit `sortBy` **replaces** the default
617
+ entirely. Per-column `order` defaults to `'asc'`; unknown columns are skipped.
618
+
619
+ ```typescript
620
+ // config.defaultSort: [{ column: 'position', order: 'asc' }, { column: 'created_at', order: 'desc' }]
621
+ await service.findAll(); // ORDER BY position ASC, created_at DESC, id ASC
622
+ await service.findAll({}, { sortBy: 'name' }); // ORDER BY name DESC, id ASC (default ignored)
623
+ ```
624
+
625
+ Set it per entity in [`forFeature`](#drizzlecrudmoduleforfeatureentities) config (or via
626
+ `@CrudService`). Alternatively, set [`defaults.sortOrder`](#drizzlecrudmoduleforrootconfig)
627
+ in `forRoot` to apply a `created_at` fallback to every entity that doesn't define its own.
628
+
629
+ ---
630
+
631
+ ## Cursor pagination
632
+
633
+ `findByCursor` pages with `WHERE (sort columns) > (last row)` instead of
634
+ `OFFSET`, so deep pages stay fast and rows inserted earlier do not shift the
635
+ next page:
636
+
637
+ ```typescript
638
+ let cursor: string | null = null;
639
+ do {
640
+ const page = await service.findByCursor({ status: 'active' }, { sortBy: ['-created_at'], limit: 50, cursor });
641
+ handle(page.data);
642
+ cursor = page.nextCursor; // null on the last page
643
+ } while (cursor);
644
+ ```
645
+
646
+ The cursor is opaque (base64url of the sort values plus the primary key). Pass
647
+ `count: true` for a `total`. A cursor for a different sort is a 400.
648
+
649
+ ---
650
+
651
+ ## Relations
652
+
653
+ Three kinds of relation are supported, declared in the entity's `forFeature`
654
+ config under `relations` and keyed by relation name. Any of them can nest.
655
+
656
+ | Type | SQL | Extra queries | Result |
657
+ | --- | --- | --- | --- |
658
+ | `belongsTo` (default) | `LEFT JOIN` in the main query | 0 | object, or `null` |
659
+ | `hasMany` | `SELECT ... WHERE fk IN (parent keys)` | 1 per relation, for the whole page | array, `[]` when empty |
660
+ | `manyToMany` | `SELECT ... FROM join JOIN target ... WHERE fk IN (parent keys)` | 1 per relation, for the whole page | array, `[]` when empty |
661
+ | nested (any shape) | as above, one level down | 1 per relation per level | as above |
662
+
663
+ None of them runs a query per row, so eager loading a page of 50 rows costs the
664
+ same number of queries as a page of 5. That is the N+1 fix: ask for the relation
665
+ instead of looping.
666
+
667
+ ```typescript
668
+ import { cities, countries, countryTags, regions, states, tags } from './db/schema';
669
+
670
+ DrizzleCrudModule.forFeature([
671
+ {
672
+ service: CountriesService,
673
+ table: countries,
674
+ config: {
675
+ relations: {
676
+ // belongs-to: countries.region_id -> regions.id (`references` defaults to 'id')
677
+ region: { table: regions, localKey: 'region_id', references: 'id' },
678
+ // has-many: states.country_id -> countries.id (`parentKey` defaults to the primary key)
679
+ states: {
680
+ table: states,
681
+ type: 'hasMany',
682
+ foreignKey: 'country_id',
683
+ orderBy: [{ column: 'name', order: 'asc' }],
684
+ // nested: eager-load with relations: ['states', 'states.cities']
685
+ relations: {
686
+ cities: { table: cities, type: 'hasMany', foreignKey: 'state_id' },
687
+ },
688
+ },
689
+ // many-to-many: countries.id <- country_tags -> tags.id
690
+ tags: {
691
+ table: tags,
692
+ type: 'manyToMany',
693
+ through: countryTags,
694
+ throughLocalKey: 'country_id',
695
+ throughForeignKey: 'tag_id',
696
+ },
697
+ },
698
+ },
699
+ },
700
+ ]);
701
+ ```
702
+
703
+ A relation missing its required keys throws when the service is constructed,
704
+ nested relations included, with the full path in the message. A typo shows up at
705
+ startup instead of as an empty array later.
706
+
707
+ ### 1. Eager loading
708
+
709
+ Pass `relations` in the operation options:
710
+
711
+ ```typescript
712
+ await countries.find(1, { relations: ['region', 'states', 'states.cities', 'tags'] });
713
+ // → {
714
+ // id: 1, name: 'India', region_id: 3,
715
+ // region: { id: 3, name: 'Asia', code: 'AS' },
716
+ // states: [{ id: 10, name: 'Karnataka', country_id: 1, cities: [...] }, ...],
717
+ // tags: [{ id: '...', name: 'himalayan' }],
718
+ // }
719
+
720
+ // 5 queries for the whole page: data, count, states, cities, tags
721
+ await countries.findAll({}, { page: 1, limit: 50 }, {
722
+ relations: ['region', 'states', 'states.cities', 'tags'],
723
+ });
724
+ ```
725
+
726
+ A dotted path loads a relation of a relation, and asking for `states.cities`
727
+ alone pulls in `states` too. An unmatched belongs-to relation is `null`, never an
728
+ object full of nulls. A parent with no children is `[]`, so callers can iterate
729
+ without a guard. Relations load concurrently, soft-deleted rows are excluded when
730
+ the related table has the configured column, and the join table of a
731
+ many-to-many never appears in the result.
732
+
733
+ ### 2. Filtering by related columns
734
+
735
+ Use the relation name as a filter key with a nested object of the **related
736
+ table's** columns. Supports the same [operators](#filtering) as normal filters:
737
+
738
+ ```typescript
739
+ // belongs-to: filtered through the join
740
+ await cities.findAll({ state: { name: 'Karnataka' } });
741
+ await cities.findAll({ name: { ilike: 'B%' }, state: { country_id: 3 } });
742
+
743
+ // has-many: countries.id IN (SELECT country_id FROM states WHERE ...)
744
+ await countries.findAll({ states: { name: 'Kerala' } });
745
+
746
+ // many-to-many: through the join table, still a subquery
747
+ await countries.findAll({ tags: { name: 'himalayan' } });
748
+ ```
749
+
750
+ A collection is never joined, on a filter or on an eager load. One country with
751
+ four states would become four rows, `count(*)` would return 4 instead of 1, and
752
+ `limit` would cut a country in half. The subquery and the batched load keep one
753
+ row per parent, so `total` and pagination stay correct.
754
+
755
+ A filter can go on through the related table's own relations, at any depth.
756
+ Below the first joined belongs-to, each level is an `EXISTS` subquery:
757
+
758
+ ```typescript
759
+ await cities.findAll({ state: { country: { code: 'IN' } } }); // ?state[country][code]=IN
760
+ ```
761
+
762
+ A collection takes `some` (the default), `none` or `every`, which compile to
763
+ `IN` / `NOT EXISTS` subqueries and work inside `or` / `not` groups and `count`:
764
+
765
+ ```typescript
766
+ await authors.findAll({ posts: { none: { status: 'draft' } } });
767
+ await authors.findAll({ posts: { every: { status: 'published' } } }); // also authors with no posts
768
+ await authors.findAll({ posts: { some: {} } }); // has at least one
769
+ ```
770
+
771
+ > `orderBy` sorts a collection but does not limit it per parent, and a join
772
+ > table's own columns are not returned. Model a join table that carries data as
773
+ > an entity with two belongs-to relations.
774
+
775
+ ---
776
+
777
+ ## Column selection
778
+
779
+ Pass `options.select` and the query stops being `SELECT *`. A dotted entry
780
+ narrows an eager-loaded relation:
781
+
782
+ ```typescript
783
+ await countries.findAll({}, { page: 1, limit: 20 }, { select: ['id', 'name'] });
784
+ // SELECT "id", "name" FROM "countries" ...
785
+
786
+ await countries.findAll({}, { page: 1, limit: 20 }, {
787
+ relations: ['region', 'states', 'states.cities'],
788
+ select: ['id', 'name', 'region.code', 'states.name', 'states.cities.name'],
789
+ });
790
+ // → { id, name, region: { code }, states: [{ name, country_id, id, cities: [{ name, state_id }] }] }
791
+ ```
792
+
793
+ Rules:
794
+
795
+ - A dotted entry only applies when the relation is also in `options.relations`.
796
+ Paths can be any depth: `'states.cities.name'`.
797
+ - Names that don't match a real column are ignored, not rejected.
798
+ - If every entry is dotted, the base row is returned in full, so
799
+ `select: ['region.name']` reads as "narrow the region".
800
+ - The parent key, each child's foreign key and any key a nested level needs are
801
+ added back automatically, because the grouping needs them.
802
+
803
+ `select` applies to `find`, `findOne` and `findAll`. Write methods return the
804
+ full row through `RETURNING`.
805
+
806
+ ---
807
+
808
+ ## Primary keys (serial / uuid)
809
+
810
+ Each entity names its primary key via `primaryKey` (the column key, default
811
+ `id`). Both auto-increment and UUID keys are supported, and the type comes from
812
+ the Drizzle column. `primaryKeyType` is deprecated and has no effect.
813
+
814
+ ```typescript
815
+ // serial / auto-increment (default)
816
+ { service: UsersService, table: users } // primaryKey 'id'
817
+
818
+ // UUID
819
+ {
820
+ service: TagsService,
821
+ table: tags, // e.g. uuid('id').primaryKey().defaultRandom()
822
+ }
823
+ ```
824
+
825
+ On PostgreSQL the created row (including a DB-generated UUID) is returned via
826
+ `RETURNING`. On MySQL (no `RETURNING`), provide the UUID in your create payload
827
+ so the row can be re-read. Auto-increment keys use the driver's `insertId`.
828
+
829
+ > With UUID keys, route params are strings, so don't apply
830
+ > `ParseIntPipe` in your controller.
831
+
832
+ ---
833
+
834
+ ## Soft delete
835
+
836
+ Enable per-project via `defaults.softDelete` or per-entity via `forFeature` config:
837
+
838
+ ```typescript
839
+ { service: UsersService, table: users, config: { softDelete: { enabled: true, column: 'deleted_at' } } }
840
+ ```
841
+
842
+ - `softDelete(id)` sets the column to the current timestamp.
843
+ - `restore(id)` sets it back to `null`.
844
+ - `find`/`findOne`/`findAll`/`count` automatically exclude soft-deleted rows,
845
+ and a soft-deleted belongs-to parent comes back as `null`.
846
+ - `column` is the key in your Drizzle table object (`deletedAt` for
847
+ `deletedAt: timestamp('deleted_at')`). A key the table lacks fails at startup.
848
+ - The trash view: pass `onlyDeleted: true` (or `withDeleted: true` for both)
849
+ to any read, e.g. `findAll({}, {}, { onlyDeleted: true })`.
850
+ - `forceDelete(id)` / `massForceDelete(ids)` hard-delete rows whether they are
851
+ in the trash or not. `delete(id)` answers 404 for a row in the trash.
852
+
853
+ ---
854
+
855
+ ## Timestamps
856
+
857
+ There are two ways to manage `created_at` / `updated_at`.
858
+
859
+ ### Package-managed (convenient)
860
+
861
+ Enable `defaults.timestamps` (or per-entity `timestamps`). On `create()` the
862
+ service stamps both columns; on `update()`/`softDelete()` it stamps `updated_at`:
863
+
864
+ ```typescript
865
+ DrizzleCrudModule.forRoot({
866
+ dialect: 'postgresql',
867
+ connectionString: process.env.DATABASE_URL,
868
+ schema,
869
+ defaults: { timestamps: true }, // uses columns created_at / updated_at
870
+ });
871
+ ```
872
+
873
+ > Caveat: this uses the **application** clock and only applies to writes that go
874
+ > **through the package**. Raw SQL, migrations or other code paths won't set
875
+ > the values.
876
+
877
+ ### Schema/DB-managed (recommended)
878
+
879
+ For authoritative timestamps (database time, every write path), define them in
880
+ your Drizzle schema and **leave the package's `timestamps` disabled**:
881
+
882
+ ```typescript
883
+ import { timestamp } from 'drizzle-orm/pg-core';
884
+
885
+ created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
886
+ updated_at: timestamp('updated_at', { withTimezone: true })
887
+ .defaultNow()
888
+ .notNull()
889
+ .$onUpdate(() => new Date()),
890
+ ```
891
+
892
+ `defaultNow()` lets the database set `created_at` on insert; `$onUpdate()` makes
893
+ Drizzle bump `updated_at` on every update (including the package's own
894
+ `update()`). For `updated_at` that's authoritative even for raw SQL, add a
895
+ Postgres trigger / MySQL `ON UPDATE CURRENT_TIMESTAMP`.
896
+
897
+ ---
898
+
899
+ ## Bulk operations
900
+
901
+ ```typescript
902
+ await service.massCreate([dto1, dto2, dto3]);
903
+ await service.massUpdate([1, 2, 3], { status: 'archived' });
904
+ await service.massSoftDelete([1, 2, 3]);
905
+ ```
906
+
907
+ Each bulk method is one statement for the whole set (plus one `SELECT` that
908
+ checks the ids exist), inside a single transaction: `massUpdate` over 100 ids is
909
+ two queries. If any item fails, a `BulkOperationException` (400) is thrown and
910
+ the transaction rolls back. The failing items are found by replaying the batch
911
+ inside a savepoint that is rolled back, and the response body lists only them:
912
+
913
+ ```json
914
+ { "statusCode": 400, "error": "Bad Request",
915
+ "message": "Mass create errors. 1 error(s) occurred.",
916
+ "errors": [{ "index": 1, "status": 409, "message": "users with email 'a@x.io' already exists" }] }
917
+ ```
918
+
919
+ `bulkMode: 'perItem'` runs the single-row method per item instead, as in 3.3.
920
+
921
+ ---
922
+
923
+ ## Upsert
924
+
925
+ ```typescript
926
+ await users.upsert(
927
+ { email: 'ada@example.com', name: 'Ada' },
928
+ { conflictTarget: 'email' }, // update: [...] limits the overwritten columns
929
+ );
930
+ await users.massUpsert(rows, { conflictTarget: 'email' });
931
+ ```
932
+
933
+ `ON CONFLICT DO UPDATE` on PostgreSQL, `ON DUPLICATE KEY UPDATE` on MySQL. A
934
+ clash on another unique key is a 409, bad input a 400.
935
+
936
+ ---
937
+
938
+ ## Relations on writes
939
+
940
+ Writes take `relations` and `counts` like reads, and load them with the same
941
+ batched loaders, one query per relation for the whole batch:
942
+
943
+ ```typescript
944
+ const post = await posts.create(dto, { relations: ['author', 'tags'] });
945
+ await posts.massCreate(rows, { relations: ['author'] }); // 1 INSERT + 1 SELECT
946
+ ```
947
+
948
+ A create body can also write relations listed in `writableRelations`, inside one
949
+ transaction: an array for a has-many, an object for a belongs-to, ids or objects
950
+ for a many-to-many.
951
+
952
+ ```typescript
953
+ // forFeature config: writableRelations: ['posts', 'tags']
954
+ await authors.create({ name: 'Ada', posts: [{ title: 'One' }, { title: 'Two' }] });
955
+ await posts.create({ title: 'Tagged', tags: [1, { name: 'new' }] });
956
+ ```
957
+
958
+ Many-to-many links change with set-based `attach`, `detach` and `sync`, which
959
+ return the row with the relation loaded:
960
+
961
+ ```typescript
962
+ await posts.attach(1, 'tags', [2, 3]);
963
+ await posts.detach(1, 'tags', [3]); // or detach(1, 'tags') for all
964
+ await posts.sync(1, 'tags', [2]); // exactly these
965
+ ```
966
+
967
+ ---
968
+
969
+ ## Controller factory
970
+
971
+ `createCrudController(Service, options)` builds a controller with list (offset,
972
+ or keyset with `?cursor=`), `GET /trash`, get, create, update, delete,
973
+ `POST /:id/soft-delete`, `POST /:id/restore` and, when you turn them on, the bulk
974
+ routes. `:id` is parsed from the primary key column: `ParseIntPipe` for an
975
+ integer, `ParseUUIDPipe` for a uuid.
976
+
977
+ ```typescript
978
+ @Controller('projects')
979
+ export class ProjectsController extends createCrudController(ProjectsService, {
980
+ dto: { create: CreateProjectDto, update: UpdateProjectDto },
981
+ routes: { bulkCreate: true, delete: { decorators: [UseGuards(AdminGuard)] } },
982
+ decorators: [UseGuards(AuthGuard)],
983
+ deleteMode: 'soft',
984
+ }) {}
985
+ ```
986
+
987
+ ---
988
+
989
+ ## OpenAPI (Swagger)
990
+
991
+ `@ApiCrudList(source)` documents a list route: `page`, `limit`, `sortBy`,
992
+ `cursor`, `withCount`, `fields`, `with`, `counts`, `search` and one parameter per
993
+ filterable column, plus the page envelope. `@ApiCrudQuery(source)` adds only the
994
+ parameters. `@nestjs/swagger` is an optional peer, loaded only when a decorator
995
+ runs.
996
+
997
+ ```typescript
998
+ @Get()
999
+ @ApiCrudList({ table: countries, ...countriesConfig })
1000
+ findAll(@Query() query: Record<string, unknown>) { ... }
1001
+ ```
1002
+
1003
+ ---
1004
+
1005
+ ## Multi-tenancy: scope
1006
+
1007
+ A `scope` is ANDed into every read, count and write, bulk and upsert included,
1008
+ and written onto every row a create inserts. Another tenant's id is a 404.
1009
+
1010
+ ```typescript
1011
+ // forFeature config
1012
+ scope: (ctx) => ({ tenant_id: ctx.tenantId }),
1013
+
1014
+ // per request, in a NestModule
1015
+ consumer
1016
+ .apply(CrudContext.middleware((req) => ({ tenantId: req.user?.tenantId })))
1017
+ .forRoutes('*');
1018
+
1019
+ // or per call
1020
+ await docs.findAll({}, {}, { context: { tenantId: 7 } });
1021
+ ```
1022
+
1023
+ A missing context value is a 403 (`MissingScopeException`), never an unscoped
1024
+ query. A resolver that returns `null`, or `skipScope: true`, runs unscoped on
1025
+ purpose.
1026
+
1027
+ ---
1028
+
1029
+ ## Events and global hooks
1030
+
1031
+ With `hooks.enableGlobalHooks: true`, every service raises a before and an after
1032
+ event on the injectable `CrudEventBus` for each write. A bulk call raises one
1033
+ event with `bulk: true` and every id.
1034
+
1035
+ ```typescript
1036
+ DrizzleCrudModule.forRoot({
1037
+ dialect: 'postgresql', connectionString,
1038
+ hooks: { enableGlobalHooks: true },
1039
+ });
1040
+
1041
+ @Injectable()
1042
+ export class Audit implements OnModuleInit {
1043
+ constructor(private readonly bus: CrudEventBus) {}
1044
+ onModuleInit() {
1045
+ this.bus.subscribe({
1046
+ afterWrite: (e) => this.log(e.entity, e.operation, e.ids),
1047
+ });
1048
+ }
1049
+ }
1050
+ ```
1051
+
1052
+ A before listener that throws stops the write; an after listener that throws
1053
+ inside a transaction rolls it back. The per-service hooks run as before.
1054
+
1055
+ ---
1056
+
1057
+ ## Optimistic locking
1058
+
1059
+ ```typescript
1060
+ // forFeature config
1061
+ versionColumn: 'version',
1062
+
1063
+ await docs.update(1, { title: 'New', version: 3 }); // 409 if the row is past 3
1064
+ await docs.delete(1, { expectedVersion: 4 });
1065
+ await docs.massUpdate([1, 2], data, { expectedVersions: { '1': 4, '2': 7 } });
1066
+ ```
1067
+
1068
+ The version starts at 1 and goes up on every update, soft delete, restore,
1069
+ upsert conflict and bulk write. A stale one is a 409 `StaleVersionException`.
1070
+
1071
+ ---
1072
+
1073
+ ## Counts and grouped counts
1074
+
1075
+ ```typescript
1076
+ await authors.findAll({}, {}, { counts: ['posts'] });
1077
+ // [{ id: 1, name: 'Ann', _count: { posts: 10 } }, ...]
1078
+ await authors.findAll({}, {}, { counts: { posts: { status: 'published' } } });
1079
+
1080
+ await posts.countBy('status'); // [{ value: 'draft', count: 15 }, ...]
1081
+ await posts.countBy('author.name', { status: 'published' });
1082
+ ```
1083
+
1084
+ Each count is one `GROUP BY` query for the whole result, never one per row, and
1085
+ skips soft-deleted related rows. `findAll(filters, { withCount: false })` skips
1086
+ the `COUNT(*)`: `total` is `null` and `hasMore` tells you if another page follows.
1087
+
1088
+ ---
1089
+
1090
+ ## Transactions
1091
+
1092
+ ```typescript
1093
+ await service.executeSqlTransaction(async (tx) => {
1094
+ const user = await service.create(userDto, { transaction: tx });
1095
+ await profileService.create({ userId: user.id }, { transaction: tx });
1096
+ });
1097
+ ```
1098
+
1099
+ Pass `{ transaction: tx }` in `options` to any method to enlist it.
1100
+
1101
+ ---
1102
+
1103
+ ## Full-text search (PostgreSQL)
1104
+
1105
+ ```typescript
1106
+ const { data, total } = await service.fullTextSearch(
1107
+ 'john doe',
1108
+ ['name', 'email', 'bio'],
1109
+ { page: 1, limit: 20 },
1110
+ );
1111
+ ```
1112
+
1113
+ Builds `to_tsvector(...) @@ plainto_tsquery(...)` across the given columns and
1114
+ orders by `ts_rank`. Throws if the dialect is not `postgresql`.
1115
+
1116
+ For paginated list endpoints, prefer `findAll(..., ..., { search })` so search is
1117
+ combined with filters and the count query. Use `fullTextSearch` directly when
1118
+ you want ranked full-text results as the primary operation.
1119
+
1120
+ ---
1121
+
1122
+ ## Lifecycle hooks & validation
1123
+
1124
+ Override any of these `protected` methods in your service (all are optional;
1125
+ defaults are no-op / pass-through):
1126
+
1127
+ ```typescript
1128
+ protected validateCreate(data: CreateDto): Promise<void>
1129
+ protected validateUpdate(id: any, data: UpdateDto): Promise<void>
1130
+ protected mapCreateDtoToEntity(data: CreateDto): Record<string, any>
1131
+ protected mapUpdateDtoToEntity(data: UpdateDto): Record<string, any>
1132
+
1133
+ protected beforeCreate(data: CreateDto): Promise<CreateDto>
1134
+ protected afterCreate(entity: T): Promise<void>
1135
+ protected beforeUpdate(id: any, data: UpdateDto): Promise<UpdateDto>
1136
+ protected afterUpdate(entity: T): Promise<void>
1137
+ protected beforeDelete(id: any): Promise<void>
1138
+ protected afterDelete(id: any): Promise<void>
1139
+ protected beforeSoftDelete(id: any): Promise<void>
1140
+ protected afterSoftDelete(id: any): Promise<void>
1141
+ protected beforeRestore(id: any): Promise<void>
1142
+ protected afterRestore(entity: T): Promise<void>
1143
+ ```
1144
+
1145
+ `mapCreateDtoToEntity` / `mapUpdateDtoToEntity` transform the incoming DTO into the
1146
+ row to persist (default returns a shallow copy). When `timestamps` is enabled, the
1147
+ service stamps `created_at`/`updated_at` automatically.
1148
+
1149
+ ---
1150
+
1151
+ ## Testing
1152
+
1153
+ ```typescript
1154
+ import { TestCrudFactory } from '@avytrix/nestjs-drizzle-crud';
1155
+
1156
+ const mockDb = TestCrudFactory.createMockDb();
1157
+ const mockTable = TestCrudFactory.createMockTable();
1158
+ const service = TestCrudFactory.createTestService(UsersService, mockDb, mockTable);
1159
+ ```
1160
+
1161
+ `TestCrudFactory` provides `createMockDb()`, `createMockTable()`,
1162
+ `createMockEntity()` and `createTestService()` for unit tests without a database.
1163
+
1164
+ ---
1165
+
1166
+ ## For AI agents / LLM tools
1167
+
1168
+ ### Point your agent at the rules
1169
+
1170
+ `AGENT.md` ships inside the package and holds the rules agents should follow
1171
+ when they use it. Copy the pointer template to the filename your tool reads:
1172
+
1173
+ ```bash
1174
+ cp node_modules/@avytrix/nestjs-drizzle-crud/AGENTS.template.md ./AGENTS.md # Codex, Cursor, Copilot, Zed, Cline, Windsurf
1175
+ cp node_modules/@avytrix/nestjs-drizzle-crud/CLAUDE.template.md ./CLAUDE.md # Claude Code
1176
+ # Gemini CLI reads ./GEMINI.md, Aider reads ./CONVENTIONS.md
1177
+ ```
1178
+
1179
+ Each template is one `@node_modules/@avytrix/nestjs-drizzle-crud/AGENT.md` line, so an
1180
+ upgrade updates the rules with it and you never hold a stale copy.
1181
+
1182
+ ### Or run the MCP server
1183
+
1184
+ ```bash
1185
+ npx -p @avytrix/nestjs-drizzle-crud nestjs-drizzle-crud-mcp
1186
+ ```
1187
+
1188
+ A stdio MCP server, zero dependencies, that serves this package's docs to any MCP
1189
+ client through three tools: `list_docs`, `get_doc` (`agent-guide` / `readme` /
1190
+ `changelog`, optionally one section) and `search_docs`. Register it the usual way:
1191
+
1192
+ ```json
1193
+ {
1194
+ "mcpServers": {
1195
+ "nestjs-drizzle-crud": {
1196
+ "command": "npx",
1197
+ "args": ["-y", "-p", "@avytrix/nestjs-drizzle-crud", "nestjs-drizzle-crud-mcp"]
1198
+ }
1199
+ }
1200
+ }
1201
+ ```
1202
+
1203
+ That shape works for Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`),
1204
+ Windsurf, Gemini CLI and Cline. VS Code and Copilot use `.vscode/mcp.json` with
1205
+ the entries under `servers`; Codex uses `[mcp_servers.*]` in
1206
+ `~/.codex/config.toml`; Zed uses a `context_servers` entry. The server is
1207
+ read-only and never touches your database. Full setup:
1208
+ [crud.avytrix.dev/docs/ai-agents](https://crud.avytrix.dev/docs/ai-agents).
1209
+
1210
+ ### Facts for code generation
1211
+
1212
+ Use these facts instead of guessing.
1213
+
1214
+ **Package:** `nestjs-drizzle-crud` · **Peers:** `@nestjs/common`, `@nestjs/core`, `drizzle-orm`, `reflect-metadata`; optional `postgres` (PG) / `@nestjs/swagger` (for `ApiCrudList`); `mysql2` is installed by you for MySQL, not a peer.
1215
+
1216
+ **Setup is two steps and no per-service connection wiring:**
1217
+ 1. `DrizzleCrudModule.forRoot({ dialect, connectionString | db, schema, defaults })` once in `AppModule`.
1218
+ 2. `DrizzleCrudModule.forFeature([{ service, table, config? }])` in each feature module.
1219
+
1220
+ **A service is an empty subclass. Do NOT inject the db or pass `dialect`/`db`:**
1221
+ ```typescript
1222
+ export class XService extends SqlBaseCrudService<X, CreateXDto, UpdateXDto, XFilterDto> {}
1223
+ ```
1224
+
1225
+ **Rules / gotchas:**
1226
+ - Generics order: `SqlBaseCrudService<Entity, CreateDto, UpdateDto, FilterDto>`. All but `Entity` default to `Partial<Entity>`.
1227
+ - Do **not** add an `@Inject('DRIZZLE_DB')` constructor: `forFeature` constructs the service for you. A constructor that calls `super({...})` is the old manual pattern and is not needed.
1228
+ - The table is passed in `forFeature`, **not** in the service.
1229
+ - If tables lack timestamp/soft-delete columns, set `defaults: { softDelete: false, timestamps: false }`. The settings name table **keys** (`deletedAt`, not `deleted_at`, for a camelCase schema), and a missing key fails at startup.
1230
+ - `findAll` returns `{ data, total, page, limit }`, not a bare array.
1231
+ - Filter operators live inside an object value: `{ age: { gte: 18 } }`. `like`/`ilike` require caller-supplied `%` wildcards; `startsWith`/`endsWith`/`contains` escape the value; a bare string is exact match. Also `notIn`, `between`, the `i` pattern forms and, on PostgreSQL, `arrayContains`/`arrayOverlaps`/`jsonContains`. `or`/`and`/`not` group filters and nest. An unknown filter key or operator is a 400; `{ isNull: false }` is `IS NOT NULL`.
1232
+ - In a controller, `service.parseQuery(@Query() query)` builds filters, pagination and options from the query string; use `findByCursor` when `isCursor` is true. Set the allowlists (`filterableColumns`, `sortableColumns`, `selectableColumns`, `searchableColumns`, `loadableRelations`, `writableColumns`) on any entity a client can reach.
1233
+ - Soft-deleted rows: `onlyDeleted: true` / `withDeleted: true` on any read; `forceDelete(id)` purges. Bulk methods are one statement per set; `upsert(dto, { conflictTarget })` exists.
1234
+ - `delete`/`softDelete` return `boolean`; `update`/`restore` return the entity and throw `EntityNotFoundException` when missing.
1235
+ - Postgres error codes are translated for you: `23505` unique -> 409, `22001`/`23514`/`22003`/`22P02`/`22007`/`22008` bad value -> 400 (reads included), `23503` on create/update (missing FK target) -> 400 `ForeignKeyViolationException`, `23503` on delete (still referenced) -> 409 `EntityInUseException`.
1236
+ - Relations on writes: `create`/`update`/`upsert`/mass writes take `relations` and `counts`. Nested create only for relations in `writableRelations`; many-to-many links change with `attach`/`detach`/`sync`.
1237
+ - Controllers: prefer `createCrudController(Service, { dto, routes, decorators })` over hand-written CRUD routes. The id pipe follows the primary key.
1238
+ - Multi-tenant data: set `scope: (ctx) => ({ tenant_id: ctx.tenantId })` and fill the context with `CrudContext.middleware`. Do not add `tenant_id` filters by hand.
1239
+ - `hooks.enableGlobalHooks: true` + `CrudEventBus.subscribe` for audit and cache busting; one event per bulk call.
1240
+ - `versionColumn` for optimistic locking: a stale `version` in the body or `expectedVersion` is a 409.
1241
+ - `options.counts: ['posts']` gives `_count`; `countBy(column)` gives grouped counts; `withCount: false` skips `COUNT(*)`.
1242
+ - Relations: declare in forFeature `config.relations` and eager-load via `options.relations: ['relName']`, where a dotted path nests (`['states', 'states.cities']`, `['state.country']`; the parent path is pulled in automatically). Belongs-to is `{ table, localKey, references? }`, LEFT JOINed into the main query, resolving to an object or `null`. Has-many is `{ table, type: 'hasMany', foreignKey, parentKey?, orderBy? }`. Many-to-many is `{ table, type: 'manyToMany', through, throughLocalKey, throughForeignKey, references?, parentKey?, orderBy? }`. Nested relations live in a `relations` map on the relation. Every collection and every nested level costs exactly one extra query for the whole result set, never one per row. Filter via `findAll({ relName: { col: value } })` with the same operators; collection filters compile to `parentKey IN (SELECT ... WHERE ...)`, so `total` stays correct. Filters go through relation paths at any depth (`{ state: { country: { code: 'IN' } } }`), and collections take `some` / `none` / `every` (`{ posts: { none: { status: 'draft' } } }`), compiled to subqueries.
1243
+ - `options.select` narrows the projection and takes dotted relation paths at any depth: `['id', 'name', 'region.code', 'states.cities.name']`. Unknown names are ignored; an all-dotted list leaves the base row complete; the parent key, the child foreign key and any nested key are re-added automatically.
1244
+ - Primary keys: `primaryKey` (default `'id'`) per entity in forFeature config (`primaryKeyType` is deprecated and does nothing). UUID works on Postgres via RETURNING; with uuid keys, route params are strings, so don't use `ParseIntPipe`.
1245
+ - Full-text search is PostgreSQL-only.
1246
+ - Exports (3.5): `createCrudController`, `CrudContext`, `CrudEventBus`, `ApiCrudQuery`, `ApiCrudList`, `MissingScopeException`, `StaleVersionException`, and the types `CrudControllerOptions`, `CrudEvent`, `CrudSubscriber`, `CrudScope`, `CollectionFilter`, `CountByRow`, `CrudPage`, `UncountedCrudPage`, `RelationCounts`.
1247
+ - Exports: `SqlBaseCrudService`, `DrizzleCrudModule`, `DRIZZLE_DB`, `DRIZZLE_CRUD_CONFIG`, `TestCrudFactory`, exceptions (`EntityNotFoundException`, `DuplicateEntityException`, `ForeignKeyViolationException`, `EntityInUseException`, `ValidationFailedException`, `BulkOperationException`, plus the deprecated `DatabaseConnectionException` and `TransactionException`), and types (`SqlCrudConfig`, `SqlOperationOptions`, `DrizzleCrudConfig`, `CrudFeature`, `CrudFeatureConfig`, `PaginationOptions`, `OperationOptions`, `SqlSearchOptions`, `SqlSearchMode`, `BulkOperationResult`, `BulkOperationItemError`, `SqlDialect`, `PrimaryKeyType`, `CrudFilter`, `FilterOperators`, `SortInput`, `CursorPaginationOptions`, `CursorPage`, `CrudAllowlists`, `UpsertOptions`, `BulkMode`, `ParsedCrudQuery`), and `parseCrudQuery`.
1248
+
1249
+ ### `SqlCrudConfig`
1250
+
1251
+ ```typescript
1252
+ interface SqlCrudConfig {
1253
+ dialect: 'postgresql' | 'mysql';
1254
+ db: any; // Drizzle instance (injected by forFeature)
1255
+ table: any; // Drizzle table (set by forFeature)
1256
+ primaryKey: string; // default 'id'
1257
+ primaryKeyType?: 'serial' | 'bigserial' | 'int' | 'bigint' | 'uuid'; // deprecated, no effect
1258
+ softDelete?: { enabled: boolean; column: string }; // column: the table key
1259
+ timestamps?: { createdAt: string; updatedAt: string }; // table keys
1260
+ pagination?: { defaultLimit: number; maxLimit: number };
1261
+ sql?: { caseSensitive: boolean; useReturning: boolean; jsonSupport: boolean; enableFullTextSearch: boolean }; // last two deprecated
1262
+ relations?: Record<string, {
1263
+ table: any;
1264
+ type?: 'belongsTo' | 'hasMany' | 'manyToMany';
1265
+ localKey?: string; // belongs-to: FK column on this table
1266
+ references?: string; // target column, default 'id'
1267
+ foreignKey?: string; // has-many: column on the target table
1268
+ parentKey?: string; // collections: column here it matches, default primaryKey
1269
+ through?: any; // many-to-many: the join table
1270
+ throughLocalKey?: string; // many-to-many: join column matching parentKey
1271
+ throughForeignKey?: string; // many-to-many: join column matching references
1272
+ orderBy?: SortColumn[]; // collections: order the children in SQL
1273
+ relations?: RelationsConfig; // nested relations, addressed as 'rel.child'
1274
+ }>;
1275
+ // Allowlists: unset allows everything, anything outside a set list is a 400
1276
+ filterableColumns?: string[];
1277
+ sortableColumns?: string[];
1278
+ selectableColumns?: string[];
1279
+ searchableColumns?: string[];
1280
+ loadableRelations?: string[];
1281
+ writableColumns?: string[];
1282
+ writableRelations?: string[]; // nested create; attach/detach/sync when set
1283
+ scope?: Record<string, unknown> | ((ctx) => Record<string, unknown> | null);
1284
+ versionColumn?: string; // optimistic locking
1285
+ events?: CrudEventBus; // set by the module with enableGlobalHooks
1286
+ }
1287
+ ```
1288
+
1289
+ ---
1290
+
1291
+ ## Support
1292
+
1293
+ - **Docs:** [crud.avytrix.dev](https://crud.avytrix.dev), including a
1294
+ [FAQ and where-to-ask guide](https://crud.avytrix.dev/docs/getting-help).
1295
+ - **Questions:** [GitHub Discussions](https://github.com/avytrix/nestjs-drizzle-crud/discussions).
1296
+ - **Bugs:** [open an issue](https://github.com/avytrix/nestjs-drizzle-crud/issues/new/choose)
1297
+ with the version, dialect, table definition and the call you made.
1298
+ - **Security:** follow [SECURITY.md](https://github.com/avytrix/nestjs-drizzle-crud/blob/main/SECURITY.md),
1299
+ never a public issue.
1300
+ - **AI agents:** this package ships `AGENT.md` plus an MCP server. See
1301
+ [For AI agents / LLM tools](#for-ai-agents--llm-tools).
1302
+ - **Sponsor:** [github.com/sponsors/avytrix](https://github.com/sponsors/avytrix).
1303
+
1304
+ ---
1305
+
1306
+ ## License
1307
+
1308
+ MIT