@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/AGENT.md ADDED
@@ -0,0 +1,513 @@
1
+ # Using nestjs-drizzle-crud in your project (AI agent guide)
2
+
3
+ > **Audience:** developers (and their AI agents) **consuming** `nestjs-drizzle-crud`
4
+ > in a downstream NestJS app. This file ships with the npm package. To auto-load
5
+ > these rules, copy the ready-made pointer to your project root; it `@`-imports
6
+ > this file, so you never duplicate the rules:
7
+ >
8
+ > ```bash
9
+ > cp node_modules/nestjs-drizzle-crud/AGENTS.template.md ./AGENTS.md # Codex, Cursor, Copilot, Zed, Cline, Windsurf
10
+ > cp node_modules/nestjs-drizzle-crud/CLAUDE.template.md ./CLAUDE.md # Claude Code
11
+ > # Gemini CLI reads ./GEMINI.md, Aider reads ./CONVENTIONS.md
12
+ > ```
13
+ >
14
+ > Or let the agent read these docs on demand: `npx nestjs-drizzle-crud-mcp` is an
15
+ > MCP server shipped with the package. See
16
+ > [crud.avytrix.dev/docs/ai-agents](https://crud.avytrix.dev/docs/ai-agents).
17
+ >
18
+ > To *contribute to* the package itself, see
19
+ > [CONTRIBUTING.md](https://github.com/avytrix/nestjs-drizzle-crud/blob/main/CONTRIBUTING.md)
20
+ > and [AGENTS.md](https://github.com/avytrix/nestjs-drizzle-crud/blob/main/AGENTS.md).
21
+ > Full docs: [crud.avytrix.dev](https://crud.avytrix.dev).
22
+
23
+ You are a backend engineer building NestJS APIs on Drizzle ORM. Your job is to
24
+ ship endpoints that are type safe, consistent and production ready by leaning on
25
+ this package instead of hand writing repository code. Do not rebuild what the
26
+ base service already does.
27
+
28
+ ---
29
+
30
+ # Core Principles
31
+
32
+ Order your decisions by these priorities:
33
+
34
+ 1. Configure over code.
35
+ 2. Reuse the base service over rebuilding a repository.
36
+ 3. Push behaviour into config, then into hooks, and only then into an override.
37
+ 4. Simplicity over abstraction.
38
+ 5. Correct SQL over convenient SQL.
39
+ 6. Predictable HTTP status codes.
40
+ 7. Tests that run without a database, plus one that runs with one.
41
+
42
+ ---
43
+
44
+ # Think Before You Build
45
+
46
+ Before writing a query, check whether the base service already covers it:
47
+ filters (with or / not groups), comparison and pattern operators, query string
48
+ parsing, allowlists, offset and cursor pagination, multi-column and relation
49
+ sorts, eager loading, relation filters, soft delete with a trash view and purge,
50
+ timestamps, set-based bulk writes, upsert, transactions, row locks and full text
51
+ search all ship. If you are writing `db.select()` inside a
52
+ service, stop and check the API reference first.
53
+
54
+ ---
55
+
56
+ # Installation
57
+
58
+ ```bash
59
+ pnpm add nestjs-drizzle-crud
60
+ pnpm add @nestjs/common @nestjs/core drizzle-orm reflect-metadata # peers, always
61
+ pnpm add postgres # PostgreSQL
62
+ pnpm add mysql2 # MySQL
63
+ ```
64
+
65
+ `postgres` is an optional peer. You only need it when you let the module build
66
+ the connection from a `connectionString` on PostgreSQL. If you pass your own
67
+ `db`, no driver peer is required.
68
+
69
+ Requires Node >= 20 and `emitDecoratorMetadata` / `experimentalDecorators` in
70
+ `tsconfig.json`, which the Nest CLI already sets.
71
+
72
+ ---
73
+
74
+ # Setup is two steps
75
+
76
+ **1. Configure the connection once**, in `AppModule`:
77
+
78
+ ```typescript
79
+ DrizzleCrudModule.forRootAsync({
80
+ useFactory: () => ({
81
+ dialect: 'postgresql' as const,
82
+ connectionString: process.env.DATABASE_URL!,
83
+ schema,
84
+ defaults: { softDelete: false, timestamps: true },
85
+ }),
86
+ });
87
+ ```
88
+
89
+ Use `forRoot` for a static config and `forRootAsync` when the value comes from
90
+ `ConfigService` or the environment. Provide exactly one of `connectionString`
91
+ or `db`. The module is global, so feature modules do not import it again for
92
+ the connection.
93
+
94
+ **2. Bind each entity in its feature module:**
95
+
96
+ ```typescript
97
+ DrizzleCrudModule.forFeature([
98
+ { service: TagsService, table: tags },
99
+ ]);
100
+ ```
101
+
102
+ The table is declared here, not in the service.
103
+
104
+ ---
105
+
106
+ # A service is an empty subclass
107
+
108
+ ```typescript
109
+ export class CountriesService extends SqlBaseCrudService<
110
+ Country,
111
+ CreateCountryDto,
112
+ UpdateCountryDto,
113
+ QueryCountryDto
114
+ > {}
115
+ ```
116
+
117
+ Generic order is `<Entity, CreateDto, UpdateDto, FilterDto>`. Derive `Entity`
118
+ from the schema with `typeof table.$inferSelect`.
119
+
120
+ **Do not:**
121
+
122
+ - add `@Injectable()`; `forFeature` constructs the service with a factory,
123
+ - inject `DRIZZLE_DB` or write a constructor that calls `super({ ... })`,
124
+ - pass `dialect`, `db` or `table` from inside the service.
125
+
126
+ Those are legacy manual patterns and they break registration.
127
+
128
+ ---
129
+
130
+ # API surface
131
+
132
+ | Group | Methods |
133
+ | --- | --- |
134
+ | Read | `find(id)`, `findOne(where)`, `findAll(filters, pagination, options)`, `findByCursor(filters, cursorPagination, options)`, `exists(id)`, `count(filters)`, `countBy(column, filters)` |
135
+ | Write | `create(dto)`, `update(id, dto)`, `upsert(dto, { conflictTarget, update })`, `softDelete(id)`, `restore(id)`, `delete(id)`, `forceDelete(id)` |
136
+ | Many-to-many links | `attach(id, relation, ids)`, `detach(id, relation, ids?)`, `sync(id, relation, ids)` |
137
+ | Controller | `createCrudController(Service, options)`, `service.parseId(raw)` |
138
+ | OpenAPI | `@ApiCrudList(source)`, `@ApiCrudQuery(source)` (needs `@nestjs/swagger`) |
139
+ | Bulk | `massCreate`, `massUpdate`, `massUpsert`, `massSoftDelete`, `massRestore`, `massDelete`, `massForceDelete` |
140
+ | Query string | `parseQuery(query, options)`, or the standalone `parseCrudQuery(query, table, options)` |
141
+ | Search | `fullTextSearch(term, columns, pagination)`, PostgreSQL only |
142
+ | Transaction | `executeSqlTransaction(cb)` |
143
+
144
+ `findAll` returns `{ data, total, page, limit }`, not a bare array; with
145
+ `withCount: false` it skips the count, `total` is `null` and `hasMore` is set.
146
+ `delete` and `softDelete` return a boolean. `update` and `restore` return the entity and
147
+ throw `EntityNotFoundException` when the row is missing.
148
+
149
+ Every method takes `options`: `{ transaction, relations, select, search, hooks, lock, forNoKeyUpdate, withDeleted, onlyDeleted, skipAllowlists, bulkMode, counts, context, skipScope, skipEvents, expectedVersion, expectedVersions }`.
150
+
151
+ **Do not hand write CRUD controllers.** Extend `createCrudController(Service, {
152
+ dto, routes, decorators })`. It registers list (offset and `?cursor=`), trash,
153
+ get, create, update, delete, soft delete, restore and, when enabled, the bulk
154
+ routes, all through `parseQuery`, and parses `:id` by the primary key type
155
+ (ParseIntPipe, ParseUUIDPipe). Add guards with `decorators` or per route, turn
156
+ routes off with `routes: { delete: false }`, and add your own routes to the
157
+ subclass.
158
+
159
+ ---
160
+
161
+ # Filtering
162
+
163
+ `findAll(filters)` and `count(filters)` take an object keyed by column name.
164
+ `null` and `undefined` values are skipped. A key that is not a column or a
165
+ configured relation is a 400, so pull `page`, `limit`, `sortBy` and other
166
+ non-filter fields out of the query DTO before passing the rest through.
167
+
168
+ ```typescript
169
+ await service.findAll({
170
+ status: 'active', // exact match, case-insensitive on text columns
171
+ author_id: '1', // a query string value is fine on an integer column
172
+ role: ['admin', 'editor'], // IN (...)
173
+ age: { gte: 18, lt: 65 },
174
+ created_at: { gte: '2025-01-01' }, // parsed to a Date; an invalid date is a 400
175
+ name: { ilike: 'jo%' }, // you supply the wildcards
176
+ archived_at: { isNull: false }, // IS NOT NULL
177
+ });
178
+ ```
179
+
180
+ Operators inside an object value: `eq`, `gt`, `gte`, `lt`, `lte`, `neq`, `like`,
181
+ `ilike`, `in`, `notIn`, `between` (`[from, to]`), `isNull`, `isNotNull`,
182
+ `startsWith`, `endsWith`, `contains` (escaped, case-insensitive unless
183
+ `sql.caseSensitive`), `iStartsWith`, `iEndsWith`, `iContains` (always
184
+ case-insensitive), and on PostgreSQL only `arrayContains`, `arrayOverlaps` and
185
+ `jsonContains`. Any other operator is a 400. A bare string is an exact match,
186
+ not a pattern, and `%` in it is a literal character. `isNull` and `isNotNull`
187
+ take `true` / `false` or the strings `"true"` / `"false"`.
188
+
189
+ OR and NOT need no raw Drizzle. `and`, `or` and `not` group filter objects, nest,
190
+ and can hold relation and collection filters:
191
+
192
+ ```typescript
193
+ await posts.findAll({
194
+ status: 'published',
195
+ or: [{ author: { name: 'Ann' } }, { views: { gte: 1000 } }],
196
+ not: { tags: { name: 'internal' } },
197
+ });
198
+ ```
199
+
200
+ `findOne(where)` throws a 400 when no condition is left (every value
201
+ `undefined`) or a key is not a column, and `null` means `IS NULL`. Never feed it
202
+ an optional field without checking it first.
203
+
204
+ **Never build a filter form or a where clause by hand in a controller.** Hand
205
+ the query string to `parseQuery`, which coerces values by column type and
206
+ rejects unknown keys with a 400, and let the service compile the SQL:
207
+
208
+ ```typescript
209
+ @Get()
210
+ findAll(@Query() query: Record<string, unknown>) {
211
+ const q = this.users.parseQuery(query);
212
+ return q.isCursor
213
+ ? this.users.findByCursor(q.filters, q.pagination, q.options)
214
+ : this.users.findAll(q.filters, q.pagination, q.options);
215
+ }
216
+ ```
217
+
218
+ Type the service's `FilterDto` generic as `CrudFilter` so the parsed filters fit.
219
+ The query string grammar: `col=v`, `col[op]=v`, `col[in]=a,b`,
220
+ `relation[col]=v`, `or[0][col]=v`, `not[col]=v`, `sortBy=-a,relation.b`,
221
+ `page`, `limit`, `fields=a,relation.b`, `with=relation`, `search`, `cursor`.
222
+ `withDeleted` / `onlyDeleted` only with `parseQuery(query, { allowTrash: true })`.
223
+
224
+ ---
225
+
226
+ # Allowlists: set them on every entity a client can reach
227
+
228
+ ```typescript
229
+ config: {
230
+ filterableColumns: ['status', 'team.name'],
231
+ sortableColumns: ['name', 'created_at'],
232
+ selectableColumns: ['id', 'name', 'email', 'team.name'],
233
+ searchableColumns: ['name', 'email'],
234
+ loadableRelations: ['team'],
235
+ writableColumns: ['name', 'email', 'role'],
236
+ }
237
+ ```
238
+
239
+ Unset lists allow everything. Anything outside a set list is a 400, in the
240
+ service and in `parseQuery`. `findOne(where)` keys are not checked (server-side
241
+ lookups); pass `skipAllowlists: true` for any other trusted call. Without
242
+ `writableColumns`, create and update ignore the soft delete column, and update
243
+ ignores the primary key and `createdAt`.
244
+
245
+ ---
246
+
247
+ # Sorting and cursor pagination
248
+
249
+ `sortBy` takes one column (with `sortOrder`, default `'desc'`) or a list:
250
+ `['team.name', '-created_at']` or `[{ column, order }]`. A dotted entry sorts by a
251
+ top-level belongs-to column. The primary key is always the last sort column.
252
+
253
+ For infinite scroll, exports and deep pages use `findByCursor(filters,
254
+ { cursor, limit, sortBy, count })`. It returns `{ data, limit, nextCursor,
255
+ total? }`; pass `nextCursor` back as `cursor`. Do not hand-roll `id > lastId`
256
+ pagination.
257
+
258
+ ---
259
+
260
+ # Relations
261
+
262
+ Four shapes, all declared per entity in `forFeature`:
263
+
264
+ ```typescript
265
+ config: {
266
+ relations: {
267
+ // belongs to: countries.region_id -> regions.id
268
+ region: { table: regions, localKey: 'region_id' },
269
+ // has many: states.country_id -> countries.id
270
+ states: {
271
+ table: states,
272
+ type: 'hasMany',
273
+ foreignKey: 'country_id',
274
+ orderBy: [{ column: 'name', order: 'asc' }],
275
+ // nested: load with relations: ['states', 'states.cities']
276
+ relations: {
277
+ cities: { table: cities, type: 'hasMany', foreignKey: 'state_id' },
278
+ },
279
+ },
280
+ // many to many: countries.id <- country_tags -> tags.id
281
+ tags: {
282
+ table: tags,
283
+ type: 'manyToMany',
284
+ through: countryTags,
285
+ throughLocalKey: 'country_id',
286
+ throughForeignKey: 'tag_id',
287
+ },
288
+ },
289
+ }
290
+ ```
291
+
292
+ Eager load with `options.relations: ['region', 'states', 'states.cities', 'tags']`.
293
+ A belongs to relation is joined into the main query and nests an object (`null`
294
+ when unmatched). Every collection, and every nested level whatever its shape,
295
+ runs **one extra batched query for the whole result set** and nests an array
296
+ (`[]` when empty). A dotted path pulls in its parent path automatically.
297
+
298
+ **Never load a collection in a loop.** A page of 50 countries with their states
299
+ and every state's cities is 4 queries: data, count, states, cities. Fetching them
300
+ per row is hundreds. If you catch yourself writing
301
+ `for (const row of rows) await ...`, the relation you want is already in this
302
+ config; declare it and pass it in `options.relations`.
303
+
304
+ Filter with `findAll({ region: { name: 'Asia' } })`,
305
+ `findAll({ states: { name: 'Kerala' } })` or
306
+ `findAll({ tags: { name: 'himalayan' } })` using the same operators. A collection
307
+ filter compiles to `id IN (SELECT ... WHERE ...)`, never a join, so `total` and
308
+ pagination stay correct. A filter can go through relations at any depth:
309
+ `{ state: { country: { code: 'IN' } } }` (`?state[country][code]=IN`). A
310
+ collection takes `some` (the default), `none` or `every`:
311
+ `{ posts: { none: { status: 'draft' } } }`. They compile to `EXISTS` /
312
+ `NOT EXISTS` subqueries; never post-filter rows in JavaScript instead.
313
+
314
+ **Counts.** Ask for `options.counts: ['posts']` to get `_count: { posts: n }`
315
+ on each row, one grouped query per relation, and `countBy('status', filters)`
316
+ for facet counts. Never call `count()` once per row.
317
+
318
+ **Relations on writes.** Pass `options.relations` to `create`, `update`,
319
+ `upsert` and the mass writes to get the related rows back. Nested create
320
+ (`authors.create({ name, posts: [...] })`) only writes relations listed in
321
+ `writableRelations`, inside one transaction. Change many-to-many links with
322
+ `attach` / `detach` / `sync`, never with raw inserts into the join table.
323
+
324
+ A relation missing its keys throws at startup, with the full path in the message.
325
+ For a join table that carries its own columns, model it as an entity with two
326
+ belongs to relations.
327
+
328
+ ---
329
+
330
+ # Only select the columns you need
331
+
332
+ `options.select` narrows the projection, and a dotted entry narrows a relation:
333
+
334
+ ```typescript
335
+ await countries.findAll({}, { page: 1, limit: 20 }, {
336
+ relations: ['region', 'states', 'states.cities'],
337
+ select: ['id', 'name', 'region.code', 'states.name', 'states.cities.name'],
338
+ });
339
+ // { id, name, region: { code }, states: [{ name, country_id, id, cities: [{ name, state_id }] }] }
340
+ ```
341
+
342
+ Use it on list endpoints and hot paths rather than hydrating every column. A
343
+ dotted entry only applies when the relation is also eager loaded, paths can be
344
+ any depth, unknown names are ignored, and a list of only dotted entries leaves
345
+ the base row complete. The keys needed to group a collection come back whether
346
+ you asked for them or not.
347
+
348
+ ---
349
+
350
+ # Keys, soft delete and timestamps
351
+
352
+ - **Primary keys:** `primaryKey` (default `id`) per entity. The type comes from
353
+ the column; `primaryKeyType` is deprecated and does nothing. With uuid keys
354
+ route params are strings, so do not apply `ParseIntPipe`. The primary key is
355
+ always the last `ORDER BY` column, so pages are stable.
356
+ - **Soft delete:** enable per project (`defaults.softDelete`) or per entity.
357
+ Reads then exclude deleted rows automatically, including a soft-deleted
358
+ belongs-to parent. `softDelete.column` and the `timestamps` keys name the key
359
+ in the Drizzle table object (`deletedAt` for a camelCase schema). A key the
360
+ table does not have stops the app at startup. For a trash view pass
361
+ `onlyDeleted: true` (or `withDeleted: true`) in the options of any read; to
362
+ purge, call `forceDelete(id)` or `massForceDelete(ids)`. `delete(id)` answers
363
+ 404 for a row that is already in the trash. Never query `deleted_at` by hand.
364
+ - **Timestamps:** prefer schema managed (`defaultNow()` plus `$onUpdate()`) so
365
+ every write path is stamped by the database. The package managed option uses
366
+ the application clock and only covers writes that go through the package.
367
+
368
+ ---
369
+
370
+ # Multi-tenancy: use `scope`, never hand written tenant filters
371
+
372
+ For data owned by a tenant or a user, set `scope` in the entity config and put
373
+ the tenant in the context once per request:
374
+
375
+ ```typescript
376
+ config: { scope: (ctx) => ({ tenant_id: ctx.tenantId }) }
377
+
378
+ // in a NestModule
379
+ consumer
380
+ .apply(CrudContext.middleware((req) => ({ tenantId: req.user?.tenantId })))
381
+ .forRoutes('*');
382
+ ```
383
+
384
+ The scope is ANDed into every read, count and write (bulk, upsert, soft delete,
385
+ restore, force delete, attach included) and written onto every created row, so
386
+ another tenant's id is a 404. A missing context value is a 403, never an
387
+ unscoped query. Do not add `tenant_id` to filters or DTOs by hand, and use
388
+ `skipScope: true` only in trusted server code.
389
+
390
+ # Optimistic locking
391
+
392
+ Set `versionColumn: 'version'` when two people can edit the same row. Clients
393
+ send back the `version` they read; a stale one is a 409
394
+ (`StaleVersionException`). Pass `expectedVersion` in code, or
395
+ `expectedVersions: { [id]: version }` for mass writes.
396
+
397
+ # Hooks, events and validation
398
+
399
+ Override the `protected` hooks on your service rather than wrapping the service
400
+ in another layer: `validateCreate`, `validateUpdate`, `mapCreateDtoToEntity`,
401
+ `mapUpdateDtoToEntity`, and the `before*` / `after*` pairs for create, update,
402
+ delete, soft delete and restore. Defaults are no ops and pass throughs, so
403
+ override only what you need.
404
+
405
+ For cross-cutting work (audit trails, cache busting, outbox rows) turn on
406
+ `hooks.enableGlobalHooks` in `forRoot` and subscribe on the injected
407
+ `CrudEventBus` instead of copying hooks into every service. A bulk call raises
408
+ one event with every id; write audit rows with `event.transaction` so they
409
+ commit or roll back with the write.
410
+
411
+ Validation of request shape stays in DTOs with `class-validator`. Use
412
+ `validateCreate` for rules that need the database.
413
+
414
+ ---
415
+
416
+ # Errors
417
+
418
+ The package throws NestJS exceptions, so the default filter maps them without
419
+ extra wiring: `EntityNotFoundException` gives 404, `DuplicateEntityException`
420
+ and `EntityInUseException` give 409, and `ValidationFailedException`,
421
+ `ForeignKeyViolationException` and `BulkOperationException` give 400,
422
+ `StaleVersionException` gives 409 and `MissingScopeException` gives 403. Do not
423
+ catch these to re-throw a generic 500, and do not add a custom filter to
424
+ translate them again.
425
+
426
+ Driver error codes are translated at the service boundary, so bad client input
427
+ never reaches the caller as a 500: a unique violation is a 409, a value that is
428
+ too long, fails a check, has the wrong type or is not a valid date is a 400 (on
429
+ reads too), a foreign key pointing at a row that does not exist is a 400, and a
430
+ delete blocked by dependent rows is a 409.
431
+
432
+ ---
433
+
434
+ # Transactions and bulk writes
435
+
436
+ ```typescript
437
+ await service.executeSqlTransaction(async (tx) => {
438
+ const user = await service.create(dto, { transaction: tx });
439
+ await profiles.create({ userId: user.id }, { transaction: tx });
440
+ });
441
+ ```
442
+
443
+ Pass `{ transaction: tx }` to enlist any method. All bulk methods already run in
444
+ a single transaction and roll back as a unit. Each is one statement for the
445
+ whole set (plus one `SELECT` that checks the ids), so never loop `create` or
446
+ `update` over an array yourself. The 400 from `BulkOperationException` lists
447
+ only the failed items: `errors: [{ index | id, status, message }]`.
448
+ `bulkMode: 'perItem'` runs one single-row call per item when a hook needs it.
449
+
450
+ To insert or update by a unique key, use
451
+ `upsert(dto, { conflictTarget: 'email' })` or `massUpsert(rows, ...)` rather
452
+ than a find followed by a create.
453
+
454
+ Take a row lock with `options.lock: 'update' | 'share'` only inside a
455
+ transaction, and prefer `forNoKeyUpdate` when the key is not changing. The lock
456
+ covers the service's own table only (`FOR UPDATE OF <table>`), so it works with
457
+ an eager belongs-to.
458
+
459
+ ---
460
+
461
+ # Testing
462
+
463
+ ```typescript
464
+ const mockDb = TestCrudFactory.createMockDb();
465
+ const mockTable = TestCrudFactory.createMockTable();
466
+ const service = TestCrudFactory.createTestService(UsersService, mockDb, mockTable);
467
+ ```
468
+
469
+ `TestCrudFactory` covers unit tests without a database. Back anything that has
470
+ to be true over the wire with one e2e test that boots the app and hits the route
471
+ against a real database.
472
+
473
+ ---
474
+
475
+ # AI Development Rules
476
+
477
+ Before writing code, check the API reference for a method or option that already
478
+ does it. Extend behaviour through config, then hooks, then an override, in that
479
+ order. Never duplicate query logic across services, and never reach into
480
+ `config.db` from a controller.
481
+
482
+ **Do not fetch a whole table to filter it in JavaScript.** Filtering, sorting,
483
+ pagination and counting are the service's job, and doing them in the controller
484
+ loads every row into memory. The same rule applies to "export all": ask the
485
+ database with the query, not the client with the page.
486
+
487
+ ---
488
+
489
+ # Definition of Done
490
+
491
+ A feature is complete only when:
492
+
493
+ ✓ Uses the base service rather than hand written queries.
494
+
495
+ ✓ Declares the table in `forFeature`, not in the service.
496
+
497
+ ✓ Types the entity from the Drizzle schema (`$inferSelect`).
498
+
499
+ ✓ Validates input with DTOs, and with hooks when the rule needs the database.
500
+
501
+ ✓ Returns the right HTTP status through the package's exceptions.
502
+
503
+ ✓ Handles pagination and filtering through `findAll`, not in the controller.
504
+
505
+ ✓ Uses `createCrudController` for CRUD routes and `scope` for tenant data.
506
+
507
+ ✓ Runs multi row writes in a transaction.
508
+
509
+ ✓ Has unit tests, plus an e2e test for anything database specific.
510
+
511
+ ✓ Passes lint, type checking and build.
512
+
513
+ ✓ Is production ready.
@@ -0,0 +1,31 @@
1
+ <!--
2
+ nestjs-drizzle-crud - AI agent rules for apps that consume the package.
3
+
4
+ Copy this file to your project root under whatever filename your agent reads:
5
+
6
+ cp node_modules/nestjs-drizzle-crud/AGENTS.template.md ./AGENTS.md
7
+
8
+ Most tools read AGENTS.md (OpenAI Codex, Cursor, GitHub Copilot, Zed, Cline,
9
+ Windsurf, Jules). Tools with their own filename:
10
+
11
+ Claude Code ./CLAUDE.md
12
+ Gemini CLI ./GEMINI.md
13
+ Aider ./CONVENTIONS.md, then `aider --read CONVENTIONS.md`
14
+
15
+ You can copy this file to several of those names, or keep one and have the
16
+ others point at it with a single line.
17
+
18
+ The line below loads the full guide that ships inside the package, so AGENT.md
19
+ stays the single source of truth and you never duplicate the rules. If your
20
+ agent does not resolve `@` imports, delete it and paste the contents of
21
+ node_modules/nestjs-drizzle-crud/AGENT.md here instead, or run the package's
22
+ MCP server (see "MCP" in the README) so the agent can read the docs on demand.
23
+ -->
24
+
25
+ @node_modules/nestjs-drizzle-crud/AGENT.md
26
+
27
+ <!--
28
+ Fallback for agents that ignore the line above: read
29
+ node_modules/nestjs-drizzle-crud/AGENT.md before writing any code that uses
30
+ this package, and follow it.
31
+ -->