@malamute/ai-rules 1.0.0 → 1.2.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 +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/prisma/**"
|
|
4
|
+
- "**/db/**"
|
|
5
|
+
- "**/lib/db.ts"
|
|
6
|
+
- "**/lib/prisma.ts"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Next.js Database (Prisma)
|
|
10
|
+
|
|
11
|
+
## Prisma Setup
|
|
12
|
+
|
|
13
|
+
### Client Singleton
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// lib/prisma.ts
|
|
17
|
+
import { PrismaClient } from '@prisma/client';
|
|
18
|
+
|
|
19
|
+
const globalForPrisma = globalThis as unknown as {
|
|
20
|
+
prisma: PrismaClient | undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
|
24
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
28
|
+
globalForPrisma.prisma = prisma;
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Schema
|
|
33
|
+
|
|
34
|
+
```prisma
|
|
35
|
+
// prisma/schema.prisma
|
|
36
|
+
generator client {
|
|
37
|
+
provider = "prisma-client-js"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
datasource db {
|
|
41
|
+
provider = "postgresql"
|
|
42
|
+
url = env("DATABASE_URL")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
model User {
|
|
46
|
+
id String @id @default(cuid())
|
|
47
|
+
email String @unique
|
|
48
|
+
name String?
|
|
49
|
+
role Role @default(USER)
|
|
50
|
+
posts Post[]
|
|
51
|
+
createdAt DateTime @default(now())
|
|
52
|
+
updatedAt DateTime @updatedAt
|
|
53
|
+
|
|
54
|
+
@@index([email])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
model Post {
|
|
58
|
+
id String @id @default(cuid())
|
|
59
|
+
title String
|
|
60
|
+
slug String @unique
|
|
61
|
+
content String?
|
|
62
|
+
published Boolean @default(false)
|
|
63
|
+
author User @relation(fields: [authorId], references: [id])
|
|
64
|
+
authorId String
|
|
65
|
+
createdAt DateTime @default(now())
|
|
66
|
+
updatedAt DateTime @updatedAt
|
|
67
|
+
|
|
68
|
+
@@index([authorId])
|
|
69
|
+
@@index([slug])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
enum Role {
|
|
73
|
+
USER
|
|
74
|
+
ADMIN
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Query Patterns
|
|
79
|
+
|
|
80
|
+
### Server Components
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// app/users/page.tsx
|
|
84
|
+
import { prisma } from '@/lib/prisma';
|
|
85
|
+
|
|
86
|
+
export default async function UsersPage() {
|
|
87
|
+
const users = await prisma.user.findMany({
|
|
88
|
+
select: {
|
|
89
|
+
id: true,
|
|
90
|
+
name: true,
|
|
91
|
+
email: true,
|
|
92
|
+
_count: { select: { posts: true } },
|
|
93
|
+
},
|
|
94
|
+
orderBy: { createdAt: 'desc' },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<ul>
|
|
99
|
+
{users.map(user => (
|
|
100
|
+
<li key={user.id}>
|
|
101
|
+
{user.name} - {user._count.posts} posts
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### With Pagination
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// app/posts/page.tsx
|
|
113
|
+
import { prisma } from '@/lib/prisma';
|
|
114
|
+
|
|
115
|
+
type Props = {
|
|
116
|
+
searchParams: Promise<{ page?: string; limit?: string }>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default async function PostsPage({ searchParams }: Props) {
|
|
120
|
+
const { page = '1', limit = '10' } = await searchParams;
|
|
121
|
+
const pageNum = parseInt(page);
|
|
122
|
+
const limitNum = parseInt(limit);
|
|
123
|
+
|
|
124
|
+
const [posts, total] = await Promise.all([
|
|
125
|
+
prisma.post.findMany({
|
|
126
|
+
where: { published: true },
|
|
127
|
+
include: { author: { select: { name: true } } },
|
|
128
|
+
skip: (pageNum - 1) * limitNum,
|
|
129
|
+
take: limitNum,
|
|
130
|
+
orderBy: { createdAt: 'desc' },
|
|
131
|
+
}),
|
|
132
|
+
prisma.post.count({ where: { published: true } }),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const totalPages = Math.ceil(total / limitNum);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
<PostList posts={posts} />
|
|
140
|
+
<Pagination
|
|
141
|
+
currentPage={pageNum}
|
|
142
|
+
totalPages={totalPages}
|
|
143
|
+
/>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Server Actions
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// app/posts/actions.ts
|
|
153
|
+
'use server';
|
|
154
|
+
|
|
155
|
+
import { prisma } from '@/lib/prisma';
|
|
156
|
+
import { auth } from '@/auth';
|
|
157
|
+
import { revalidatePath } from 'next/cache';
|
|
158
|
+
import { z } from 'zod';
|
|
159
|
+
|
|
160
|
+
const createPostSchema = z.object({
|
|
161
|
+
title: z.string().min(1).max(200),
|
|
162
|
+
content: z.string().optional(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export async function createPost(formData: FormData) {
|
|
166
|
+
const session = await auth();
|
|
167
|
+
if (!session) throw new Error('Unauthorized');
|
|
168
|
+
|
|
169
|
+
const data = createPostSchema.parse({
|
|
170
|
+
title: formData.get('title'),
|
|
171
|
+
content: formData.get('content'),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const slug = data.title
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
177
|
+
.replace(/(^-|-$)/g, '');
|
|
178
|
+
|
|
179
|
+
const post = await prisma.post.create({
|
|
180
|
+
data: {
|
|
181
|
+
...data,
|
|
182
|
+
slug,
|
|
183
|
+
authorId: session.user.id,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
revalidatePath('/posts');
|
|
188
|
+
return post;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function deletePost(id: string) {
|
|
192
|
+
const session = await auth();
|
|
193
|
+
if (!session) throw new Error('Unauthorized');
|
|
194
|
+
|
|
195
|
+
const post = await prisma.post.findUnique({
|
|
196
|
+
where: { id },
|
|
197
|
+
select: { authorId: true },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (post?.authorId !== session.user.id) {
|
|
201
|
+
throw new Error('Forbidden');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await prisma.post.delete({ where: { id } });
|
|
205
|
+
revalidatePath('/posts');
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Transactions
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
export async function transferCredits(
|
|
213
|
+
fromUserId: string,
|
|
214
|
+
toUserId: string,
|
|
215
|
+
amount: number
|
|
216
|
+
) {
|
|
217
|
+
return prisma.$transaction(async (tx) => {
|
|
218
|
+
const sender = await tx.user.update({
|
|
219
|
+
where: { id: fromUserId },
|
|
220
|
+
data: { credits: { decrement: amount } },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (sender.credits < 0) {
|
|
224
|
+
throw new Error('Insufficient credits');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const recipient = await tx.user.update({
|
|
228
|
+
where: { id: toUserId },
|
|
229
|
+
data: { credits: { increment: amount } },
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await tx.transaction.create({
|
|
233
|
+
data: {
|
|
234
|
+
fromUserId,
|
|
235
|
+
toUserId,
|
|
236
|
+
amount,
|
|
237
|
+
type: 'TRANSFER',
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return { sender, recipient };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Migrations
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
# Create migration
|
|
250
|
+
npx prisma migrate dev --name add_user_role
|
|
251
|
+
|
|
252
|
+
# Apply migrations (production)
|
|
253
|
+
npx prisma migrate deploy
|
|
254
|
+
|
|
255
|
+
# Reset database (development)
|
|
256
|
+
npx prisma migrate reset
|
|
257
|
+
|
|
258
|
+
# Generate client
|
|
259
|
+
npx prisma generate
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Seeding
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// prisma/seed.ts
|
|
266
|
+
import { PrismaClient } from '@prisma/client';
|
|
267
|
+
|
|
268
|
+
const prisma = new PrismaClient();
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
// Clean up
|
|
272
|
+
await prisma.post.deleteMany();
|
|
273
|
+
await prisma.user.deleteMany();
|
|
274
|
+
|
|
275
|
+
// Create users
|
|
276
|
+
const alice = await prisma.user.create({
|
|
277
|
+
data: {
|
|
278
|
+
email: 'alice@example.com',
|
|
279
|
+
name: 'Alice',
|
|
280
|
+
role: 'ADMIN',
|
|
281
|
+
posts: {
|
|
282
|
+
create: [
|
|
283
|
+
{ title: 'First Post', slug: 'first-post', published: true },
|
|
284
|
+
{ title: 'Second Post', slug: 'second-post' },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
console.log({ alice });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
main()
|
|
294
|
+
.catch(console.error)
|
|
295
|
+
.finally(() => prisma.$disconnect());
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
// package.json
|
|
300
|
+
{
|
|
301
|
+
"prisma": {
|
|
302
|
+
"seed": "tsx prisma/seed.ts"
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Soft Deletes
|
|
308
|
+
|
|
309
|
+
```prisma
|
|
310
|
+
model User {
|
|
311
|
+
id String @id @default(cuid())
|
|
312
|
+
email String @unique
|
|
313
|
+
deletedAt DateTime?
|
|
314
|
+
|
|
315
|
+
@@index([deletedAt])
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// Middleware for soft deletes
|
|
321
|
+
prisma.$use(async (params, next) => {
|
|
322
|
+
if (params.model === 'User') {
|
|
323
|
+
if (params.action === 'delete') {
|
|
324
|
+
params.action = 'update';
|
|
325
|
+
params.args['data'] = { deletedAt: new Date() };
|
|
326
|
+
}
|
|
327
|
+
if (params.action === 'findMany' || params.action === 'findFirst') {
|
|
328
|
+
params.args['where'] = {
|
|
329
|
+
...params.args['where'],
|
|
330
|
+
deletedAt: null,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return next(params);
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Optimization
|
|
339
|
+
|
|
340
|
+
### Select Only Needed Fields
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// BAD: Fetches all fields
|
|
344
|
+
const users = await prisma.user.findMany();
|
|
345
|
+
|
|
346
|
+
// GOOD: Select only needed
|
|
347
|
+
const users = await prisma.user.findMany({
|
|
348
|
+
select: { id: true, name: true, email: true },
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Avoid N+1
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
// BAD: N+1 query
|
|
356
|
+
const posts = await prisma.post.findMany();
|
|
357
|
+
for (const post of posts) {
|
|
358
|
+
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// GOOD: Include relation
|
|
362
|
+
const posts = await prisma.post.findMany({
|
|
363
|
+
include: { author: { select: { name: true } } },
|
|
364
|
+
});
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Connection Pooling (Serverless)
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// For serverless (Vercel, AWS Lambda)
|
|
371
|
+
datasource db {
|
|
372
|
+
provider = "postgresql"
|
|
373
|
+
url = env("DATABASE_URL")
|
|
374
|
+
directUrl = env("DIRECT_URL") // For migrations
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Use connection pooler (PgBouncer, Prisma Accelerate)
|
|
378
|
+
// DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=..."
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Anti-patterns
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
// BAD: Creating client in component
|
|
385
|
+
export default async function Page() {
|
|
386
|
+
const prisma = new PrismaClient(); // New connection each request!
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// GOOD: Use singleton
|
|
390
|
+
import { prisma } from '@/lib/prisma';
|
|
391
|
+
|
|
392
|
+
// BAD: Not handling errors
|
|
393
|
+
const user = await prisma.user.findUnique({ where: { id } });
|
|
394
|
+
return user.name; // Might be null!
|
|
395
|
+
|
|
396
|
+
// GOOD: Handle null
|
|
397
|
+
const user = await prisma.user.findUnique({ where: { id } });
|
|
398
|
+
if (!user) notFound();
|
|
399
|
+
return user.name;
|
|
400
|
+
```
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "middleware.ts"
|
|
4
|
+
- "src/middleware.ts"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Next.js Middleware
|
|
8
|
+
|
|
9
|
+
## Basic Structure
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// middleware.ts
|
|
13
|
+
import { NextResponse } from 'next/server';
|
|
14
|
+
import type { NextRequest } from 'next/server';
|
|
15
|
+
|
|
16
|
+
export function middleware(request: NextRequest) {
|
|
17
|
+
// Runs on every matched request
|
|
18
|
+
return NextResponse.next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const config = {
|
|
22
|
+
matcher: [
|
|
23
|
+
// Match all paths except static files
|
|
24
|
+
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Common Patterns
|
|
30
|
+
|
|
31
|
+
### Authentication
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { NextResponse } from 'next/server';
|
|
35
|
+
import type { NextRequest } from 'next/server';
|
|
36
|
+
import { getToken } from 'next-auth/jwt';
|
|
37
|
+
|
|
38
|
+
export async function middleware(request: NextRequest) {
|
|
39
|
+
const token = await getToken({ req: request });
|
|
40
|
+
const { pathname } = request.nextUrl;
|
|
41
|
+
|
|
42
|
+
// Public paths
|
|
43
|
+
const publicPaths = ['/login', '/register', '/api/auth'];
|
|
44
|
+
if (publicPaths.some(path => pathname.startsWith(path))) {
|
|
45
|
+
return NextResponse.next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Protected paths
|
|
49
|
+
if (!token) {
|
|
50
|
+
const loginUrl = new URL('/login', request.url);
|
|
51
|
+
loginUrl.searchParams.set('callbackUrl', pathname);
|
|
52
|
+
return NextResponse.redirect(loginUrl);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return NextResponse.next();
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Role-Based Access
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
export async function middleware(request: NextRequest) {
|
|
63
|
+
const token = await getToken({ req: request });
|
|
64
|
+
const { pathname } = request.nextUrl;
|
|
65
|
+
|
|
66
|
+
// Admin routes
|
|
67
|
+
if (pathname.startsWith('/admin')) {
|
|
68
|
+
if (token?.role !== 'admin') {
|
|
69
|
+
return NextResponse.redirect(new URL('/forbidden', request.url));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// API admin routes
|
|
74
|
+
if (pathname.startsWith('/api/admin')) {
|
|
75
|
+
if (token?.role !== 'admin') {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: 'Forbidden' },
|
|
78
|
+
{ status: 403 }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return NextResponse.next();
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Internationalization (i18n)
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { NextResponse } from 'next/server';
|
|
91
|
+
import type { NextRequest } from 'next/server';
|
|
92
|
+
import { match } from '@formatjs/intl-localematcher';
|
|
93
|
+
import Negotiator from 'negotiator';
|
|
94
|
+
|
|
95
|
+
const locales = ['en', 'fr', 'de'];
|
|
96
|
+
const defaultLocale = 'en';
|
|
97
|
+
|
|
98
|
+
function getLocale(request: NextRequest): string {
|
|
99
|
+
const headers = { 'accept-language': request.headers.get('accept-language') || '' };
|
|
100
|
+
const languages = new Negotiator({ headers }).languages();
|
|
101
|
+
return match(languages, locales, defaultLocale);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function middleware(request: NextRequest) {
|
|
105
|
+
const { pathname } = request.nextUrl;
|
|
106
|
+
|
|
107
|
+
// Check if pathname has locale
|
|
108
|
+
const pathnameHasLocale = locales.some(
|
|
109
|
+
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (pathnameHasLocale) return NextResponse.next();
|
|
113
|
+
|
|
114
|
+
// Redirect to locale
|
|
115
|
+
const locale = getLocale(request);
|
|
116
|
+
request.nextUrl.pathname = `/${locale}${pathname}`;
|
|
117
|
+
return NextResponse.redirect(request.nextUrl);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const config = {
|
|
121
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
122
|
+
};
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Rate Limiting
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { NextResponse } from 'next/server';
|
|
129
|
+
import type { NextRequest } from 'next/server';
|
|
130
|
+
import { Ratelimit } from '@upstash/ratelimit';
|
|
131
|
+
import { Redis } from '@upstash/redis';
|
|
132
|
+
|
|
133
|
+
const ratelimit = new Ratelimit({
|
|
134
|
+
redis: Redis.fromEnv(),
|
|
135
|
+
limiter: Ratelimit.slidingWindow(10, '10 s'),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export async function middleware(request: NextRequest) {
|
|
139
|
+
if (request.nextUrl.pathname.startsWith('/api')) {
|
|
140
|
+
const ip = request.ip ?? '127.0.0.1';
|
|
141
|
+
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
|
|
142
|
+
|
|
143
|
+
if (!success) {
|
|
144
|
+
return NextResponse.json(
|
|
145
|
+
{ error: 'Too many requests' },
|
|
146
|
+
{
|
|
147
|
+
status: 429,
|
|
148
|
+
headers: {
|
|
149
|
+
'X-RateLimit-Limit': limit.toString(),
|
|
150
|
+
'X-RateLimit-Remaining': remaining.toString(),
|
|
151
|
+
'X-RateLimit-Reset': reset.toString(),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return NextResponse.next();
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Geolocation Redirect
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
export function middleware(request: NextRequest) {
|
|
166
|
+
const country = request.geo?.country || 'US';
|
|
167
|
+
|
|
168
|
+
// Redirect EU users to EU subdomain
|
|
169
|
+
const euCountries = ['DE', 'FR', 'IT', 'ES', 'NL'];
|
|
170
|
+
if (euCountries.includes(country) && !request.nextUrl.hostname.includes('eu.')) {
|
|
171
|
+
return NextResponse.redirect(
|
|
172
|
+
new URL(request.nextUrl.pathname, 'https://eu.example.com')
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return NextResponse.next();
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Security Headers
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
export function middleware(request: NextRequest) {
|
|
184
|
+
const response = NextResponse.next();
|
|
185
|
+
|
|
186
|
+
// Security headers
|
|
187
|
+
response.headers.set('X-Frame-Options', 'DENY');
|
|
188
|
+
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
189
|
+
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
190
|
+
response.headers.set(
|
|
191
|
+
'Content-Security-Policy',
|
|
192
|
+
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
|
|
193
|
+
);
|
|
194
|
+
response.headers.set(
|
|
195
|
+
'Permissions-Policy',
|
|
196
|
+
'camera=(), microphone=(), geolocation=()'
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return response;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Request/Response Modification
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
export function middleware(request: NextRequest) {
|
|
207
|
+
// Add custom header to request
|
|
208
|
+
const requestHeaders = new Headers(request.headers);
|
|
209
|
+
requestHeaders.set('x-request-id', crypto.randomUUID());
|
|
210
|
+
|
|
211
|
+
// Rewrite URL
|
|
212
|
+
if (request.nextUrl.pathname === '/old-path') {
|
|
213
|
+
return NextResponse.rewrite(new URL('/new-path', request.url));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Pass headers to server components
|
|
217
|
+
const response = NextResponse.next({
|
|
218
|
+
request: {
|
|
219
|
+
headers: requestHeaders,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Add header to response
|
|
224
|
+
response.headers.set('x-request-id', requestHeaders.get('x-request-id')!);
|
|
225
|
+
|
|
226
|
+
return response;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### A/B Testing
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
export function middleware(request: NextRequest) {
|
|
234
|
+
const bucket = request.cookies.get('ab-bucket')?.value;
|
|
235
|
+
|
|
236
|
+
if (!bucket) {
|
|
237
|
+
// Assign to bucket
|
|
238
|
+
const newBucket = Math.random() > 0.5 ? 'a' : 'b';
|
|
239
|
+
const response = NextResponse.next();
|
|
240
|
+
response.cookies.set('ab-bucket', newBucket, {
|
|
241
|
+
httpOnly: true,
|
|
242
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
243
|
+
});
|
|
244
|
+
return response;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Rewrite based on bucket
|
|
248
|
+
if (request.nextUrl.pathname === '/landing') {
|
|
249
|
+
return NextResponse.rewrite(
|
|
250
|
+
new URL(`/landing-${bucket}`, request.url)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return NextResponse.next();
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Matcher Patterns
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
export const config = {
|
|
262
|
+
matcher: [
|
|
263
|
+
// Match all paths except static
|
|
264
|
+
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
265
|
+
|
|
266
|
+
// Match specific paths
|
|
267
|
+
'/dashboard/:path*',
|
|
268
|
+
'/api/:path*',
|
|
269
|
+
|
|
270
|
+
// Match with regex
|
|
271
|
+
'/(api|admin)/:path*',
|
|
272
|
+
|
|
273
|
+
// Exclude specific paths
|
|
274
|
+
'/((?!api/public)api/:path*)',
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Anti-patterns
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// BAD: Heavy computation in middleware (runs on every request)
|
|
283
|
+
export async function middleware(request: NextRequest) {
|
|
284
|
+
await heavyDatabaseQuery(); // Blocks all requests!
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// GOOD: Keep middleware lightweight
|
|
288
|
+
export async function middleware(request: NextRequest) {
|
|
289
|
+
// Only quick checks, use Edge-compatible code
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// BAD: Using Node.js APIs (middleware runs on Edge)
|
|
293
|
+
import fs from 'fs'; // Won't work!
|
|
294
|
+
|
|
295
|
+
// GOOD: Use Edge-compatible APIs
|
|
296
|
+
import { Redis } from '@upstash/redis'; // Edge-compatible
|
|
297
|
+
|
|
298
|
+
// BAD: Modifying response body
|
|
299
|
+
return new Response('Modified body'); // Loses Next.js features
|
|
300
|
+
|
|
301
|
+
// GOOD: Use rewrite or redirect
|
|
302
|
+
return NextResponse.rewrite(new URL('/new-path', request.url));
|
|
303
|
+
```
|