@plazmodium/odin 0.3.2-beta → 0.3.4-beta
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 +82 -11
- package/builtin/ODIN.md +1045 -0
- package/builtin/agent-definitions/README.md +170 -0
- package/builtin/agent-definitions/_shared-context.md +377 -0
- package/builtin/agent-definitions/architect.md +627 -0
- package/builtin/agent-definitions/builder.md +716 -0
- package/builtin/agent-definitions/discovery.md +293 -0
- package/builtin/agent-definitions/documenter.md +238 -0
- package/builtin/agent-definitions/guardian.md +1049 -0
- package/builtin/agent-definitions/integrator.md +363 -0
- package/builtin/agent-definitions/planning.md +236 -0
- package/builtin/agent-definitions/product.md +405 -0
- package/builtin/agent-definitions/release.md +430 -0
- package/builtin/agent-definitions/reviewer.md +447 -0
- package/builtin/agent-definitions/watcher.md +402 -0
- package/builtin/skills/api/graphql/SKILL.md +548 -0
- package/builtin/skills/api/grpc/SKILL.md +554 -0
- package/builtin/skills/api/rest-api/SKILL.md +469 -0
- package/builtin/skills/api/trpc/SKILL.md +503 -0
- package/builtin/skills/architecture/clean-architecture/SKILL.md +141 -0
- package/builtin/skills/architecture/domain-driven-design/SKILL.md +129 -0
- package/builtin/skills/architecture/event-driven/SKILL.md +145 -0
- package/builtin/skills/architecture/microservices/SKILL.md +143 -0
- package/builtin/skills/architecture/tla-precheck/SKILL.md +171 -0
- package/builtin/skills/backend/golang-gin/SKILL.md +141 -0
- package/builtin/skills/backend/nodejs-express/SKILL.md +277 -0
- package/builtin/skills/backend/nodejs-fastify/SKILL.md +152 -0
- package/builtin/skills/backend/python-django/SKILL.md +128 -0
- package/builtin/skills/backend/python-fastapi/SKILL.md +140 -0
- package/builtin/skills/database/mongodb/SKILL.md +132 -0
- package/builtin/skills/database/postgresql/SKILL.md +120 -0
- package/builtin/skills/database/prisma-orm/SKILL.md +366 -0
- package/builtin/skills/database/redis/SKILL.md +140 -0
- package/builtin/skills/database/supabase/SKILL.md +416 -0
- package/builtin/skills/devops/aws/SKILL.md +382 -0
- package/builtin/skills/devops/docker/SKILL.md +359 -0
- package/builtin/skills/devops/github-actions/SKILL.md +435 -0
- package/builtin/skills/devops/kubernetes/SKILL.md +459 -0
- package/builtin/skills/devops/terraform/SKILL.md +453 -0
- package/builtin/skills/frontend/alpine-dev/SKILL.md +27 -0
- package/builtin/skills/frontend/angular-dev/SKILL.md +28 -0
- package/builtin/skills/frontend/astro-dev/SKILL.md +28 -0
- package/builtin/skills/frontend/htmx-dev/SKILL.md +28 -0
- package/builtin/skills/frontend/nextjs-dev/SKILL.md +470 -0
- package/builtin/skills/frontend/react-patterns/SKILL.md +166 -0
- package/builtin/skills/frontend/svelte-dev/SKILL.md +28 -0
- package/builtin/skills/frontend/tailwindcss/SKILL.md +131 -0
- package/builtin/skills/frontend/vuejs-dev/SKILL.md +28 -0
- package/builtin/skills/generic-dev/SKILL.md +307 -0
- package/builtin/skills/testing/cypress/SKILL.md +372 -0
- package/builtin/skills/testing/jest/SKILL.md +176 -0
- package/builtin/skills/testing/playwright/SKILL.md +341 -0
- package/builtin/skills/testing/unit-tests-eval-sdd/SKILL.md +73 -0
- package/builtin/skills/testing/unit-tests-sdd/SKILL.md +83 -0
- package/builtin/skills/testing/vitest/SKILL.md +249 -0
- package/dist/adapters/skills/filesystem.d.ts.map +1 -1
- package/dist/adapters/skills/filesystem.js +2 -18
- package/dist/adapters/skills/filesystem.js.map +1 -1
- package/dist/builtin-assets.d.ts +8 -0
- package/dist/builtin-assets.d.ts.map +1 -0
- package/dist/builtin-assets.js +90 -0
- package/dist/builtin-assets.js.map +1 -0
- package/dist/init.js +69 -11
- package/dist/init.js.map +1 -1
- package/dist/schemas.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/prepare-phase-context.d.ts.map +1 -1
- package/dist/tools/prepare-phase-context.js +5 -0
- package/dist/tools/prepare-phase-context.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: trpc
|
|
3
|
+
description: tRPC expertise for building end-to-end typesafe APIs. Covers routers, procedures, context, middleware, and React Query integration for full-stack TypeScript applications.
|
|
4
|
+
category: api
|
|
5
|
+
compatible_with:
|
|
6
|
+
- nextjs-dev
|
|
7
|
+
- react-patterns
|
|
8
|
+
- prisma-orm
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# tRPC End-to-End Typesafe APIs
|
|
12
|
+
|
|
13
|
+
## Instructions
|
|
14
|
+
|
|
15
|
+
1. **Assess the project**: tRPC is ideal for full-stack TypeScript monorepos.
|
|
16
|
+
2. **Follow tRPC conventions**:
|
|
17
|
+
- Define routers and procedures
|
|
18
|
+
- Use Zod for input validation
|
|
19
|
+
- Leverage TypeScript inference
|
|
20
|
+
- Integrate with React Query
|
|
21
|
+
3. **Provide complete examples**: Include server routers and client usage.
|
|
22
|
+
4. **Guide on best practices**: Error handling, middleware, subscriptions.
|
|
23
|
+
|
|
24
|
+
## Server Setup
|
|
25
|
+
|
|
26
|
+
### Initialize tRPC
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// server/trpc.ts
|
|
30
|
+
import { initTRPC, TRPCError } from '@trpc/server';
|
|
31
|
+
import { type Context } from './context';
|
|
32
|
+
import superjson from 'superjson';
|
|
33
|
+
|
|
34
|
+
const t = initTRPC.context<Context>().create({
|
|
35
|
+
transformer: superjson,
|
|
36
|
+
errorFormatter({ shape, error }) {
|
|
37
|
+
return {
|
|
38
|
+
...shape,
|
|
39
|
+
data: {
|
|
40
|
+
...shape.data,
|
|
41
|
+
zodError: error.cause instanceof ZodError
|
|
42
|
+
? error.cause.flatten()
|
|
43
|
+
: null,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const router = t.router;
|
|
50
|
+
export const publicProcedure = t.procedure;
|
|
51
|
+
export const middleware = t.middleware;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Context
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// server/context.ts
|
|
58
|
+
import { type inferAsyncReturnType } from '@trpc/server';
|
|
59
|
+
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
|
|
60
|
+
import { getSession } from 'next-auth/react';
|
|
61
|
+
import { prisma } from './db';
|
|
62
|
+
|
|
63
|
+
export const createContext = async (opts: CreateNextContextOptions) => {
|
|
64
|
+
const session = await getSession({ req: opts.req });
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
session,
|
|
68
|
+
user: session?.user,
|
|
69
|
+
prisma,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type Context = inferAsyncReturnType<typeof createContext>;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Middleware
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
// server/trpc.ts
|
|
80
|
+
const isAuthed = middleware(async ({ ctx, next }) => {
|
|
81
|
+
if (!ctx.session?.user) {
|
|
82
|
+
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
83
|
+
}
|
|
84
|
+
return next({
|
|
85
|
+
ctx: {
|
|
86
|
+
...ctx,
|
|
87
|
+
user: ctx.session.user,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const isAdmin = middleware(async ({ ctx, next }) => {
|
|
93
|
+
if (ctx.user?.role !== 'ADMIN') {
|
|
94
|
+
throw new TRPCError({ code: 'FORBIDDEN' });
|
|
95
|
+
}
|
|
96
|
+
return next({ ctx });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const protectedProcedure = t.procedure.use(isAuthed);
|
|
100
|
+
export const adminProcedure = t.procedure.use(isAuthed).use(isAdmin);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Routers
|
|
104
|
+
|
|
105
|
+
### Basic Router
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// server/routers/user.ts
|
|
109
|
+
import { z } from 'zod';
|
|
110
|
+
import { router, publicProcedure, protectedProcedure } from '../trpc';
|
|
111
|
+
import { TRPCError } from '@trpc/server';
|
|
112
|
+
|
|
113
|
+
export const userRouter = router({
|
|
114
|
+
// Public query
|
|
115
|
+
getById: publicProcedure
|
|
116
|
+
.input(z.object({ id: z.string() }))
|
|
117
|
+
.query(async ({ ctx, input }) => {
|
|
118
|
+
const user = await ctx.prisma.user.findUnique({
|
|
119
|
+
where: { id: input.id },
|
|
120
|
+
select: { id: true, name: true, email: true, image: true },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!user) {
|
|
124
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return user;
|
|
128
|
+
}),
|
|
129
|
+
|
|
130
|
+
// Protected query
|
|
131
|
+
me: protectedProcedure.query(async ({ ctx }) => {
|
|
132
|
+
return ctx.prisma.user.findUnique({
|
|
133
|
+
where: { id: ctx.user.id },
|
|
134
|
+
});
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
// Mutation with input validation
|
|
138
|
+
update: protectedProcedure
|
|
139
|
+
.input(z.object({
|
|
140
|
+
name: z.string().min(1).max(100).optional(),
|
|
141
|
+
bio: z.string().max(500).optional(),
|
|
142
|
+
}))
|
|
143
|
+
.mutation(async ({ ctx, input }) => {
|
|
144
|
+
return ctx.prisma.user.update({
|
|
145
|
+
where: { id: ctx.user.id },
|
|
146
|
+
data: input,
|
|
147
|
+
});
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Nested Router with Pagination
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// server/routers/post.ts
|
|
156
|
+
import { z } from 'zod';
|
|
157
|
+
import { router, publicProcedure, protectedProcedure } from '../trpc';
|
|
158
|
+
|
|
159
|
+
export const postRouter = router({
|
|
160
|
+
list: publicProcedure
|
|
161
|
+
.input(z.object({
|
|
162
|
+
limit: z.number().min(1).max(100).default(20),
|
|
163
|
+
cursor: z.string().nullish(),
|
|
164
|
+
filter: z.object({
|
|
165
|
+
published: z.boolean().optional(),
|
|
166
|
+
authorId: z.string().optional(),
|
|
167
|
+
}).optional(),
|
|
168
|
+
}))
|
|
169
|
+
.query(async ({ ctx, input }) => {
|
|
170
|
+
const { limit, cursor, filter } = input;
|
|
171
|
+
|
|
172
|
+
const posts = await ctx.prisma.post.findMany({
|
|
173
|
+
take: limit + 1,
|
|
174
|
+
cursor: cursor ? { id: cursor } : undefined,
|
|
175
|
+
where: {
|
|
176
|
+
published: filter?.published,
|
|
177
|
+
authorId: filter?.authorId,
|
|
178
|
+
},
|
|
179
|
+
orderBy: { createdAt: 'desc' },
|
|
180
|
+
include: {
|
|
181
|
+
author: { select: { id: true, name: true, image: true } },
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
let nextCursor: string | undefined = undefined;
|
|
186
|
+
if (posts.length > limit) {
|
|
187
|
+
const nextItem = posts.pop();
|
|
188
|
+
nextCursor = nextItem!.id;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
items: posts,
|
|
193
|
+
nextCursor,
|
|
194
|
+
};
|
|
195
|
+
}),
|
|
196
|
+
|
|
197
|
+
create: protectedProcedure
|
|
198
|
+
.input(z.object({
|
|
199
|
+
title: z.string().min(1).max(200),
|
|
200
|
+
content: z.string().min(1),
|
|
201
|
+
published: z.boolean().default(false),
|
|
202
|
+
}))
|
|
203
|
+
.mutation(async ({ ctx, input }) => {
|
|
204
|
+
return ctx.prisma.post.create({
|
|
205
|
+
data: {
|
|
206
|
+
...input,
|
|
207
|
+
authorId: ctx.user.id,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}),
|
|
211
|
+
|
|
212
|
+
update: protectedProcedure
|
|
213
|
+
.input(z.object({
|
|
214
|
+
id: z.string(),
|
|
215
|
+
title: z.string().min(1).max(200).optional(),
|
|
216
|
+
content: z.string().min(1).optional(),
|
|
217
|
+
published: z.boolean().optional(),
|
|
218
|
+
}))
|
|
219
|
+
.mutation(async ({ ctx, input }) => {
|
|
220
|
+
const { id, ...data } = input;
|
|
221
|
+
|
|
222
|
+
// Verify ownership
|
|
223
|
+
const post = await ctx.prisma.post.findUnique({ where: { id } });
|
|
224
|
+
if (!post || post.authorId !== ctx.user.id) {
|
|
225
|
+
throw new TRPCError({ code: 'FORBIDDEN' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return ctx.prisma.post.update({
|
|
229
|
+
where: { id },
|
|
230
|
+
data,
|
|
231
|
+
});
|
|
232
|
+
}),
|
|
233
|
+
|
|
234
|
+
delete: protectedProcedure
|
|
235
|
+
.input(z.object({ id: z.string() }))
|
|
236
|
+
.mutation(async ({ ctx, input }) => {
|
|
237
|
+
const post = await ctx.prisma.post.findUnique({
|
|
238
|
+
where: { id: input.id }
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (!post || post.authorId !== ctx.user.id) {
|
|
242
|
+
throw new TRPCError({ code: 'FORBIDDEN' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await ctx.prisma.post.delete({ where: { id: input.id } });
|
|
246
|
+
return { success: true };
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Root Router
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
// server/routers/_app.ts
|
|
255
|
+
import { router } from '../trpc';
|
|
256
|
+
import { userRouter } from './user';
|
|
257
|
+
import { postRouter } from './post';
|
|
258
|
+
|
|
259
|
+
export const appRouter = router({
|
|
260
|
+
user: userRouter,
|
|
261
|
+
post: postRouter,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
export type AppRouter = typeof appRouter;
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Client Setup
|
|
268
|
+
|
|
269
|
+
### React Client
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
// utils/trpc.ts
|
|
273
|
+
import { createTRPCReact } from '@trpc/react-query';
|
|
274
|
+
import { type AppRouter } from '../server/routers/_app';
|
|
275
|
+
|
|
276
|
+
export const trpc = createTRPCReact<AppRouter>();
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Provider Setup
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// pages/_app.tsx or app/providers.tsx
|
|
283
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
284
|
+
import { httpBatchLink } from '@trpc/client';
|
|
285
|
+
import { trpc } from '../utils/trpc';
|
|
286
|
+
import superjson from 'superjson';
|
|
287
|
+
|
|
288
|
+
const queryClient = new QueryClient({
|
|
289
|
+
defaultOptions: {
|
|
290
|
+
queries: {
|
|
291
|
+
staleTime: 5 * 60 * 1000,
|
|
292
|
+
refetchOnWindowFocus: false,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const trpcClient = trpc.createClient({
|
|
298
|
+
links: [
|
|
299
|
+
httpBatchLink({
|
|
300
|
+
url: '/api/trpc',
|
|
301
|
+
transformer: superjson,
|
|
302
|
+
headers() {
|
|
303
|
+
return {
|
|
304
|
+
// Add auth headers if needed
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
312
|
+
return (
|
|
313
|
+
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
|
314
|
+
<QueryClientProvider client={queryClient}>
|
|
315
|
+
{children}
|
|
316
|
+
</QueryClientProvider>
|
|
317
|
+
</trpc.Provider>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Client Usage
|
|
323
|
+
|
|
324
|
+
### Queries
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
328
|
+
const { data, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
|
|
329
|
+
|
|
330
|
+
if (isLoading) return <Spinner />;
|
|
331
|
+
if (error) return <Error message={error.message} />;
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div>
|
|
335
|
+
<h1>{data.name}</h1>
|
|
336
|
+
<p>{data.email}</p>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// With options
|
|
342
|
+
const { data } = trpc.post.list.useQuery(
|
|
343
|
+
{ limit: 10, filter: { published: true } },
|
|
344
|
+
{
|
|
345
|
+
enabled: isLoggedIn,
|
|
346
|
+
refetchInterval: 30000,
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Infinite Queries
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
function PostFeed() {
|
|
355
|
+
const {
|
|
356
|
+
data,
|
|
357
|
+
fetchNextPage,
|
|
358
|
+
hasNextPage,
|
|
359
|
+
isFetchingNextPage,
|
|
360
|
+
} = trpc.post.list.useInfiniteQuery(
|
|
361
|
+
{ limit: 20 },
|
|
362
|
+
{
|
|
363
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<>
|
|
369
|
+
{data?.pages.map((page) =>
|
|
370
|
+
page.items.map((post) => <PostCard key={post.id} post={post} />)
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{hasNextPage && (
|
|
374
|
+
<button
|
|
375
|
+
onClick={() => fetchNextPage()}
|
|
376
|
+
disabled={isFetchingNextPage}
|
|
377
|
+
>
|
|
378
|
+
{isFetchingNextPage ? 'Loading...' : 'Load More'}
|
|
379
|
+
</button>
|
|
380
|
+
)}
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### Mutations
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
function CreatePostForm() {
|
|
390
|
+
const utils = trpc.useUtils();
|
|
391
|
+
|
|
392
|
+
const createPost = trpc.post.create.useMutation({
|
|
393
|
+
onSuccess: () => {
|
|
394
|
+
// Invalidate and refetch
|
|
395
|
+
utils.post.list.invalidate();
|
|
396
|
+
},
|
|
397
|
+
onError: (error) => {
|
|
398
|
+
if (error.data?.zodError) {
|
|
399
|
+
// Handle validation errors
|
|
400
|
+
console.log(error.data.zodError.fieldErrors);
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const handleSubmit = (values: { title: string; content: string }) => {
|
|
406
|
+
createPost.mutate(values);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<form onSubmit={handleSubmit}>
|
|
411
|
+
{/* Form fields */}
|
|
412
|
+
<button type="submit" disabled={createPost.isPending}>
|
|
413
|
+
{createPost.isPending ? 'Creating...' : 'Create Post'}
|
|
414
|
+
</button>
|
|
415
|
+
</form>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Optimistic Updates
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
const updatePost = trpc.post.update.useMutation({
|
|
424
|
+
onMutate: async (newData) => {
|
|
425
|
+
// Cancel outgoing refetches
|
|
426
|
+
await utils.post.getById.cancel({ id: newData.id });
|
|
427
|
+
|
|
428
|
+
// Snapshot previous value
|
|
429
|
+
const previousPost = utils.post.getById.getData({ id: newData.id });
|
|
430
|
+
|
|
431
|
+
// Optimistically update
|
|
432
|
+
utils.post.getById.setData({ id: newData.id }, (old) => ({
|
|
433
|
+
...old!,
|
|
434
|
+
...newData,
|
|
435
|
+
}));
|
|
436
|
+
|
|
437
|
+
return { previousPost };
|
|
438
|
+
},
|
|
439
|
+
onError: (err, newData, context) => {
|
|
440
|
+
// Rollback on error
|
|
441
|
+
utils.post.getById.setData(
|
|
442
|
+
{ id: newData.id },
|
|
443
|
+
context?.previousPost
|
|
444
|
+
);
|
|
445
|
+
},
|
|
446
|
+
onSettled: (data, error, variables) => {
|
|
447
|
+
// Refetch after mutation
|
|
448
|
+
utils.post.getById.invalidate({ id: variables.id });
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
## Subscriptions (WebSocket)
|
|
454
|
+
|
|
455
|
+
### Server
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import { observable } from '@trpc/server/observable';
|
|
459
|
+
|
|
460
|
+
export const notificationRouter = router({
|
|
461
|
+
onNew: protectedProcedure.subscription(({ ctx }) => {
|
|
462
|
+
return observable<Notification>((emit) => {
|
|
463
|
+
const onNotification = (notification: Notification) => {
|
|
464
|
+
if (notification.userId === ctx.user.id) {
|
|
465
|
+
emit.next(notification);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
eventEmitter.on('notification', onNotification);
|
|
470
|
+
|
|
471
|
+
return () => {
|
|
472
|
+
eventEmitter.off('notification', onNotification);
|
|
473
|
+
};
|
|
474
|
+
});
|
|
475
|
+
}),
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Client
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
trpc.notification.onNew.useSubscription(undefined, {
|
|
483
|
+
onData: (notification) => {
|
|
484
|
+
toast.info(notification.message);
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Best Practices
|
|
490
|
+
|
|
491
|
+
- **Use Zod schemas** - Single source of truth for validation
|
|
492
|
+
- **Leverage inference** - Let TypeScript infer types from schemas
|
|
493
|
+
- **Batch requests** - Use `httpBatchLink` for multiple queries
|
|
494
|
+
- **Invalidate wisely** - Only invalidate what's necessary
|
|
495
|
+
- **Error boundaries** - Wrap components that use queries
|
|
496
|
+
- **Prefetch data** - Use `prefetch` for anticipated navigation
|
|
497
|
+
- **Keep routers focused** - One domain per router file
|
|
498
|
+
|
|
499
|
+
## References
|
|
500
|
+
|
|
501
|
+
- tRPC Documentation: https://trpc.io/docs
|
|
502
|
+
- tRPC + Next.js: https://trpc.io/docs/nextjs
|
|
503
|
+
- tRPC + React Query: https://trpc.io/docs/react-query
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clean-architecture
|
|
3
|
+
description: Clean Architecture principles — dependency inversion, use cases, and layer separation
|
|
4
|
+
category: architecture
|
|
5
|
+
version: "1.0"
|
|
6
|
+
compatible_with:
|
|
7
|
+
- nodejs-express
|
|
8
|
+
- nodejs-fastify
|
|
9
|
+
- python-fastapi
|
|
10
|
+
- python-django
|
|
11
|
+
- golang-gin
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Clean Architecture
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
Clean Architecture separates code into concentric layers with the Dependency Rule: source code dependencies always point inward. Business logic never depends on frameworks, databases, or UI.
|
|
19
|
+
|
|
20
|
+
## Layer Structure
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/
|
|
24
|
+
├── domain/ # Innermost — entities + business rules
|
|
25
|
+
│ ├── entities/
|
|
26
|
+
│ │ └── User.ts
|
|
27
|
+
│ └── value-objects/
|
|
28
|
+
│ └── Email.ts
|
|
29
|
+
├── application/ # Use cases — orchestrate domain logic
|
|
30
|
+
│ ├── use-cases/
|
|
31
|
+
│ │ ├── CreateUser.ts
|
|
32
|
+
│ │ └── GetUserById.ts
|
|
33
|
+
│ └── ports/ # Interfaces (driven/driving)
|
|
34
|
+
│ ├── UserRepository.ts
|
|
35
|
+
│ └── EmailService.ts
|
|
36
|
+
├── infrastructure/ # Outermost — frameworks, DB, external APIs
|
|
37
|
+
│ ├── persistence/
|
|
38
|
+
│ │ └── PgUserRepository.ts
|
|
39
|
+
│ ├── services/
|
|
40
|
+
│ │ └── SendGridEmailService.ts
|
|
41
|
+
│ └── web/
|
|
42
|
+
│ ├── routes.ts
|
|
43
|
+
│ └── controllers/
|
|
44
|
+
│ └── UserController.ts
|
|
45
|
+
└── main.ts # Composition root — wires everything together
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Core Patterns
|
|
49
|
+
|
|
50
|
+
### Entity (Domain Layer)
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// Pure business logic — no framework imports
|
|
54
|
+
export class User {
|
|
55
|
+
constructor(
|
|
56
|
+
public readonly id: string,
|
|
57
|
+
public readonly email: Email,
|
|
58
|
+
public name: string,
|
|
59
|
+
private passwordHash: string,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
changeName(newName: string): void {
|
|
63
|
+
if (newName.length < 2) throw new DomainError('Name too short');
|
|
64
|
+
this.name = newName;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
verifyPassword(plaintext: string): boolean {
|
|
68
|
+
return hashCompare(plaintext, this.passwordHash);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Use Case (Application Layer)
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// Depends only on ports (interfaces), not implementations
|
|
77
|
+
export class CreateUser {
|
|
78
|
+
constructor(
|
|
79
|
+
private userRepo: UserRepository,
|
|
80
|
+
private emailService: EmailService,
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
async execute(input: { email: string; name: string; password: string }): Promise<User> {
|
|
84
|
+
const existing = await this.userRepo.findByEmail(input.email);
|
|
85
|
+
if (existing) throw new ConflictError('Email already registered');
|
|
86
|
+
|
|
87
|
+
const user = User.create(input);
|
|
88
|
+
await this.userRepo.save(user);
|
|
89
|
+
await this.emailService.sendWelcome(user.email);
|
|
90
|
+
return user;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Port (Interface)
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Defined in application layer — implemented in infrastructure
|
|
99
|
+
export interface UserRepository {
|
|
100
|
+
findById(id: string): Promise<User | null>;
|
|
101
|
+
findByEmail(email: string): Promise<User | null>;
|
|
102
|
+
save(user: User): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Adapter (Infrastructure Layer)
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Implements the port — depends on the interface, not the other way
|
|
110
|
+
export class PgUserRepository implements UserRepository {
|
|
111
|
+
constructor(private db: Pool) {}
|
|
112
|
+
|
|
113
|
+
async findById(id: string): Promise<User | null> {
|
|
114
|
+
const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
|
|
115
|
+
return row.rows[0] ? this.toDomain(row.rows[0]) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async save(user: User): Promise<void> {
|
|
119
|
+
await this.db.query(
|
|
120
|
+
'INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, $4)',
|
|
121
|
+
[user.id, user.email.value, user.name, user.passwordHash],
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Best Practices
|
|
128
|
+
|
|
129
|
+
1. **Dependency Rule** — inner layers never import from outer layers
|
|
130
|
+
2. **Composition Root** — wire dependencies in `main.ts`, not inside classes
|
|
131
|
+
3. **Use Case = 1 operation** — each use case does one thing
|
|
132
|
+
4. **Domain has no dependencies** — no ORM decorators, no framework types
|
|
133
|
+
5. **Test use cases** with mock ports — fast, no DB needed
|
|
134
|
+
6. **Value Objects** for validated primitives (Email, Money, UserId)
|
|
135
|
+
|
|
136
|
+
## Gotchas
|
|
137
|
+
|
|
138
|
+
- **Over-engineering** — don't add layers for trivial CRUD; use clean architecture for complex domains
|
|
139
|
+
- **Mapping fatigue** — converting between layers (DB row → domain entity → DTO) adds boilerplate
|
|
140
|
+
- **Framework leakage** — ORM decorators on domain entities break the dependency rule
|
|
141
|
+
- **Where does validation go?** — input validation at the boundary, business rules in the domain
|