@prismakit/cli 2.2.3 → 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.
@@ -0,0 +1,282 @@
1
+ ---
2
+ name: prismakit
3
+ description: >-
4
+ PrismaKit data-access contract for Prisma apps: repository-only access via createRepository,
5
+ cache-aside with setCache/invalidate, auto-compose relations, row locks, telemetry, prismakit CLI,
6
+ and @prismakit/eslint-plugin. Use when working with @prismakit/core, files under repositories/,
7
+ CacheAdapter/Redis caching of Prisma reads, or when code injects PrismaClient outside a repository.
8
+ ---
9
+
10
+ # PrismaKit
11
+
12
+ Framework-agnostic Prisma repository kit. Not a Prisma fork. Only repositories talk to Prisma.
13
+
14
+ NestJS apps: also load skill `prismakit-nestjs` after this contract.
15
+
16
+ ## Non-negotiables
17
+
18
+ Violations are bugs. Enforce with `@prismakit/eslint-plugin` + this skill.
19
+
20
+ | Forbidden | Why |
21
+ |-----------|-----|
22
+ | `PrismaClient` / `PrismaService` in services, helpers, controllers, processors | Bypasses cache, compose, invalidation |
23
+ | `prisma.<model>.*` outside `**/repositories/**` | Same |
24
+ | `$transaction` in Nest feature code | Use `TransactionService.execTx` |
25
+ | `setCache: true` on auth / uniqueness `getFirst` | Stale nulls / race hazards |
26
+ | Caching selects with `password` (or other `sensitiveFields`) | Security |
27
+ | Row `lock` without `tx` | Lock must live inside a transaction |
28
+ | Prisma `include` for relations | Use relation keys in `select` (auto-compose) |
29
+
30
+ | Required | How |
31
+ |----------|-----|
32
+ | Reads/writes | `*Repository` from `createRepository` / Nest factories |
33
+ | Tx writes | `invalidate: 'none'` then `invalidateCache` after commit |
34
+ | User-facing reads | `setCache: true` when the repo has cache config |
35
+ | Relations in `select` | `model` + `scalarFields` or loaded Prisma meta |
36
+ | ESLint | `prismakit.configs.recommended` |
37
+
38
+ ## Layers
39
+
40
+ ```
41
+ Controller / route handler → Service → Helper → Repository → Prisma / CacheAdapter
42
+ ```
43
+
44
+ Helpers may inject repositories — never the Prisma client.
45
+
46
+ ## Decision table
47
+
48
+ | Task | Use |
49
+ |------|-----|
50
+ | New repository | `createRepository` (core) or Nest `defineRepo` — see `prismakit-nestjs` |
51
+ | User-facing get by id | `getThrowById` / `getById` + `setCache: true` |
52
+ | Existence / uniqueness / auth | `getFirst` — **no** `setCache` |
53
+ | List | `getMany` + `setCache: true` + optional `cacheTags` |
54
+ | Paginated list | `getManyPaginate` |
55
+ | Create / update / delete | matching mutation; default invalidation is enough outside tx |
56
+ | Multi-step write | one transaction; pass `tx` into every repo call |
57
+ | `SELECT … FOR UPDATE` | `lock: { mode: 'update' }` **inside** `tx` |
58
+ | Load relations | nested objects in `select`, not Prisma `include` |
59
+
60
+ ## Create a repository (core)
61
+
62
+ ```typescript
63
+ import { Prisma, PrismaClient } from '@prisma/client';
64
+ import { createRepository } from '@prismakit/core';
65
+ import { RedisCacheAdapter } from '@prismakit/redis';
66
+
67
+ const DAY = 86_400;
68
+
69
+ const UserRepoClass = createRepository({
70
+ model: 'user',
71
+ scalarFields: Prisma.UserScalarFieldEnum,
72
+ cache: { ttl: DAY, nullTtl: 60, sensitiveFields: ['password'] },
73
+ lock: true, // table + columns from Prisma meta
74
+ });
75
+
76
+ const prisma = new PrismaClient();
77
+ const cache = new RedisCacheAdapter({ prefix: 'myapp' });
78
+ export const users = new UserRepoClass({ prisma, cache });
79
+ ```
80
+
81
+ `defineRepository` and `createPrismaRepository` are aliases of `createRepository`.
82
+
83
+ | Option | Description |
84
+ |--------|-------------|
85
+ | `model` | Prisma client key (`prisma.user` → `'user'`). Needed for cache + compose. |
86
+ | `scalarFields` | Usually `Prisma.XScalarFieldEnum`. Enables select-split + compose. Optional if DMMF/schema meta is loaded. |
87
+ | `cache` | `CacheOptions` or `true` → `{ ttl: 86400, sensitiveFields: ['password'] }`. |
88
+ | `lock` | `true` / client key / `@@map` table / `{ tableName, columns }`. |
89
+ | `primaryKey` | `string` or `string[]` (composite). Defaults to meta PK or `id`. |
90
+ | `schemaPath` | Path to `schema.prisma` when meta is not loaded globally. |
91
+ | `getDelegate` | Optional. Defaults to `(c) => c[model]`. |
92
+
93
+ Put files under `**/repositories/**`. Keep Prisma client construction under `**/infrastructure/prisma/**`.
94
+
95
+ ## Reads
96
+
97
+ Always pass an explicit `select`. Never rely on “fetch all columns”.
98
+
99
+ ```typescript
100
+ await users.getThrowById({
101
+ id,
102
+ select: { id: true, email: true, name: true },
103
+ setCache: true,
104
+ });
105
+
106
+ await users.getFirst({
107
+ where: { email },
108
+ select: { id: true, password: true },
109
+ // no setCache — auth / uniqueness
110
+ });
111
+ ```
112
+
113
+ | Method | Returns |
114
+ |--------|---------|
115
+ | `getById` | payload or `null` |
116
+ | `getThrowById` | payload; throws if missing |
117
+ | `getFirst` | first match or `null` |
118
+ | `getMany` | array (`take` / `skip` / `orderBy`) |
119
+ | `getManyPaginate` | `{ data, meta: { page, pageSize, totalItems, totalPages } }` |
120
+
121
+ `id` is `string` or `Record<string, string>` for composite PKs.
122
+
123
+ ## Writes
124
+
125
+ | Method | Default `invalidate` |
126
+ |--------|----------------------|
127
+ | `create` / `createMany` | `queries` |
128
+ | `updateById` / `updateMany` / `upsert` / `deleteById` / `deleteMany` | `all` |
129
+
130
+ ```typescript
131
+ await users.updateById({
132
+ id,
133
+ data: { name: 'Ada' },
134
+ select: { id: true, name: true },
135
+ });
136
+ ```
137
+
138
+ ## Cache
139
+
140
+ A read uses the cache **only when all of these are true**:
141
+
142
+ 1. `setCache: true` (or repo `cache.defaultSetCache: true` and caller did not pass `false`)
143
+ 2. Repository has `model` + `cache` config
144
+ 3. No `tx` (transactions never cache)
145
+ 4. Method is not disabled in `cache.methods`
146
+ 5. `select` does not include a sensitive field (default includes `password`)
147
+
148
+ If Redis is down, `RedisCacheAdapter` **fails open** — queries still hit Prisma.
149
+
150
+ | Scenario | `setCache` |
151
+ |----------|------------|
152
+ | API detail / list | `true` |
153
+ | Auth, uniqueness, JWT lookup | omit / `false` |
154
+ | Inside `tx` | ignored |
155
+
156
+ Empty `cacheModels` allowlist is **fail-open**. In production, register allowed model keys; a cached repo whose model is missing from the list throws at init.
157
+
158
+ `setCache` / `cacheTags` / `invalidate` / `invalidateCache` exist on the type **only** when the repo has `cache` config. Do not force them on uncached repos.
159
+
160
+ ## Transactions (plain Node)
161
+
162
+ ```typescript
163
+ await prisma.$transaction(async (tx) => {
164
+ await users.updateById({ tx, id, data, invalidate: 'none' });
165
+ });
166
+ await users.invalidateCache({ id });
167
+ ```
168
+
169
+ Wrap this in an app helper so call sites stay consistent. Nest: `TransactionService.execTx` (skill `prismakit-nestjs`).
170
+
171
+ Never invalidate cache inside the transaction. If the tx rolls back, cache must still be valid.
172
+
173
+ ## Auto-compose
174
+
175
+ Relations in `select` load through other registered repositories — not Prisma `include`.
176
+
177
+ ```typescript
178
+ await posts.getThrowById({
179
+ id,
180
+ select: {
181
+ id: true,
182
+ title: true,
183
+ author: { select: { id: true, name: true } },
184
+ },
185
+ setCache: true,
186
+ });
187
+ ```
188
+
189
+ Requirements: source repo has `model`; `scalarFields` **or** Prisma meta loaded (`loadPrismaMetaFromDmmf(Prisma.dmmf)` on Prisma 5/6, `loadPrismaMetaFromSchema('prisma/schema.prisma')` on Prisma 7); related model repos are registered.
190
+
191
+ AutoComposer injects the target primary key into nested selects even if omitted. Relation field names resolve from schema / DMMF meta (`schemaPath` defaults to `prisma/schema.prisma`).
192
+
193
+ ## Row locks
194
+
195
+ Repo must declare `lock`. Call must pass `tx`. Default mode is `noKeyUpdate`. `skipLocked` cannot combine with `nowait`. Prefer short transactions.
196
+
197
+ ```typescript
198
+ await prisma.$transaction(async (tx) => {
199
+ const row = await wallets.getById({
200
+ tx,
201
+ id,
202
+ select: { id: true, balance: true },
203
+ lock: { mode: 'update' },
204
+ });
205
+ await wallets.updateById({
206
+ tx,
207
+ id,
208
+ data: { balance: row!.balance - amount },
209
+ invalidate: 'none',
210
+ });
211
+ });
212
+ await wallets.invalidateCache({ id });
213
+ ```
214
+
215
+ ## CLI and ESLint
216
+
217
+ ```bash
218
+ npx prismakit generate <name> --cache
219
+ npx prismakit generate <name> --cache --full --route <path>
220
+ npx prismakit validate --auto-register
221
+ ```
222
+
223
+ ```js
224
+ // eslint.config.mjs
225
+ import prismakit from '@prismakit/eslint-plugin';
226
+ export default [prismakit.configs.recommended];
227
+ ```
228
+
229
+ Allowed Prisma usage: `**/repositories/**`, `**/infrastructure/prisma/**`. Rules: `no-prisma-service-outside-repos`, `no-direct-prisma-delegate`, `require-transaction-service`.
230
+
231
+ ## Clean code
232
+
233
+ - One repository per Prisma model, file `*.repository.ts` under `repositories/`.
234
+ - Select presets as `satisfies Prisma.XSelect`: `minimal` (no cache), `general` (API, cacheable), `withPassword` (auth only, never cached). Keep presets next to the repo; controllers never build selects.
235
+ - Named TTL constants (`const DAY = 86_400`), not magic numbers scattered in call sites.
236
+ - Pass `tx` into **every** repo call in a unit of work. Do not mix cached reads with half-committed writes.
237
+ - Tests: `@prismakit/memory` `MemoryCacheAdapter`. Production: `@prismakit/redis`.
238
+
239
+ ## Anti-patterns (BAD → GOOD)
240
+
241
+ ```typescript
242
+ // BAD — Prisma in a service
243
+ constructor(private readonly prisma: PrismaClient) {}
244
+ await this.prisma.user.findUnique({ where: { id } });
245
+ await this.prisma.$transaction(async (tx) => { /* ... */ });
246
+ await this.prisma.post.findUnique({ include: { author: true } });
247
+ await users.getFirst({ where: { email }, select: { id: true }, setCache: true });
248
+ await users.getById({ id, select: { password: true }, setCache: true });
249
+ await wallets.getById({ id, select: { id: true }, lock: { mode: 'update' } }); // no tx
250
+ await users.updateById({ tx, id, data }); // auto-invalidate inside tx
251
+
252
+ // GOOD
253
+ constructor(private readonly users: UserRepository) {}
254
+ await this.users.getById({ id, select: { id: true, email: true }, setCache: true });
255
+ await prisma.$transaction(async (tx) => {
256
+ await this.users.updateById({ tx, id, data, invalidate: 'none' });
257
+ });
258
+ await this.users.invalidateCache({ id });
259
+ await this.posts.getById({
260
+ id,
261
+ select: { id: true, author: { select: { id: true, name: true } } },
262
+ setCache: true,
263
+ });
264
+ await this.users.getFirst({ where: { email }, select: { id: true } }); // no setCache
265
+ await this.users.getFirst({
266
+ where: { email },
267
+ select: { id: true, password: true }, // auth; never setCache
268
+ });
269
+ await prisma.$transaction(async (tx) => {
270
+ await this.wallets.getById({
271
+ tx, id, select: { id: true, balance: true }, lock: { mode: 'update' },
272
+ });
273
+ });
274
+ ```
275
+
276
+ ## Before you finish
277
+
278
+ Copy and complete [review-checklist.md](review-checklist.md).
279
+
280
+ - Method/options detail: [reference.md](reference.md)
281
+ - End-to-end snippets: [examples.md](examples.md)
282
+ - NestJS wiring: skill `prismakit-nestjs`
@@ -0,0 +1,238 @@
1
+ # PrismaKit core examples
2
+
3
+ Copy-paste patterns for `@prismakit/core`. Contract: [SKILL.md](SKILL.md).
4
+
5
+ ## 1. New repository from scratch
6
+
7
+ ```typescript
8
+ // src/modules/users/repositories/user.repository.ts
9
+ import { Prisma } from '@prisma/client';
10
+ import { createRepository } from '@prismakit/core';
11
+
12
+ const DAY = 86_400;
13
+
14
+ export const userSelectPresets = {
15
+ minimal: { id: true } satisfies Prisma.UserSelect,
16
+ general: { id: true, email: true, name: true } satisfies Prisma.UserSelect,
17
+ withPassword: {
18
+ id: true,
19
+ email: true,
20
+ password: true,
21
+ } satisfies Prisma.UserSelect,
22
+ };
23
+
24
+ export const UserRepoClass = createRepository({
25
+ model: 'user',
26
+ scalarFields: Prisma.UserScalarFieldEnum,
27
+ cache: {
28
+ ttl: DAY,
29
+ nullTtl: 60,
30
+ sensitiveFields: ['password'],
31
+ methods: {
32
+ getFirst: { enabled: false },
33
+ },
34
+ },
35
+ lock: true,
36
+ });
37
+
38
+ export type UserRepository = InstanceType<typeof UserRepoClass>;
39
+ ```
40
+
41
+ ```typescript
42
+ // src/infrastructure/prisma/repos.ts
43
+ import { PrismaClient } from '@prisma/client';
44
+ import { RedisCacheAdapter } from '@prismakit/redis';
45
+ import { UserRepoClass } from '../../modules/users/repositories/user.repository';
46
+
47
+ const prisma = new PrismaClient();
48
+ const cache = new RedisCacheAdapter({
49
+ url: process.env.REDIS_URL,
50
+ prefix: process.env.CACHE_PREFIX ?? 'myapp',
51
+ });
52
+
53
+ export const users = new UserRepoClass({ prisma, cache });
54
+ export { prisma };
55
+ ```
56
+
57
+ ## 2. User-facing read vs uniqueness check
58
+
59
+ ```typescript
60
+ import { users } from '../../infrastructure/prisma/repos';
61
+ import { userSelectPresets } from './repositories/user.repository';
62
+
63
+ export async function getProfile(id: string) {
64
+ return users.getThrowById({
65
+ id,
66
+ select: userSelectPresets.general,
67
+ setCache: true,
68
+ });
69
+ }
70
+
71
+ export async function assertEmailFree(email: string) {
72
+ const existing = await users.getFirst({
73
+ where: { email },
74
+ select: userSelectPresets.minimal,
75
+ // never setCache on uniqueness
76
+ });
77
+ if (existing) throw new Error('email taken');
78
+ }
79
+
80
+ export async function verifyLogin(email: string) {
81
+ return users.getFirst({
82
+ where: { email },
83
+ select: userSelectPresets.withPassword,
84
+ // never cache password selects
85
+ });
86
+ }
87
+ ```
88
+
89
+ ## 3. Paginated list with cache tags
90
+
91
+ ```typescript
92
+ const page = await products.getManyPaginate({
93
+ where: { categoryId, status: 'ACTIVE' },
94
+ select: { id: true, name: true, price: true },
95
+ orderBy: { createdAt: 'desc' },
96
+ page: 1,
97
+ pageSize: 20,
98
+ setCache: true,
99
+ cacheTags: [`category:${categoryId}`],
100
+ });
101
+ // page.data, page.meta.totalItems, page.meta.totalPages
102
+
103
+ await products.updateById({
104
+ id,
105
+ data: { price: 1999 },
106
+ select: { id: true },
107
+ tags: [`category:${categoryId}`],
108
+ });
109
+ ```
110
+
111
+ ## 4. Transaction + invalidate after commit
112
+
113
+ ```typescript
114
+ import { prisma, users } from '../../infrastructure/prisma/repos';
115
+
116
+ export async function renameUser(id: string, name: string) {
117
+ const result = await prisma.$transaction(async (tx) => {
118
+ return users.updateById({
119
+ tx,
120
+ id,
121
+ data: { name },
122
+ select: { id: true, name: true },
123
+ invalidate: 'none',
124
+ });
125
+ });
126
+ await users.invalidateCache({ id });
127
+ return result;
128
+ }
129
+ ```
130
+
131
+ Prefer wrapping `$transaction` + `afterCommit` in one app helper so every call site stays consistent.
132
+
133
+ ## 5. Transfer with row lock
134
+
135
+ ```typescript
136
+ export async function transfer(fromId: string, toId: string, amount: number) {
137
+ const moved = await prisma.$transaction(async (tx) => {
138
+ const from = await wallets.getById({
139
+ tx,
140
+ id: fromId,
141
+ select: { id: true, balance: true },
142
+ lock: { mode: 'update' },
143
+ });
144
+ const to = await wallets.getById({
145
+ tx,
146
+ id: toId,
147
+ select: { id: true, balance: true },
148
+ lock: { mode: 'update' },
149
+ });
150
+ if (!from || !to) throw new Error('wallet missing');
151
+ if (from.balance < amount) throw new Error('insufficient funds');
152
+
153
+ await wallets.updateById({
154
+ tx,
155
+ id: fromId,
156
+ data: { balance: from.balance - amount },
157
+ invalidate: 'none',
158
+ });
159
+ await wallets.updateById({
160
+ tx,
161
+ id: toId,
162
+ data: { balance: to.balance + amount },
163
+ invalidate: 'none',
164
+ });
165
+ return { fromId, toId, amount };
166
+ });
167
+
168
+ await wallets.invalidateCache({ id: fromId });
169
+ await wallets.invalidateCache({ id: toId });
170
+ return moved;
171
+ }
172
+ ```
173
+
174
+ ## 6. Nested select (auto-compose)
175
+
176
+ Related repositories must be constructed and registered before the read.
177
+
178
+ ```typescript
179
+ const post = await posts.getThrowById({
180
+ id,
181
+ select: {
182
+ id: true,
183
+ title: true,
184
+ author: { select: { name: true } }, // PK injected; no Prisma include
185
+ comments: {
186
+ select: {
187
+ id: true,
188
+ body: true,
189
+ author: { select: { name: true } },
190
+ },
191
+ },
192
+ },
193
+ setCache: true,
194
+ });
195
+ ```
196
+
197
+ ## 7. Unit test with MemoryCacheAdapter
198
+
199
+ ```typescript
200
+ import { createRepository } from '@prismakit/core';
201
+ import { MemoryCacheAdapter } from '@prismakit/memory';
202
+
203
+ const UserRepo = createRepository({
204
+ model: 'user',
205
+ scalarFields: { id: 'id', email: 'email', name: 'name' },
206
+ cache: { ttl: 60 },
207
+ });
208
+
209
+ const prisma = {
210
+ user: {
211
+ findUnique: async ({ where }: { where: { id: string } }) =>
212
+ where.id === 'u1' ? { id: 'u1', email: 'a@b.c', name: 'Ada' } : null,
213
+ findUniqueOrThrow: async ({ where }: { where: { id: string } }) => {
214
+ if (where.id !== 'u1') throw new Error('missing');
215
+ return { id: 'u1', email: 'a@b.c', name: 'Ada' };
216
+ },
217
+ },
218
+ };
219
+
220
+ const cache = new MemoryCacheAdapter({ prefix: 'test' });
221
+ const users = new UserRepo({ prisma, cache });
222
+
223
+ const select = { id: true, email: true, name: true };
224
+
225
+ const a = await users.getThrowById({ id: 'u1', select, setCache: true });
226
+ const b = await users.getThrowById({ id: 'u1', select, setCache: true }); // cache hit
227
+ ```
228
+
229
+ ## 8. Scaffold with CLI
230
+
231
+ ```bash
232
+ npx prismakit generate product --cache
233
+ npx prismakit generate product --cache --full --route products
234
+ npx prismakit generate product --prisma-import src/infrastructure/prisma/client --dry-run
235
+ npx prismakit validate --auto-register
236
+ ```
237
+
238
+ After repo-only generate: register the class (Nest `providers`, or `new RepoClass({ prisma, cache })` in plain Node). After `--full`: import the feature module in `app.module.ts`.