@strayl/agent 0.1.3 → 0.1.4
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/package.json +5 -1
- package/skills/api-creation/SKILL.md +631 -0
- package/skills/authentication/SKILL.md +294 -0
- package/skills/frontend-design/SKILL.md +108 -0
- package/skills/landing-creation/SKILL.md +125 -0
- package/skills/reference/SKILL.md +149 -0
- package/skills/web-application-creation/SKILL.md +231 -0
- package/src/agent.ts +0 -465
- package/src/checkpoints/manager.ts +0 -112
- package/src/context/manager.ts +0 -185
- package/src/context/summarizer.ts +0 -104
- package/src/context/trim.ts +0 -55
- package/src/emitter.ts +0 -14
- package/src/hitl/manager.ts +0 -77
- package/src/hitl/transport.ts +0 -13
- package/src/index.ts +0 -116
- package/src/llm/client.ts +0 -276
- package/src/llm/gemini-native.ts +0 -307
- package/src/llm/models.ts +0 -64
- package/src/middleware/compose.ts +0 -24
- package/src/middleware/credential-scrubbing.ts +0 -31
- package/src/middleware/forbidden-packages.ts +0 -107
- package/src/middleware/plan-mode.ts +0 -143
- package/src/middleware/prompt-caching.ts +0 -21
- package/src/middleware/tool-compression.ts +0 -25
- package/src/middleware/tool-filter.ts +0 -13
- package/src/prompts/implementation-mode.md +0 -16
- package/src/prompts/plan-mode.md +0 -51
- package/src/prompts/system.ts +0 -173
- package/src/skills/loader.ts +0 -53
- package/src/stdin-listener.ts +0 -61
- package/src/subagents/definitions.ts +0 -72
- package/src/subagents/manager.ts +0 -161
- package/src/todos/manager.ts +0 -61
- package/src/tools/builtin/delete.ts +0 -29
- package/src/tools/builtin/edit.ts +0 -74
- package/src/tools/builtin/exec.ts +0 -216
- package/src/tools/builtin/glob.ts +0 -104
- package/src/tools/builtin/grep.ts +0 -115
- package/src/tools/builtin/ls.ts +0 -54
- package/src/tools/builtin/move.ts +0 -31
- package/src/tools/builtin/read.ts +0 -69
- package/src/tools/builtin/write.ts +0 -42
- package/src/tools/executor.ts +0 -51
- package/src/tools/external/database.ts +0 -285
- package/src/tools/external/enter-plan-mode.ts +0 -34
- package/src/tools/external/generate-image.ts +0 -110
- package/src/tools/external/hitl-tools.ts +0 -118
- package/src/tools/external/preview.ts +0 -28
- package/src/tools/external/proxy-fetch.ts +0 -51
- package/src/tools/external/task.ts +0 -38
- package/src/tools/external/wait.ts +0 -20
- package/src/tools/external/web-fetch.ts +0 -57
- package/src/tools/external/web-search.ts +0 -61
- package/src/tools/registry.ts +0 -36
- package/src/tools/zod-to-json-schema.ts +0 -86
- package/src/types.ts +0 -151
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strayl/agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"main": "dist/index.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"skills"
|
|
12
|
+
],
|
|
9
13
|
"scripts": {
|
|
10
14
|
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/agent.js --external:fsevents",
|
|
11
15
|
"dev": "tsx watch src/index.ts"
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: api-creation
|
|
3
|
+
description: Create server-side API logic in a TanStack Start app using createServerFn, Drizzle ORM, and Zod validation. Use when the user needs backend endpoints, CRUD operations, data fetching, or server-side business logic.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# API Creation
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
- User asks to create an API, endpoint, server function, or backend logic
|
|
11
|
+
- User needs CRUD operations (create, read, update, delete) for any entity
|
|
12
|
+
- User wants to fetch data from the database and display it
|
|
13
|
+
- User mentions "server function", "API", "backend", "endpoint", "CRUD"
|
|
14
|
+
- User asks to "save data", "fetch data", "submit a form", "load from database"
|
|
15
|
+
|
|
16
|
+
## When NOT to Use
|
|
17
|
+
|
|
18
|
+
- User only needs client-side state (React state, localStorage, URL search params)
|
|
19
|
+
- User is asking about authentication specifically — use the `authentication` skill
|
|
20
|
+
- User wants to scaffold a new project — use `web-application-creation` skill
|
|
21
|
+
- User only needs to create/modify database schema — use `database-management` skill
|
|
22
|
+
|
|
23
|
+
## Prerequisites
|
|
24
|
+
|
|
25
|
+
- A TanStack Start project (scaffolded with `web-application-creation` or existing)
|
|
26
|
+
- A database with tables (via `database-management` skill)
|
|
27
|
+
- If endpoints need auth protection, set up auth first with `authentication` skill
|
|
28
|
+
|
|
29
|
+
## ⛔ RULE #1 — NEVER Use Dynamic Imports for Server Functions
|
|
30
|
+
|
|
31
|
+
This is the most common mistake. It **breaks the build** every time.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// ❌ BROKEN — dynamic import inside useEffect, onClick, or any async callback
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const load = async () => {
|
|
37
|
+
const { getTasks } = await import('../server/tasks') // BUILD ERROR
|
|
38
|
+
setTasks(await getTasks({ userId }))
|
|
39
|
+
}
|
|
40
|
+
load()
|
|
41
|
+
}, [userId])
|
|
42
|
+
|
|
43
|
+
// ❌ BROKEN — same problem inside an event handler
|
|
44
|
+
const handleCreate = async () => {
|
|
45
|
+
const { createTask } = await import('../server/tasks') // BUILD ERROR
|
|
46
|
+
await createTask({ ... })
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// ✅ CORRECT — static import at top of file, always
|
|
52
|
+
import { getTasks } from '@/server/tasks'
|
|
53
|
+
import { createTask } from '@/server/tasks'
|
|
54
|
+
import { useServerFn } from '@tanstack/react-start'
|
|
55
|
+
|
|
56
|
+
// GET data → call in route loader
|
|
57
|
+
export const Route = createFileRoute('/tasks')({
|
|
58
|
+
loader: ({ context }) => getTasks({ userId: context.userId }),
|
|
59
|
+
component: TasksPage,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// POST mutation → wrap with useServerFn() in component
|
|
63
|
+
function TasksPage() {
|
|
64
|
+
const create = useServerFn(createTask)
|
|
65
|
+
const handleCreate = async () => {
|
|
66
|
+
await create({ data: { title: 'New task', userId } })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Why it breaks:** TanStack Start analyzes static imports at build time to split client/server bundles. Dynamic `await import()` bypasses this — the bundler fails or leaks server-only code (DB, env vars) to the client.
|
|
72
|
+
|
|
73
|
+
## ⛔ RULE #2 — Do NOT Use useEffect for Data Loading
|
|
74
|
+
|
|
75
|
+
Never load server data inside `useEffect`. Always use route loaders:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// ❌ BROKEN pattern — data fetching in useEffect
|
|
79
|
+
function TasksPage() {
|
|
80
|
+
const [tasks, setTasks] = useState([])
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
// DON'T DO THIS — use loader instead
|
|
83
|
+
getTasks({ userId }).then(setTasks)
|
|
84
|
+
}, [userId])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ✅ CORRECT — data in loader, consumed with useLoaderData()
|
|
88
|
+
export const Route = createFileRoute('/tasks')({
|
|
89
|
+
loader: ({ context }) => getTasks({ userId: context.userId }),
|
|
90
|
+
component: TasksPage,
|
|
91
|
+
})
|
|
92
|
+
function TasksPage() {
|
|
93
|
+
const tasks = Route.useLoaderData() // no useEffect needed
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How to Pass Auth Context to Loaders
|
|
98
|
+
|
|
99
|
+
When you need `userId` (or any auth data) in a route loader, use the router context — set it once in `__root.tsx` and it flows to all routes automatically:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// src/routes/__root.tsx — use createRootRouteWithContext for TypeScript safety
|
|
103
|
+
import { createRootRouteWithContext } from '@tanstack/react-router'
|
|
104
|
+
import { getUser } from '@/server/auth'
|
|
105
|
+
|
|
106
|
+
interface RouterContext {
|
|
107
|
+
user: Awaited<ReturnType<typeof getUser>> | null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const Route = createRootRouteWithContext<RouterContext>()({
|
|
111
|
+
beforeLoad: async () => {
|
|
112
|
+
const user = await getUser()
|
|
113
|
+
return { user } // available as context.user in ALL route loaders
|
|
114
|
+
},
|
|
115
|
+
// ...
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// src/routes/tasks.tsx — access user in loader (no useEffect needed)
|
|
119
|
+
import { getTasks } from '@/server/tasks'
|
|
120
|
+
export const Route = createFileRoute('/tasks')({
|
|
121
|
+
loader: ({ context }) => {
|
|
122
|
+
if (!context.user) return []
|
|
123
|
+
return getTasks({ userId: context.user.id })
|
|
124
|
+
},
|
|
125
|
+
component: TasksPage,
|
|
126
|
+
})
|
|
127
|
+
function TasksPage() {
|
|
128
|
+
const tasks = Route.useLoaderData() // data from loader, fully typed
|
|
129
|
+
const { user } = Route.useRouteContext() // auth context in component
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This eliminates the need for useEffect-based data loading entirely.
|
|
134
|
+
|
|
135
|
+
## Core API: createServerFn
|
|
136
|
+
|
|
137
|
+
All backend logic goes through `createServerFn` from `@tanstack/react-start`. Server functions run on the server (Nitro) and are callable from loaders and client components.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
// ✅ CORRECT import path
|
|
141
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
142
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
143
|
+
|
|
144
|
+
// ❌ WRONG — /server subpath is for entry-point utilities only (createStartHandler, setResponseStatus)
|
|
145
|
+
import { createServerFn } from "@tanstack/react-start/server"; // BUILD ERROR
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Fluent API chain:** `createServerFn({ method }) .inputValidator(fn) .handler(fn)`
|
|
149
|
+
|
|
150
|
+
- `method: "GET"` — for reads (loaders, data fetching)
|
|
151
|
+
- `method: "POST"` — for mutations (create, update, delete)
|
|
152
|
+
- `.inputValidator()` — validates and types input (use zod)
|
|
153
|
+
- `.handler()` — receives `{ data }` (validated input), returns response
|
|
154
|
+
|
|
155
|
+
## File Organization
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
src/
|
|
159
|
+
├── lib/
|
|
160
|
+
│ ├── db.ts # Drizzle client (single instance)
|
|
161
|
+
│ └── schema.ts # All table definitions
|
|
162
|
+
├── server/
|
|
163
|
+
│ ├── auth.ts # Auth functions (if authentication skill used)
|
|
164
|
+
│ ├── posts.ts # Post server functions
|
|
165
|
+
│ ├── comments.ts # Comment server functions
|
|
166
|
+
│ └── {entity}.ts # One file per entity/domain
|
|
167
|
+
└── routes/
|
|
168
|
+
├── posts.tsx # List page (loader calls GET server fn)
|
|
169
|
+
├── posts.$postId.tsx # Detail page (loader with params)
|
|
170
|
+
└── _authed/
|
|
171
|
+
└── posts.new.tsx # Create form (calls POST server fn)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Convention:** Server functions in `src/server/{entity}.ts`. Routes import and call them. Keeps server logic separate from UI.
|
|
175
|
+
|
|
176
|
+
## Setup (if not done)
|
|
177
|
+
|
|
178
|
+
### Database Client
|
|
179
|
+
|
|
180
|
+
**Check first** — `src/lib/db.ts` may already exist from `authentication` or `database-management`. If not:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// src/lib/db.ts
|
|
184
|
+
import { neon } from "@neondatabase/serverless";
|
|
185
|
+
import { drizzle } from "drizzle-orm/neon-http";
|
|
186
|
+
|
|
187
|
+
let _db: ReturnType<typeof drizzle>;
|
|
188
|
+
export function db() {
|
|
189
|
+
if (!_db) {
|
|
190
|
+
_db = drizzle(neon(process.env.DATABASE_URL!));
|
|
191
|
+
}
|
|
192
|
+
return _db;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Schema
|
|
197
|
+
|
|
198
|
+
Add table definitions to `src/lib/schema.ts` (edit if exists, create if not):
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { pgTable, serial, text, timestamp, integer, boolean } from "drizzle-orm/pg-core";
|
|
202
|
+
|
|
203
|
+
export const posts = pgTable("posts", {
|
|
204
|
+
id: serial("id").primaryKey(),
|
|
205
|
+
title: text("title").notNull(),
|
|
206
|
+
content: text("content").notNull().default(""),
|
|
207
|
+
authorId: integer("author_id").notNull(),
|
|
208
|
+
published: boolean("published").notNull().default(false),
|
|
209
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
210
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
After defining schema in code, **create the actual table** via migration:
|
|
215
|
+
```
|
|
216
|
+
universal({ tool: "prepare_database_migration", params: { migrationSql: "CREATE TABLE IF NOT EXISTS posts (...)" } })
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Patterns
|
|
220
|
+
|
|
221
|
+
### GET — List All
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// src/server/posts.ts
|
|
225
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
226
|
+
import { db } from "@/lib/db";
|
|
227
|
+
import { posts } from "@/lib/schema";
|
|
228
|
+
import { desc, eq } from "drizzle-orm";
|
|
229
|
+
|
|
230
|
+
export const getPosts = createServerFn({ method: "GET" }).handler(async () => {
|
|
231
|
+
return db().select().from(posts).orderBy(desc(posts.createdAt));
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**In a route loader:**
|
|
236
|
+
```typescript
|
|
237
|
+
// src/routes/posts.tsx
|
|
238
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
239
|
+
import { getPosts } from "@/server/posts";
|
|
240
|
+
|
|
241
|
+
export const Route = createFileRoute("/posts")({
|
|
242
|
+
loader: () => getPosts(),
|
|
243
|
+
component: PostsPage,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
function PostsPage() {
|
|
247
|
+
const posts = Route.useLoaderData();
|
|
248
|
+
return (
|
|
249
|
+
<div className="container mx-auto p-6">
|
|
250
|
+
{posts.map((post) => (
|
|
251
|
+
<div key={post.id}>{post.title}</div>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### GET — Single by ID
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
export const getPost = createServerFn({ method: "GET" })
|
|
262
|
+
.inputValidator((id: number) => id)
|
|
263
|
+
.handler(async ({ data: id }) => {
|
|
264
|
+
const [post] = await db().select().from(posts).where(eq(posts.id, id)).limit(1);
|
|
265
|
+
if (!post) throw new Error("Post not found");
|
|
266
|
+
return post;
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**In a route with params:**
|
|
271
|
+
```typescript
|
|
272
|
+
// src/routes/posts.$postId.tsx
|
|
273
|
+
export const Route = createFileRoute("/posts/$postId")({
|
|
274
|
+
loader: ({ params }) => getPost({ data: Number(params.postId) }),
|
|
275
|
+
component: PostPage,
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### POST — Create with Validation
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
import { z } from "zod";
|
|
283
|
+
|
|
284
|
+
const createPostSchema = z.object({
|
|
285
|
+
title: z.string().min(1).max(200),
|
|
286
|
+
content: z.string().max(50000).default(""),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
export const createPost = createServerFn({ method: "POST" })
|
|
290
|
+
.inputValidator((d: unknown) => createPostSchema.parse(d))
|
|
291
|
+
.handler(async ({ data }) => {
|
|
292
|
+
const [post] = await db()
|
|
293
|
+
.insert(posts)
|
|
294
|
+
.values({ title: data.title, content: data.content, authorId: 1 })
|
|
295
|
+
.returning();
|
|
296
|
+
return post;
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Calling from a component:**
|
|
301
|
+
```typescript
|
|
302
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
303
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
304
|
+
import { createPost } from "@/server/posts";
|
|
305
|
+
import { Button } from "@/components/ui/button";
|
|
306
|
+
import { Input } from "@/components/ui/input";
|
|
307
|
+
import { Label } from "@/components/ui/label";
|
|
308
|
+
|
|
309
|
+
function CreatePostForm() {
|
|
310
|
+
const navigate = useNavigate();
|
|
311
|
+
const create = useServerFn(createPost);
|
|
312
|
+
const [error, setError] = useState("");
|
|
313
|
+
|
|
314
|
+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
setError("");
|
|
317
|
+
const formData = new FormData(e.currentTarget);
|
|
318
|
+
try {
|
|
319
|
+
const post = await create({
|
|
320
|
+
data: {
|
|
321
|
+
title: formData.get("title") as string,
|
|
322
|
+
content: formData.get("content") as string,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
navigate({ to: "/posts/$postId", params: { postId: String(post.id) } });
|
|
326
|
+
} catch (err) {
|
|
327
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
333
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
334
|
+
<div className="space-y-2">
|
|
335
|
+
<Label htmlFor="title">Title</Label>
|
|
336
|
+
<Input id="title" name="title" required />
|
|
337
|
+
</div>
|
|
338
|
+
<div className="space-y-2">
|
|
339
|
+
<Label htmlFor="content">Content</Label>
|
|
340
|
+
<textarea id="content" name="content" className="w-full rounded-md border bg-background p-3" />
|
|
341
|
+
</div>
|
|
342
|
+
<Button type="submit">Create Post</Button>
|
|
343
|
+
</form>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### POST — Update
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const updatePostSchema = z.object({
|
|
352
|
+
id: z.number(),
|
|
353
|
+
title: z.string().min(1).max(200).optional(),
|
|
354
|
+
content: z.string().max(50000).optional(),
|
|
355
|
+
published: z.boolean().optional(),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
export const updatePost = createServerFn({ method: "POST" })
|
|
359
|
+
.inputValidator((d: unknown) => updatePostSchema.parse(d))
|
|
360
|
+
.handler(async ({ data }) => {
|
|
361
|
+
const { id, ...updates } = data;
|
|
362
|
+
const setValues: Record<string, unknown> = { updatedAt: new Date() };
|
|
363
|
+
if (updates.title !== undefined) setValues.title = updates.title;
|
|
364
|
+
if (updates.content !== undefined) setValues.content = updates.content;
|
|
365
|
+
if (updates.published !== undefined) setValues.published = updates.published;
|
|
366
|
+
|
|
367
|
+
const [updated] = await db().update(posts).set(setValues).where(eq(posts.id, id)).returning();
|
|
368
|
+
if (!updated) throw new Error("Post not found");
|
|
369
|
+
return updated;
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### POST — Delete
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
export const deletePost = createServerFn({ method: "POST" })
|
|
377
|
+
.inputValidator((d: unknown) => z.object({ id: z.number() }).parse(d))
|
|
378
|
+
.handler(async ({ data }) => {
|
|
379
|
+
const [deleted] = await db().delete(posts).where(eq(posts.id, data.id)).returning({ id: posts.id });
|
|
380
|
+
if (!deleted) throw new Error("Post not found");
|
|
381
|
+
return { success: true };
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Auth-Protected Server Functions
|
|
386
|
+
|
|
387
|
+
When `authentication` skill is set up, check session:
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
import { useAppSession } from "@/lib/session";
|
|
391
|
+
|
|
392
|
+
export const createPost = createServerFn({ method: "POST" })
|
|
393
|
+
.inputValidator((d: unknown) => createPostSchema.parse(d))
|
|
394
|
+
.handler(async ({ data }) => {
|
|
395
|
+
const session = await useAppSession();
|
|
396
|
+
if (!session.data.userId) {
|
|
397
|
+
return { error: "Not authenticated" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const [post] = await db()
|
|
401
|
+
.insert(posts)
|
|
402
|
+
.values({ title: data.title, content: data.content, authorId: session.data.userId })
|
|
403
|
+
.returning();
|
|
404
|
+
return post;
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
For GET functions in loaders, return empty instead of throwing:
|
|
409
|
+
```typescript
|
|
410
|
+
export const getMyPosts = createServerFn({ method: "GET" }).handler(async () => {
|
|
411
|
+
const session = await useAppSession();
|
|
412
|
+
if (!session.data.userId) return [];
|
|
413
|
+
return db().select().from(posts).where(eq(posts.authorId, session.data.userId)).orderBy(desc(posts.createdAt));
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Pagination
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
import { sql } from "drizzle-orm";
|
|
421
|
+
|
|
422
|
+
const listSchema = z.object({
|
|
423
|
+
page: z.number().int().min(1).default(1),
|
|
424
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
export const listPosts = createServerFn({ method: "GET" })
|
|
428
|
+
.inputValidator((d: unknown) => listSchema.parse(d))
|
|
429
|
+
.handler(async ({ data }) => {
|
|
430
|
+
const offset = (data.page - 1) * data.limit;
|
|
431
|
+
const [rows, countResult] = await Promise.all([
|
|
432
|
+
db().select().from(posts).orderBy(desc(posts.createdAt)).limit(data.limit).offset(offset),
|
|
433
|
+
db().select({ count: sql<number>`count(*)::int` }).from(posts),
|
|
434
|
+
]);
|
|
435
|
+
return {
|
|
436
|
+
items: rows,
|
|
437
|
+
total: countResult[0].count,
|
|
438
|
+
page: data.page,
|
|
439
|
+
totalPages: Math.ceil(countResult[0].count / data.limit),
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Search / Filter
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
import { ilike, and } from "drizzle-orm";
|
|
448
|
+
|
|
449
|
+
export const searchPosts = createServerFn({ method: "GET" })
|
|
450
|
+
.inputValidator((d: unknown) => z.object({ query: z.string().default("") }).parse(d))
|
|
451
|
+
.handler(async ({ data }) => {
|
|
452
|
+
const conditions = [];
|
|
453
|
+
if (data.query) conditions.push(ilike(posts.title, `%${data.query}%`));
|
|
454
|
+
return db()
|
|
455
|
+
.select().from(posts)
|
|
456
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
457
|
+
.orderBy(desc(posts.createdAt))
|
|
458
|
+
.limit(50);
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### JOIN (Relations)
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
export const getPostWithAuthor = createServerFn({ method: "GET" })
|
|
466
|
+
.inputValidator((id: number) => id)
|
|
467
|
+
.handler(async ({ data: id }) => {
|
|
468
|
+
const [result] = await db()
|
|
469
|
+
.select({
|
|
470
|
+
id: posts.id, title: posts.title, content: posts.content, createdAt: posts.createdAt,
|
|
471
|
+
author: { id: users.id, name: users.name, email: users.email },
|
|
472
|
+
})
|
|
473
|
+
.from(posts)
|
|
474
|
+
.innerJoin(users, eq(posts.authorId, users.id))
|
|
475
|
+
.where(eq(posts.id, id))
|
|
476
|
+
.limit(1);
|
|
477
|
+
if (!result) throw new Error("Post not found");
|
|
478
|
+
return result;
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
## Workflow
|
|
483
|
+
|
|
484
|
+
When the user asks to create backend logic for an entity:
|
|
485
|
+
|
|
486
|
+
1. **Understand the entity** — fields, access rules, operations needed
|
|
487
|
+
2. **Check existing setup:**
|
|
488
|
+
```
|
|
489
|
+
read_file("src/lib/db.ts") # DB client exists?
|
|
490
|
+
read_file("src/lib/schema.ts") # Existing tables?
|
|
491
|
+
ls("src/server") # Existing server functions?
|
|
492
|
+
```
|
|
493
|
+
3. **Create/migrate table** — `database-management` tools
|
|
494
|
+
4. **Install deps if needed** — `@neondatabase/serverless drizzle-orm zod`
|
|
495
|
+
5. **Add schema definition** — edit `src/lib/schema.ts`
|
|
496
|
+
6. **Create server functions** — `src/server/{entity}.ts`
|
|
497
|
+
7. **Create route files** — loaders + components using the server functions
|
|
498
|
+
8. **Run build to catch import errors:**
|
|
499
|
+
```
|
|
500
|
+
npm run build
|
|
501
|
+
```
|
|
502
|
+
Fix any errors before continuing. Build errors here almost always mean a dynamic import or a server-only import leaking into client code.
|
|
503
|
+
9. **Show preview**
|
|
504
|
+
|
|
505
|
+
## Error Handling
|
|
506
|
+
|
|
507
|
+
### In server functions — return errors for expected cases, throw for unexpected:
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
export const createPost = createServerFn({ method: "POST" })
|
|
511
|
+
.inputValidator((d: unknown) => createPostSchema.parse(d))
|
|
512
|
+
.handler(async ({ data }) => {
|
|
513
|
+
try {
|
|
514
|
+
const [post] = await db().insert(posts).values(data).returning();
|
|
515
|
+
return post;
|
|
516
|
+
} catch (err: any) {
|
|
517
|
+
// Handle unique constraint violations gracefully
|
|
518
|
+
if (err.message?.includes("unique constraint")) {
|
|
519
|
+
return { error: "A post with this title already exists" };
|
|
520
|
+
}
|
|
521
|
+
throw err; // Re-throw unexpected errors
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### In components — check for error in result:
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
const result = await create({ data: formValues });
|
|
530
|
+
if (result && "error" in result) {
|
|
531
|
+
setError(result.error);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
// Success — navigate or update UI
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### Zod validation errors — catch in form handler:
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
try {
|
|
541
|
+
await create({ data: formValues });
|
|
542
|
+
} catch (err) {
|
|
543
|
+
if (err instanceof z.ZodError) {
|
|
544
|
+
setError(err.errors.map(e => e.message).join(", "));
|
|
545
|
+
} else {
|
|
546
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## Drizzle Quick Reference
|
|
552
|
+
|
|
553
|
+
### SQL-to-Drizzle Type Mapping
|
|
554
|
+
|
|
555
|
+
| SQL Type | Drizzle | Notes |
|
|
556
|
+
|----------|---------|-------|
|
|
557
|
+
| `SERIAL` | `serial("col")` | Auto-increment integer PK |
|
|
558
|
+
| `TEXT` | `text("col")` | Unbounded string |
|
|
559
|
+
| `VARCHAR(N)` | `varchar("col", { length: N })` | Bounded string |
|
|
560
|
+
| `INTEGER` | `integer("col")` | 32-bit integer |
|
|
561
|
+
| `BOOLEAN` | `boolean("col")` | true/false |
|
|
562
|
+
| `TIMESTAMPTZ` | `timestamp("col", { withTimezone: true })` | Always use `withTimezone: true` |
|
|
563
|
+
| `JSONB` | `jsonb("col")` | JSON column |
|
|
564
|
+
| `TEXT[]` | `text("col").array()` | Array column |
|
|
565
|
+
| `REAL` | `real("col")` | Floating point |
|
|
566
|
+
|
|
567
|
+
### Query Operations
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
import { eq, ne, gt, gte, lt, lte, and, or, ilike, desc, asc, sql } from "drizzle-orm";
|
|
571
|
+
|
|
572
|
+
// Select
|
|
573
|
+
await db().select().from(posts);
|
|
574
|
+
await db().select().from(posts).where(eq(posts.id, 1));
|
|
575
|
+
await db().select({ id: posts.id, title: posts.title }).from(posts);
|
|
576
|
+
|
|
577
|
+
// Insert
|
|
578
|
+
const [post] = await db().insert(posts).values({ title: "Hi", authorId: 1 }).returning();
|
|
579
|
+
|
|
580
|
+
// Batch insert
|
|
581
|
+
await db().insert(posts).values([{ title: "A", authorId: 1 }, { title: "B", authorId: 1 }]).returning();
|
|
582
|
+
|
|
583
|
+
// Upsert (create or update)
|
|
584
|
+
await db().insert(posts).values({ id: 1, title: "Updated" })
|
|
585
|
+
.onConflictDoUpdate({ target: posts.id, set: { title: "Updated", updatedAt: new Date() } })
|
|
586
|
+
.returning();
|
|
587
|
+
|
|
588
|
+
// Update
|
|
589
|
+
const [updated] = await db().update(posts).set({ title: "New" }).where(eq(posts.id, 1)).returning();
|
|
590
|
+
|
|
591
|
+
// Delete
|
|
592
|
+
const [deleted] = await db().delete(posts).where(eq(posts.id, 1)).returning({ id: posts.id });
|
|
593
|
+
|
|
594
|
+
// Join
|
|
595
|
+
await db().select().from(posts).innerJoin(users, eq(posts.authorId, users.id));
|
|
596
|
+
|
|
597
|
+
// Count
|
|
598
|
+
await db().select({ count: sql<number>`count(*)::int` }).from(posts);
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## Zod Quick Reference
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
z.string().min(1).max(200) // required bounded string
|
|
605
|
+
z.string().email() // email validation
|
|
606
|
+
z.string().url() // URL validation
|
|
607
|
+
z.string().uuid() // UUID validation
|
|
608
|
+
z.string().regex(/^[a-z]+$/) // regex pattern
|
|
609
|
+
z.number().int().min(1) // positive integer
|
|
610
|
+
z.boolean().default(false) // boolean with default
|
|
611
|
+
z.string().optional() // optional
|
|
612
|
+
z.enum(["draft", "published"]) // enum
|
|
613
|
+
z.array(z.string()) // array
|
|
614
|
+
z.object({ key: z.string() }) // nested object
|
|
615
|
+
z.union([z.string(), z.number()]) // union type
|
|
616
|
+
z.string().transform(s => s.trim()) // transform value
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
**Always use `.inputValidator((d: unknown) => schema.parse(d))`** for proper type inference + runtime validation.
|
|
620
|
+
|
|
621
|
+
## Important Notes
|
|
622
|
+
|
|
623
|
+
- **Server functions run on the server only.** Database imports and `process.env` are safe inside `.handler()`.
|
|
624
|
+
- **Validators run on both client and server.** Keep them pure — no DB calls, no `process.env`.
|
|
625
|
+
- **GET** functions are for loaders. **POST** functions are for mutations via `useServerFn()`.
|
|
626
|
+
- **Do NOT create Express-style API routes.** `createServerFn` replaces the need for a separate API server.
|
|
627
|
+
- **Do NOT use `fetch("/api/...")`** patterns. Call server functions directly.
|
|
628
|
+
- **NEVER use `await import()` to load server functions** — always static imports at file top. Dynamic imports break TanStack Start's build-time bundle analysis and cause hard-to-debug build failures.
|
|
629
|
+
- **Schema in code must match the database.** After adding to `src/lib/schema.ts`, create the table via migration tools.
|
|
630
|
+
- **Run `npm run build` after writing first server function + route.** Catches import errors early before you write more code on top of a broken foundation.
|
|
631
|
+
- Use existing template UI components (Button, Input, Label, Card, etc.) for forms and data display. Add more via `npx shadcn@latest add <component>` — see `web-application-creation` skill for the full list.
|