@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.
- package/AGENT.md +513 -0
- package/AGENTS.template.md +31 -0
- package/CHANGELOG.md +512 -0
- package/CLAUDE.template.md +18 -0
- package/LICENSE +21 -0
- package/README.md +1300 -2
- package/dist/controller/crud-controller.factory.d.ts +27 -0
- package/dist/controller/crud-controller.factory.js +251 -0
- package/dist/controller/crud-controller.factory.js.map +1 -0
- package/dist/core/abstract/sql-base-crud.service.d.ts +225 -0
- package/dist/core/abstract/sql-base-crud.service.js +2866 -0
- package/dist/core/abstract/sql-base-crud.service.js.map +1 -0
- package/dist/core/context/crud-context.d.ts +6 -0
- package/dist/core/context/crud-context.js +27 -0
- package/dist/core/context/crud-context.js.map +1 -0
- package/dist/core/events/crud-event-bus.d.ts +7 -0
- package/dist/core/events/crud-event-bus.js +30 -0
- package/dist/core/events/crud-event-bus.js.map +1 -0
- package/dist/core/interfaces/crud-service.interface.d.ts +54 -0
- package/dist/core/interfaces/crud-service.interface.js +3 -0
- package/dist/core/interfaces/crud-service.interface.js.map +1 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.d.ts +39 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.js +3 -0
- package/dist/core/interfaces/drizzle-crud-config.interface.js.map +1 -0
- package/dist/core/interfaces/events.interface.d.ts +39 -0
- package/dist/core/interfaces/events.interface.js +3 -0
- package/dist/core/interfaces/events.interface.js.map +1 -0
- package/dist/core/interfaces/query.interface.d.ts +112 -0
- package/dist/core/interfaces/query.interface.js +3 -0
- package/dist/core/interfaces/query.interface.js.map +1 -0
- package/dist/core/interfaces/scope.interface.d.ts +3 -0
- package/dist/core/interfaces/scope.interface.js +3 -0
- package/dist/core/interfaces/scope.interface.js.map +1 -0
- package/dist/core/interfaces/sql-crud-config.interface.d.ts +87 -0
- package/dist/core/interfaces/sql-crud-config.interface.js +3 -0
- package/dist/core/interfaces/sql-crud-config.interface.js.map +1 -0
- package/dist/core/types/sql.types.d.ts +7 -0
- package/dist/core/types/sql.types.js +3 -0
- package/dist/core/types/sql.types.js.map +1 -0
- package/dist/core/utils/cursor.d.ts +2 -0
- package/dist/core/utils/cursor.js +44 -0
- package/dist/core/utils/cursor.js.map +1 -0
- package/dist/core/utils/merge-config.d.ts +2 -0
- package/dist/core/utils/merge-config.js +22 -0
- package/dist/core/utils/merge-config.js.map +1 -0
- package/dist/core/utils/query-parser.d.ts +2 -0
- package/dist/core/utils/query-parser.js +504 -0
- package/dist/core/utils/query-parser.js.map +1 -0
- package/dist/decorators/crud-service.decorator.d.ts +4 -0
- package/dist/decorators/crud-service.decorator.js +10 -0
- package/dist/decorators/crud-service.decorator.js.map +1 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +6 -0
- package/dist/decorators/index.js.map +1 -0
- package/dist/exceptions/crud.exceptions.d.ts +46 -0
- package/dist/exceptions/crud.exceptions.js +93 -0
- package/dist/exceptions/crud.exceptions.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +236 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/modules/drizzle-connection.d.ts +12 -0
- package/dist/modules/drizzle-connection.js +73 -0
- package/dist/modules/drizzle-connection.js.map +1 -0
- package/dist/modules/drizzle-crud.module.d.ts +11 -0
- package/dist/modules/drizzle-crud.module.js +165 -0
- package/dist/modules/drizzle-crud.module.js.map +1 -0
- package/dist/openapi/api-crud.decorators.d.ts +10 -0
- package/dist/openapi/api-crud.decorators.js +216 -0
- package/dist/openapi/api-crud.decorators.js.map +1 -0
- package/dist/test-utils/base-crud.spec-helper.d.ts +64 -0
- package/dist/test-utils/base-crud.spec-helper.js +119 -0
- package/dist/test-utils/base-crud.spec-helper.js.map +1 -0
- package/dist/test-utils/index.d.ts +2 -0
- package/dist/test-utils/index.js +8 -0
- package/dist/test-utils/index.js.map +1 -0
- package/dist/test-utils/test-factory.d.ts +29 -0
- package/dist/test-utils/test-factory.js +81 -0
- package/dist/test-utils/test-factory.js.map +1 -0
- 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
|
+
-->
|