@prismakit/cli 2.2.2 → 3.0.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.
- package/README.md +11 -5
- package/dist/bin.cjs +181 -63
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +26 -15
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-7E6MWEJP.js → chunk-A2GZ2IEY.js} +159 -55
- package/dist/chunk-A2GZ2IEY.js.map +1 -0
- package/dist/index.cjs +161 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -10
- package/dist/index.d.ts +30 -10
- package/dist/index.js +5 -3
- package/package.json +9 -6
- package/rules/data-access.mdc +25 -0
- package/skills/README.md +40 -0
- package/skills/prismakit/SKILL.md +282 -0
- package/skills/prismakit/examples.md +238 -0
- package/skills/prismakit/reference.md +312 -0
- package/skills/prismakit/review-checklist.md +72 -0
- package/skills/prismakit-nestjs/SKILL.md +255 -0
- package/skills/prismakit-nestjs/examples.md +235 -0
- package/skills/prismakit-nestjs/reference.md +146 -0
- package/skills/prismakit-nestjs/review-checklist.md +57 -0
- package/src/__tests__/skills.spec.ts +117 -0
- package/src/bin.ts +29 -14
- package/src/commands/skills.ts +207 -0
- package/src/commands/validate.ts +8 -2
- package/src/index.ts +6 -1
- package/dist/chunk-7E6MWEJP.js.map +0 -1
- package/src/commands/codegen.ts +0 -69
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# PrismaKit core reference
|
|
2
|
+
|
|
3
|
+
API surface for `@prismakit/core` 3.x. Read [SKILL.md](SKILL.md) first.
|
|
4
|
+
|
|
5
|
+
## Packages
|
|
6
|
+
|
|
7
|
+
| Package | Role |
|
|
8
|
+
|---------|------|
|
|
9
|
+
| `@prismakit/core` | `createRepository`, AutoComposer, locks, pagination, `CacheAdapter` |
|
|
10
|
+
| `@prismakit/redis` | `RedisCacheAdapter` |
|
|
11
|
+
| `@prismakit/memory` | `MemoryCacheAdapter` (tests / local) |
|
|
12
|
+
| `@prismakit/cli` | `prismakit generate / validate / skills` |
|
|
13
|
+
| `@prismakit/eslint-plugin` | Repository-only data-access rules |
|
|
14
|
+
|
|
15
|
+
Node ≥ 20. Install:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @prismakit/core
|
|
19
|
+
pnpm add @prismakit/redis ioredis # optional production cache
|
|
20
|
+
pnpm add -D @prismakit/eslint-plugin @prismakit/cli
|
|
21
|
+
# tests: pnpm add -D @prismakit/memory
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Factory
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
createRepository(options) → new RepoClass(deps: RepositoryDeps)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Aliases: `defineRepository`, `createPrismaRepository`.
|
|
31
|
+
|
|
32
|
+
`RepositoryDeps`: `{ prisma, cache?, registry?, autoCompose? }`.
|
|
33
|
+
|
|
34
|
+
### `RepositoryOptions`
|
|
35
|
+
|
|
36
|
+
| Field | Type | Notes |
|
|
37
|
+
|-------|------|-------|
|
|
38
|
+
| `model` | `string` | Client key. Required for cache + compose. |
|
|
39
|
+
| `scalarFields` | `Record<string, string>` | `Prisma.XScalarFieldEnum`; inferred from meta if omitted. |
|
|
40
|
+
| `cache` | `CacheOptions \| true` | `true` → `{ ttl: 86400, sensitiveFields: ['password'] }`. |
|
|
41
|
+
| `lock` | `true \| string \| RepositoryLockConfig` | `true` / client key / Pascal name / `@@map` table. |
|
|
42
|
+
| `schemaPath` | `string` | Lock/schema helpers when global meta is missing. |
|
|
43
|
+
| `primaryKey` | `string \| string[]` | `*ById` + row locks. Default: meta PK or `id`. |
|
|
44
|
+
| `getDelegate` | `(client) => delegate` | Default `(c) => c[model]`. |
|
|
45
|
+
| `toPayload` | `(data) => payload` | Default identity. Prefer typed factories over this. |
|
|
46
|
+
|
|
47
|
+
`RepositoryLockConfig`: `{ tableName: string; columns?: Record<string, string> }`.
|
|
48
|
+
|
|
49
|
+
## Repository methods
|
|
50
|
+
|
|
51
|
+
All methods accept optional `tx`. Cached repos also accept cache fields (see below). `id` is `string | Record<string, string>`.
|
|
52
|
+
|
|
53
|
+
### Reads
|
|
54
|
+
|
|
55
|
+
| Method | Extra args | Returns |
|
|
56
|
+
|--------|------------|---------|
|
|
57
|
+
| `getById` | `id`, `select?`, `lock?`, `setCache?` | `T \| null` |
|
|
58
|
+
| `getThrowById` | same | `T` (throws if missing) |
|
|
59
|
+
| `getFirst` | `where?`, `select?`, `lock?`, `setCache?`, `cacheTags?` | `T \| null` |
|
|
60
|
+
| `getMany` | `where?`, `select?`, `orderBy?`, `take?`, `skip?`, `lock?`, `setCache?`, `cacheTags?` | `T[]` |
|
|
61
|
+
| `getManyPaginate` | `where?`, `select?`, `orderBy?`, `page?`, `pageSize?`, `setCache?`, `cacheTags?` | `PaginatedResult<T>` |
|
|
62
|
+
|
|
63
|
+
`PaginatedResult<T>`:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
{ data: T[]; meta: { page: number; pageSize: number; totalItems: number; totalPages: number } }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`cacheTags` may be `string[]` or `(where?) => string[]`.
|
|
70
|
+
|
|
71
|
+
### Writes
|
|
72
|
+
|
|
73
|
+
| Method | Extra args | Default `invalidate` | Returns |
|
|
74
|
+
|--------|------------|----------------------|---------|
|
|
75
|
+
| `create` | `data`, `select?`, `invalidate?`, `tags?` | `queries` | `T` |
|
|
76
|
+
| `createMany` | `data[]`, `skipDuplicates?`, `invalidate?`, `tags?` | `queries` | `{ count }` |
|
|
77
|
+
| `updateById` | `id`, `data`, `select?`, `invalidate?`, `tags?` | `all` | `T` |
|
|
78
|
+
| `updateMany` | `where`, `data`, `invalidate?`, `tags?` | `all` | `{ count }` |
|
|
79
|
+
| `upsert` | `where`, `create`, `update`, `select?`, `invalidate?`, `tags?` | `all` | `T` |
|
|
80
|
+
| `deleteById` | `id`, `select?`, `invalidate?`, `tags?` | `all` | `T` |
|
|
81
|
+
| `deleteMany` | `where`, `invalidate?`, `tags?` | `all` | `{ count }` |
|
|
82
|
+
|
|
83
|
+
Mutation `tags`: `string[] | null | undefined | ((result) => string[] | null | undefined)`.
|
|
84
|
+
|
|
85
|
+
### Manual invalidation (cached repos only)
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
await repo.invalidateCache({ id?: string; tags?: string[] });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Cache
|
|
92
|
+
|
|
93
|
+
### When a read hits cache
|
|
94
|
+
|
|
95
|
+
1. `setCache: true` **or** `cache.defaultSetCache: true` without `setCache: false`
|
|
96
|
+
2. `model` + `cache` config present
|
|
97
|
+
3. No `tx`
|
|
98
|
+
4. Method not `{ enabled: false }` in `cache.methods`
|
|
99
|
+
5. `select` has no `sensitiveFields` (default `['password']`)
|
|
100
|
+
|
|
101
|
+
### `CacheOptions`
|
|
102
|
+
|
|
103
|
+
| Field | Default | Notes |
|
|
104
|
+
|-------|---------|-------|
|
|
105
|
+
| `ttl` | `86400` | Entity TTL (seconds). |
|
|
106
|
+
| `nullTtl` | — | Negative cache for null results. |
|
|
107
|
+
| `sensitiveFields` | `['password']` | Selects containing these never cache. |
|
|
108
|
+
| `methods` | — | Per-method `{ enabled?, ttl? }` for `getById`, `getThrowById`, `getFirst`, `getMany`, `getManyPaginate`. |
|
|
109
|
+
| `defaultSetCache` | `false` | Reads cache unless caller passes `setCache: false`. |
|
|
110
|
+
| `stampede` | see below | Per-repo stampede overrides. |
|
|
111
|
+
| `compression` | — | Hint for adapters (`'none' \| 'zstd' \| 'lz4'`). Redis adapter uses `'none' \| 'gzip'`. |
|
|
112
|
+
|
|
113
|
+
### `InvalidateMode`
|
|
114
|
+
|
|
115
|
+
| Mode | Effect |
|
|
116
|
+
|------|--------|
|
|
117
|
+
| `all` | Entity keys for `id` (or all entities) + query index |
|
|
118
|
+
| `entity` | Entity keys only |
|
|
119
|
+
| `queries` | Query index only |
|
|
120
|
+
| `none` | Skip — **required inside transactions** |
|
|
121
|
+
| `stale` | Core treats like entity+queries (adapter may layer SWR) |
|
|
122
|
+
|
|
123
|
+
### Allowlist
|
|
124
|
+
|
|
125
|
+
`setRegisteredCacheModels(['user', 'product'])`. Empty/unset = fail-open. When set, a repo with `cache` whose `model` is missing throws at init.
|
|
126
|
+
|
|
127
|
+
### Key schema
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
{prefix}:repo:{model}:e:{id}:{method}:{selectHash}
|
|
131
|
+
{prefix}:repo:{model}:q:{method}:{queryHash}
|
|
132
|
+
{prefix}:repo:{model}:e:{id}:__idx
|
|
133
|
+
{prefix}:repo:{model}:e:__idx
|
|
134
|
+
{prefix}:repo:{model}:q:__idx
|
|
135
|
+
{prefix}:repo:{model}:t:{tag}:__idx
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Debug
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
CACHE_DEBUG=true
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Hits/misses/bypasses via `cacheDebugStorage` from `@prismakit/core`.
|
|
145
|
+
|
|
146
|
+
### Stampede (`StampedeOptions`)
|
|
147
|
+
|
|
148
|
+
| Field | Default |
|
|
149
|
+
|-------|---------|
|
|
150
|
+
| `lockTtl` | `5` (seconds) |
|
|
151
|
+
| `retryMs` | `100` |
|
|
152
|
+
| `maxRetries` | `10` |
|
|
153
|
+
| `backoff` | `'exponential'` (`'fixed'` also valid) |
|
|
154
|
+
| `totalTimeoutMs` | `3000` |
|
|
155
|
+
|
|
156
|
+
In-process `singleflight` is also used. If Redis is down, fail open.
|
|
157
|
+
|
|
158
|
+
### Adapters
|
|
159
|
+
|
|
160
|
+
**Redis** (`@prismakit/redis`):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
new RedisCacheAdapter({
|
|
164
|
+
url: process.env.REDIS_URL, // or host + port
|
|
165
|
+
host: 'localhost', // default when url omitted
|
|
166
|
+
port: 6379,
|
|
167
|
+
prefix: 'myapp', // default 'prismakit'
|
|
168
|
+
compression: 'gzip', // 'none' | 'gzip'
|
|
169
|
+
compressionThresholdBytes: 1024,
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Memory** (`@prismakit/memory`) — tests/local:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
new MemoryCacheAdapter({ prefix: 'test', maxSize: 1000, defaultTtl: 300 });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Custom: implement `CacheAdapter` (`get`/`set`/`del`/`setNx`/`setWithIndex`/`invalidateByIndex`/`saddAndExpire`/`smembers`/`isReady`/`getPrefix` + `safe*` wrappers). `get`/`safeGet` **must** return a fresh copy (structuredClone / JSON). Prefer fail-open `safe*` semantics.
|
|
180
|
+
|
|
181
|
+
## Auto-compose
|
|
182
|
+
|
|
183
|
+
`splitSelect` keeps scalars (+ FK fields from DMMF) for the Prisma query. Relation keys load via the target repository. Target PK is always injected into the nested select.
|
|
184
|
+
|
|
185
|
+
Load meta once at bootstrap:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { loadPrismaMetaFromDmmf, loadPrismaMetaFromSchema } from '@prismakit/core';
|
|
189
|
+
|
|
190
|
+
loadPrismaMetaFromDmmf(Prisma.dmmf); // Prisma 5/6
|
|
191
|
+
loadPrismaMetaFromSchema('prisma/schema.prisma'); // Prisma 7 (no Prisma.dmmf)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Without meta, AutoComposer cannot resolve renamed relations; pass `schemaPath` or `dmmf`. Relation field → registry key is source-scoped (`Category.products` vs `Tag.products` can target different models).
|
|
195
|
+
|
|
196
|
+
Validate: `npx prismakit validate --auto-register` or `assertSelectComposeValid`.
|
|
197
|
+
|
|
198
|
+
### `ComposeOptions` (global via `setComposeOptions`)
|
|
199
|
+
|
|
200
|
+
| Field | Default | Notes |
|
|
201
|
+
|-------|---------|-------|
|
|
202
|
+
| `maxDepth` | `10` | Max relation nesting. |
|
|
203
|
+
| `parallel` | `true` | Same-level relations via `Promise.all`. |
|
|
204
|
+
| `setCache` | `true` | Nested fetches pass `setCache: true` unless parent has `tx` / `setCache: false`. |
|
|
205
|
+
| `tx` | — | Per-call only; forwarded to nested `getMany`. |
|
|
206
|
+
|
|
207
|
+
Related repos must be registered on `RepositoryRegistry`.
|
|
208
|
+
|
|
209
|
+
## Row locks
|
|
210
|
+
|
|
211
|
+
`SELECT … FOR UPDATE` (and related). Repo `lock` config required. `tx` required on the call.
|
|
212
|
+
|
|
213
|
+
### Per-call `RowLockOptions`
|
|
214
|
+
|
|
215
|
+
| Field | Notes |
|
|
216
|
+
|-------|-------|
|
|
217
|
+
| `mode` | `'update' \| 'noKeyUpdate' \| 'share' \| 'keyShare'`. Default `noKeyUpdate`. |
|
|
218
|
+
| `nowait` | Fail immediately if locked. |
|
|
219
|
+
| `skipLocked` | Skip locked rows. **Cannot** combine with `nowait`. |
|
|
220
|
+
|
|
221
|
+
`lock: true` / `'wallet'` / `'Wallet'` / `'wallets'` resolve via Prisma meta when available, else `buildLockConfigFromSchema`.
|
|
222
|
+
|
|
223
|
+
## Telemetry
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { setTelemetry } from '@prismakit/core';
|
|
227
|
+
|
|
228
|
+
setTelemetry({
|
|
229
|
+
enabled: true,
|
|
230
|
+
onEvent: (event) => { /* metrics */ },
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| Type | When |
|
|
235
|
+
|------|------|
|
|
236
|
+
| `cache.hit` / `cache.miss` / `cache.bypass` / `cache.invalidate` | Cache-aside path |
|
|
237
|
+
| `compose.start` / `compose.complete` | Auto-compose (`queryCount`, `durationMs`) |
|
|
238
|
+
| `lock.acquired` / `lock.waited` / `lock.timeout` | Row locks |
|
|
239
|
+
| `stampede.locked` / `stampede.waited` / `stampede.fallthrough` | Stampede protection |
|
|
240
|
+
| `query.complete` / `query.slow` | Repository method timing |
|
|
241
|
+
|
|
242
|
+
## CLI
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
npx prismakit generate <name> [--cache] [--full] [--route <path>] [--prisma-import <path>] [--dry-run]
|
|
246
|
+
npx prismakit validate [--schema <path>] [--auto-register] [--no-assert]
|
|
247
|
+
npx prismakit help
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Default generate output: `src/modules/<kebab>/repositories/<kebab>.repository.ts`.
|
|
251
|
+
|
|
252
|
+
`--full` emits Nest module + controller + service + select/where types. Register `*Module` in `app.module.ts` afterwards.
|
|
253
|
+
|
|
254
|
+
## ESLint
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
import prismakit from '@prismakit/eslint-plugin';
|
|
258
|
+
export default [prismakit.configs.recommended];
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`recommended` turns all rules on at **error**.
|
|
262
|
+
|
|
263
|
+
| Rule | Forbids |
|
|
264
|
+
|------|---------|
|
|
265
|
+
| `prismakit/no-prisma-service-outside-repos` | Inject/reference `PrismaService` / `PrismaClient` outside allowlist |
|
|
266
|
+
| `prismakit/no-direct-prisma-delegate` | `prisma.<model>.*` outside allowlist |
|
|
267
|
+
| `prismakit/require-transaction-service` | `.$transaction` in feature code |
|
|
268
|
+
|
|
269
|
+
Allowed path patterns (forward slashes):
|
|
270
|
+
|
|
271
|
+
- `**/repositories/**`
|
|
272
|
+
- `**/infrastructure/prisma/**`
|
|
273
|
+
- `**/node_modules/**`
|
|
274
|
+
- `packages/(core|nestjs|redis)/**` (PrismaKit monorepo)
|
|
275
|
+
|
|
276
|
+
Mirror this layout rather than weakening the plugin.
|
|
277
|
+
|
|
278
|
+
## Bootstrap (plain Node)
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
282
|
+
import {
|
|
283
|
+
createRepository,
|
|
284
|
+
loadPrismaMetaFromSchema,
|
|
285
|
+
setRegisteredCacheModels,
|
|
286
|
+
setComposeOptions,
|
|
287
|
+
setTelemetry,
|
|
288
|
+
RepositoryRegistry,
|
|
289
|
+
AutoComposer,
|
|
290
|
+
} from '@prismakit/core';
|
|
291
|
+
import { RedisCacheAdapter } from '@prismakit/redis';
|
|
292
|
+
|
|
293
|
+
const prisma = new PrismaClient();
|
|
294
|
+
loadPrismaMetaFromSchema('prisma/schema.prisma');
|
|
295
|
+
setRegisteredCacheModels(['user', 'post']);
|
|
296
|
+
setComposeOptions({ maxDepth: 6, parallel: true, setCache: true });
|
|
297
|
+
setTelemetry({ enabled: true, onEvent: (e) => console.debug('[pk]', e.type) });
|
|
298
|
+
|
|
299
|
+
const cache = new RedisCacheAdapter({ prefix: 'myapp' });
|
|
300
|
+
const registry = new RepositoryRegistry();
|
|
301
|
+
const autoCompose = new AutoComposer(registry);
|
|
302
|
+
|
|
303
|
+
const UserRepo = createRepository({
|
|
304
|
+
model: 'user',
|
|
305
|
+
scalarFields: Prisma.UserScalarFieldEnum,
|
|
306
|
+
cache: { ttl: 86_400 },
|
|
307
|
+
});
|
|
308
|
+
const users = new UserRepo({ prisma, cache, registry, autoCompose });
|
|
309
|
+
registry.register('user', users);
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Nest apps should use `PrismaKitModule` instead of wiring this by hand.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# PrismaKit review checklist
|
|
2
|
+
|
|
3
|
+
Copy this list and tick every item before marking a data-access change done. Failures are bugs.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Task Progress:
|
|
7
|
+
- [ ] Layering
|
|
8
|
+
- [ ] Repository factory
|
|
9
|
+
- [ ] Selects
|
|
10
|
+
- [ ] Cache
|
|
11
|
+
- [ ] Transactions
|
|
12
|
+
- [ ] Locks
|
|
13
|
+
- [ ] Compose
|
|
14
|
+
- [ ] ESLint / layout
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Layering
|
|
18
|
+
|
|
19
|
+
- [ ] No `PrismaClient` / `PrismaService` injected in services, helpers, controllers, processors, or queue workers
|
|
20
|
+
- [ ] No `prisma.<model>.*` outside `**/repositories/**`
|
|
21
|
+
- [ ] Controllers/route handlers call services only — not repositories (unless the app already uses a documented exception)
|
|
22
|
+
- [ ] Helpers inject repositories, never the Prisma client
|
|
23
|
+
|
|
24
|
+
## Repository factory
|
|
25
|
+
|
|
26
|
+
- [ ] New model access goes through `createRepository` (core) or the app's Nest `defineRepo` binder — not a one-off Prisma call
|
|
27
|
+
- [ ] File lives under `**/repositories/**` as `*.repository.ts`
|
|
28
|
+
- [ ] `model` is the Prisma client key (`'user'`, not `'User'`)
|
|
29
|
+
- [ ] `scalarFields` is set **or** Prisma meta is loaded at bootstrap (`dmmf` / `schemaPath`)
|
|
30
|
+
- [ ] Cached models are listed in `cacheModels` when the app uses an allowlist
|
|
31
|
+
|
|
32
|
+
## Selects
|
|
33
|
+
|
|
34
|
+
- [ ] Every read/write that returns a row passes an explicit `select`
|
|
35
|
+
- [ ] Select presets used: `minimal` (no cache), `general` (API, cacheable), `withPassword` (auth, never cached)
|
|
36
|
+
- [ ] Controllers do not construct `select` objects
|
|
37
|
+
- [ ] Sensitive fields (`password`, tokens) only appear in auth presets
|
|
38
|
+
|
|
39
|
+
## Cache
|
|
40
|
+
|
|
41
|
+
- [ ] User-facing `getById` / `getThrowById` / `getMany` / `getManyPaginate` pass `setCache: true` when the repo has `cache` (unless `defaultSetCache: true`)
|
|
42
|
+
- [ ] Auth / uniqueness / JWT `getFirst` does **not** pass `setCache: true`
|
|
43
|
+
- [ ] No `setCache` on selects that include `sensitiveFields`
|
|
44
|
+
- [ ] `setCache` / `invalidateCache` not passed on repos that omit `cache` config (types omit those fields)
|
|
45
|
+
- [ ] Query lists that must drop together use `cacheTags` on read and `tags` on write
|
|
46
|
+
|
|
47
|
+
## Transactions
|
|
48
|
+
|
|
49
|
+
- [ ] Multi-step writes share one transaction; `tx` is passed into **every** repo call in that unit of work
|
|
50
|
+
- [ ] Nest feature code uses `TransactionService.execTx`, not `prisma.$transaction`
|
|
51
|
+
- [ ] Writes inside tx use `invalidate: 'none'`
|
|
52
|
+
- [ ] `invalidateCache` runs after commit (`afterCommit` in Nest, or immediately after `$transaction` in plain Node)
|
|
53
|
+
- [ ] No cached reads mixed with half-committed writes
|
|
54
|
+
|
|
55
|
+
## Locks
|
|
56
|
+
|
|
57
|
+
- [ ] Repo declares `lock` (`true`, client key, table name, or config) before any call passes `lock`
|
|
58
|
+
- [ ] Every `lock: { mode }` call also passes `tx`
|
|
59
|
+
- [ ] `skipLocked` is not combined with `nowait`
|
|
60
|
+
- [ ] Locked transactions stay short
|
|
61
|
+
|
|
62
|
+
## Compose
|
|
63
|
+
|
|
64
|
+
- [ ] Relations are nested objects in `select`, not Prisma `include`
|
|
65
|
+
- [ ] Related model repositories are registered (or `autoRegisterModels` covers them)
|
|
66
|
+
- [ ] `npx prismakit validate` (or `validateCompose: true`) is clean when selects nest relations
|
|
67
|
+
|
|
68
|
+
## ESLint / layout
|
|
69
|
+
|
|
70
|
+
- [ ] `@prismakit/eslint-plugin` `recommended` is in the app ESLint config
|
|
71
|
+
- [ ] Lint passes on the changed files
|
|
72
|
+
- [ ] Prisma client construction stays under `**/infrastructure/prisma/**` (or equivalent allowlisted folder)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prismakit-nestjs
|
|
3
|
+
description: >-
|
|
4
|
+
PrismaKit NestJS adapter: PrismaKitModule.forRoot/forRootAsync wiring, injectable repositories via
|
|
5
|
+
createDefineRepo/defineInjectableRepository, TransactionService.execTx with afterCommit invalidation,
|
|
6
|
+
and PRISMAKIT_* DI tokens. Use when working with @prismakit/nestjs, Nest feature modules that need
|
|
7
|
+
Prisma data access, or Nest transactions and cache invalidation.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# PrismaKit NestJS
|
|
11
|
+
|
|
12
|
+
Adapter for NestJS. The data-access contract (layering, cache, compose, locks, ESLint) lives in skill `prismakit` — follow it. This skill covers module wiring, injectable repos, and `TransactionService`.
|
|
13
|
+
|
|
14
|
+
## Bootstrap
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @prismakit/core @prismakit/nestjs
|
|
18
|
+
pnpm add @prismakit/redis ioredis
|
|
19
|
+
pnpm add -D @prismakit/eslint-plugin @prismakit/cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { Module } from '@nestjs/common';
|
|
24
|
+
import { PrismaKitModule } from '@prismakit/nestjs';
|
|
25
|
+
import { RedisCacheAdapter } from '@prismakit/redis';
|
|
26
|
+
import { Prisma } from '@prisma/client';
|
|
27
|
+
|
|
28
|
+
@Module({
|
|
29
|
+
imports: [
|
|
30
|
+
PrismaKitModule.forRoot({
|
|
31
|
+
prisma: prismaClient,
|
|
32
|
+
cache: new RedisCacheAdapter({ prefix: 'myapp' }),
|
|
33
|
+
cacheModels: ['user', 'product'],
|
|
34
|
+
schemaPath: 'prisma/schema.prisma', // default; Prisma 5/6: dmmf: Prisma.dmmf
|
|
35
|
+
validateCompose: true,
|
|
36
|
+
compose: { maxDepth: 6, parallel: true, setCache: true },
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
export class AppModule {}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`forRootAsync` when cache/URL come from `ConfigService` — see [examples.md](examples.md).
|
|
44
|
+
|
|
45
|
+
`schemaPath` defaults to `prisma/schema.prisma`. Always load Prisma meta (`dmmf` or `schemaPath`) so auto-compose and `lock: true` resolve FKs/`@@map` from the schema — no relation-alias map.
|
|
46
|
+
|
|
47
|
+
## Factory (one default)
|
|
48
|
+
|
|
49
|
+
Bind `Prisma.TypeMap` once, then define repos with runtime options only.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// src/infrastructure/prisma/define-repo.ts
|
|
53
|
+
import { createDefineRepo } from '@prismakit/nestjs';
|
|
54
|
+
import type { Prisma } from '@prisma/client';
|
|
55
|
+
|
|
56
|
+
export const defineRepo = createDefineRepo<Prisma.TypeMap>();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// src/modules/users/repositories/user.repository.ts
|
|
61
|
+
import { Prisma } from '@prisma/client';
|
|
62
|
+
import { defineRepo } from '../../../infrastructure/prisma/define-repo';
|
|
63
|
+
|
|
64
|
+
const DAY = 86_400;
|
|
65
|
+
|
|
66
|
+
export const UserRepository = defineRepo({
|
|
67
|
+
model: 'user',
|
|
68
|
+
scalarFields: Prisma.UserScalarFieldEnum,
|
|
69
|
+
cache: { ttl: DAY, nullTtl: 60, sensitiveFields: ['password'] },
|
|
70
|
+
lock: true,
|
|
71
|
+
});
|
|
72
|
+
export type UserRepository = InstanceType<typeof UserRepository>;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Escape hatches (do not use as the app default):
|
|
76
|
+
|
|
77
|
+
- `defineInjectableRepository` from `@prismakit/nestjs` (package alias `defineRepository`) — phantoms + payload HKT when TypeMap is unavailable.
|
|
78
|
+
- `createInjectableRepository` without a types bag — thin, results are `unknown`. Alias: `createPrismaRepository`.
|
|
79
|
+
|
|
80
|
+
Do not import `defineRepo` from `@prismakit/nestjs` in apps that already bind `createDefineRepo` as `defineRepo`.
|
|
81
|
+
|
|
82
|
+
## Register and inject
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
@Module({
|
|
86
|
+
controllers: [UserController],
|
|
87
|
+
providers: [UserService, UserRepository],
|
|
88
|
+
exports: [UserService, UserRepository],
|
|
89
|
+
})
|
|
90
|
+
export class UserModule {}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
@Injectable()
|
|
95
|
+
export class UserService {
|
|
96
|
+
constructor(private readonly users: UserRepository) {}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Do not** inject `PRISMAKIT_PRISMA`, `PrismaClient`, or `PrismaService` in services, helpers, controllers, or processors. The prisma token exists so injectable repositories can wire `RepositoryDeps`.
|
|
101
|
+
|
|
102
|
+
Rare custom repos (dashboard/health) may inject `PRISMAKIT_PRISMA` **only** if the class lives under `**/repositories/**`.
|
|
103
|
+
|
|
104
|
+
## Transactions
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { TransactionService } from '@prismakit/nestjs';
|
|
108
|
+
|
|
109
|
+
constructor(
|
|
110
|
+
private readonly tx: TransactionService,
|
|
111
|
+
private readonly orders: OrderRepository,
|
|
112
|
+
private readonly stocks: StockRepository,
|
|
113
|
+
) {}
|
|
114
|
+
|
|
115
|
+
await this.tx.execTx(
|
|
116
|
+
async (tx) => {
|
|
117
|
+
const order = await this.orders.create({
|
|
118
|
+
tx,
|
|
119
|
+
data: { /* ... */ },
|
|
120
|
+
select: { id: true },
|
|
121
|
+
invalidate: 'none',
|
|
122
|
+
});
|
|
123
|
+
await this.stocks.updateById({
|
|
124
|
+
tx,
|
|
125
|
+
id: input.stockId,
|
|
126
|
+
data: { qty: { decrement: input.qty } },
|
|
127
|
+
invalidate: 'none',
|
|
128
|
+
});
|
|
129
|
+
return order;
|
|
130
|
+
},
|
|
131
|
+
async () => {
|
|
132
|
+
await this.orders.invalidateCache({});
|
|
133
|
+
await this.stocks.invalidateCache({ id: input.stockId });
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Rules:
|
|
139
|
+
|
|
140
|
+
- `TransactionService.execTx(fn, afterCommit?, options?)` — never `prisma.$transaction` in feature code.
|
|
141
|
+
- Pass `tx` into every repo call in the unit of work.
|
|
142
|
+
- Writes inside tx: `invalidate: 'none'`.
|
|
143
|
+
- `afterCommit` runs only after `$transaction` resolves.
|
|
144
|
+
- Keep transactions short. Do not mix cached reads with half-committed writes.
|
|
145
|
+
|
|
146
|
+
Optional third argument: `{ maxWait, timeout, isolationLevel }`.
|
|
147
|
+
|
|
148
|
+
## Row locks
|
|
149
|
+
|
|
150
|
+
Repo must have `lock` config. Call must pass `tx`.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
await this.tx.execTx(
|
|
154
|
+
async (tx) => {
|
|
155
|
+
const wallet = await this.wallets.getById({
|
|
156
|
+
tx,
|
|
157
|
+
id,
|
|
158
|
+
select: { id: true, balance: true },
|
|
159
|
+
lock: { mode: 'update' },
|
|
160
|
+
});
|
|
161
|
+
await this.wallets.updateById({
|
|
162
|
+
tx,
|
|
163
|
+
id,
|
|
164
|
+
data: { balance: wallet!.balance - amount },
|
|
165
|
+
invalidate: 'none',
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
async () => {
|
|
169
|
+
await this.wallets.invalidateCache({ id });
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Cache typing DX
|
|
175
|
+
|
|
176
|
+
When the repo options include `cache`, TypeScript exposes `setCache`, `cacheTags`, mutation `invalidate`/`tags`, and `invalidateCache`. Without `cache`, those fields are omitted — do not pass them.
|
|
177
|
+
|
|
178
|
+
Production: set `cacheModels` to the same keys that enable `cache` on repositories. Omit = fail-open.
|
|
179
|
+
|
|
180
|
+
`cache.defaultSetCache: true` makes user-facing reads cache by default; still pass `setCache: false` on auth/uniqueness.
|
|
181
|
+
|
|
182
|
+
## What the module provides
|
|
183
|
+
|
|
184
|
+
| Token / provider | Who may inject |
|
|
185
|
+
|------------------|----------------|
|
|
186
|
+
| `TransactionService` | Services / helpers |
|
|
187
|
+
| `RepositoryRegistry` | Kit internals / compose |
|
|
188
|
+
| `AutoComposer` | Kit internals / compose |
|
|
189
|
+
| `PRISMAKIT_PRISMA` | **Repositories only** |
|
|
190
|
+
| `PRISMAKIT_CACHE` | Repositories / kit internals |
|
|
191
|
+
| `PRISMAKIT_OPTIONS` | Kit internals |
|
|
192
|
+
|
|
193
|
+
## Ops
|
|
194
|
+
|
|
195
|
+
| Option | Use |
|
|
196
|
+
|--------|-----|
|
|
197
|
+
| `cacheModels` | Strict allowlist of cached model keys |
|
|
198
|
+
| `validateCompose: true` | Assert compose-safe selects on boot |
|
|
199
|
+
| `compose` | `{ maxDepth, parallel, setCache }` |
|
|
200
|
+
| `telemetry` | `{ enabled: true, onEvent }` |
|
|
201
|
+
| `queryLog` | `{ slowThreshold, onSlowQuery }` — enables telemetry |
|
|
202
|
+
| `autoRegisterModels` | `true` or `string[]` — stub repos for compose-only models |
|
|
203
|
+
|
|
204
|
+
## Scaffolding
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npx prismakit generate product --cache
|
|
208
|
+
npx prismakit generate product --cache --full --route products
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Repo-only: add the class to feature `providers`. `--full`: import `*Module` in `AppModule`. Then `npx prismakit validate`.
|
|
212
|
+
|
|
213
|
+
Enable ESLint `prismakit.configs.recommended` (see skill `prismakit`).
|
|
214
|
+
|
|
215
|
+
## Clean code (Nest)
|
|
216
|
+
|
|
217
|
+
- One `defineRepo` binder under `src/infrastructure/prisma/`. Do not call `createDefineRepo` per feature.
|
|
218
|
+
- Feature modules own their repository providers; do not make every repo global.
|
|
219
|
+
- Controllers stay HTTP-only: map DTO → service method. No repository calls in controllers.
|
|
220
|
+
- Select presets live next to the repository (`minimal` / `general` / `withPassword`).
|
|
221
|
+
- Name TTL constants once; reuse in repo `cache` config.
|
|
222
|
+
|
|
223
|
+
## Anti-patterns (BAD → GOOD)
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// BAD
|
|
227
|
+
constructor(private readonly prisma: PrismaClient) {}
|
|
228
|
+
constructor(@Inject(PRISMAKIT_PRISMA) private readonly prisma: PrismaClient) {}
|
|
229
|
+
await this.prisma.$transaction(async (tx) => { /* ... */ });
|
|
230
|
+
await this.products.updateById({ tx, id, data }); // invalidates inside tx
|
|
231
|
+
await this.users.getFirst({ where: { email }, select: { id: true }, setCache: true });
|
|
232
|
+
|
|
233
|
+
// GOOD
|
|
234
|
+
constructor(
|
|
235
|
+
private readonly tx: TransactionService,
|
|
236
|
+
private readonly products: ProductRepository,
|
|
237
|
+
) {}
|
|
238
|
+
await this.tx.execTx(
|
|
239
|
+
async (tx) => {
|
|
240
|
+
await this.products.updateById({ tx, id, data, invalidate: 'none' });
|
|
241
|
+
},
|
|
242
|
+
async () => {
|
|
243
|
+
await this.products.invalidateCache({ id });
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
await this.users.getFirst({ where: { email }, select: { id: true } });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Before you finish
|
|
250
|
+
|
|
251
|
+
Copy and complete [review-checklist.md](review-checklist.md).
|
|
252
|
+
|
|
253
|
+
- Module options and tokens: [reference.md](reference.md)
|
|
254
|
+
- Production wiring snippets: [examples.md](examples.md)
|
|
255
|
+
- Core contract: skill `prismakit`
|