create-questpie 2.0.2 → 2.0.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.
Files changed (40) hide show
  1. package/dist/index.mjs +244 -30
  2. package/package.json +1 -1
  3. package/skills/questpie/AGENTS.md +310 -103
  4. package/skills/questpie/SKILL.md +196 -84
  5. package/skills/questpie/coverage.json +213 -0
  6. package/skills/questpie/references/auth.md +119 -4
  7. package/skills/questpie/references/business-logic.md +126 -56
  8. package/skills/questpie/references/crud-api.md +231 -29
  9. package/skills/questpie/references/data-modeling.md +26 -6
  10. package/skills/questpie/references/extend.md +98 -7
  11. package/skills/questpie/references/field-types.md +14 -2
  12. package/skills/questpie/references/infrastructure-adapters.md +207 -32
  13. package/skills/questpie/references/mcp.md +147 -0
  14. package/skills/questpie/references/multi-tenancy.md +1 -2
  15. package/skills/questpie/references/production.md +218 -53
  16. package/skills/questpie/references/quickstart.md +31 -18
  17. package/skills/questpie/references/rules.md +140 -13
  18. package/skills/questpie/references/sandbox.md +110 -0
  19. package/skills/questpie/references/tanstack-query.md +34 -11
  20. package/skills/questpie/references/type-inference.md +167 -0
  21. package/skills/questpie/references/workflows.md +155 -0
  22. package/skills/questpie-admin/AGENTS.md +141 -68
  23. package/skills/questpie-admin/SKILL.md +96 -63
  24. package/skills/questpie-admin/references/blocks.md +28 -4
  25. package/skills/questpie-admin/references/custom-ui.md +1 -1
  26. package/skills/questpie-admin/references/views.md +21 -5
  27. package/templates/tanstack-start/AGENTS.md +15 -8
  28. package/templates/tanstack-start/CLAUDE.md +12 -5
  29. package/templates/tanstack-start/README.md +7 -6
  30. package/templates/tanstack-start/package.json +1 -0
  31. package/templates/tanstack-start/src/lib/query-client.ts +10 -1
  32. package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
  33. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
  34. package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
  35. package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
  36. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
  37. package/templates/tanstack-start/src/routes/admin/$.tsx +12 -1
  38. package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
  39. package/templates/tanstack-start/src/routes/api/$.ts +1 -2
  40. package/templates/tanstack-start/vite.config.ts +2 -2
@@ -8,8 +8,7 @@ Auth is configured via `config/auth.ts` using the `authConfig()` factory:
8
8
 
9
9
  ```ts
10
10
  // src/questpie/server/config/auth.ts
11
- import { authConfig } from "questpie";
12
-
11
+ import { authConfig } from "questpie/app";
13
12
  export default authConfig({
14
13
  emailAndPassword: {
15
14
  enabled: true,
@@ -38,7 +37,7 @@ Codegen discovers this file automatically. No manual registration needed.
38
37
  ### In Routes
39
38
 
40
39
  ```ts
41
- import { route } from "questpie";
40
+ import { route } from "questpie/services";
42
41
  import z from "zod";
43
42
 
44
43
  export default route()
@@ -95,16 +94,132 @@ export default route()
95
94
 
96
95
  ## User Collection
97
96
 
98
- The `adminModule` provides a built-in `user` collection. It stores:
97
+ The `adminModule` includes the starter auth model and provides the canonical Better Auth `user` collection. It stores:
99
98
 
100
99
  - `id` -- unique identifier
101
100
  - `email` -- email address
102
101
  - `name` -- display name
103
102
  - `image` -- avatar URL
104
103
  - `emailVerified` -- verification status
104
+ - `role` -- admin access role (`admin` or `user`)
105
+ - `avatar`, `banned`, `banReason`, `banExpires` -- admin-managed profile and access fields
105
106
 
106
107
  This collection is automatically created when you add the admin module to your config.
107
108
 
109
+ Critical: the built-in admin setup route and admin `AuthGuard` depend on `user.role`. Setup checks whether any user has `role = "admin"`, and the admin UI expects `session.user.role === "admin"`. Do not replace `collection("user")` from scratch in an app that uses `adminModule`; merge `starterModule.collections.user` and extend it if custom user fields or admin layout are needed.
110
+
111
+ ```ts
112
+ import { starterModule } from "questpie/app";
113
+ import { collection } from "#questpie/factories";
114
+
115
+ export default collection("user")
116
+ .merge(starterModule.collections.user)
117
+ .fields(({ f }) => ({
118
+ internalNotes: f.textarea(),
119
+ }));
120
+ ```
121
+
122
+ `.fields()` is cumulative -- it adds to the merged starter fields and overrides them by key, never wipes them, so this recipe keeps the full starter user model.
123
+
124
+ ### Anonymous Users (Better Auth plugin)
125
+
126
+ Better Auth plugins that extend the user model follow the same recipe. For the anonymous plugin, register it in `auth.ts` (merged after the built-in plugins) and extend the starter user with the `isAnonymous` field the plugin expects:
127
+
128
+ ```ts
129
+ // auth.ts
130
+ import { anonymous } from "better-auth/plugins";
131
+ import type { AuthConfig } from "questpie/app";
132
+
133
+ export default {
134
+ plugins: [anonymous()],
135
+ } satisfies AuthConfig;
136
+ ```
137
+
138
+ ```ts
139
+ // collections/user.ts
140
+ import { starterModule } from "questpie/app";
141
+ import { collection } from "#questpie/factories";
142
+
143
+ export default collection("user")
144
+ .merge(starterModule.collections.user)
145
+ .fields(({ f }) => ({
146
+ isAnonymous: f.boolean().default(false),
147
+ }));
148
+ ```
149
+
150
+ Run `questpie generate` and apply migrations to add the column. Anonymous sign-in (`authClient.signIn.anonymous()` on the client) creates throwaway users that Better Auth can later link to real accounts.
151
+
152
+ ## Reaching the App from Better Auth Callbacks
153
+
154
+ The `/auth/*` catch-all is a plain **raw route**, and raw routes execute their handler inside `runWithContext()` (the request's AsyncLocalStorage scope). That means every Better Auth callback — `onLinkAccount`, `databaseHooks`, `sendMagicLink`, plugin hooks — already runs inside the request scope, and `getContext<App>()` returns the live app, session, db, and locale.
155
+
156
+ **Never build a module-level app singleton or a hand-rolled context bridge for auth callbacks.** The `App` import stays type-only, so there is no circular import:
157
+
158
+ ```ts
159
+ // config/auth.ts
160
+ import { anonymous } from "better-auth/plugins";
161
+ import { getContext } from "questpie";
162
+ import { authConfig } from "questpie/app";
163
+ import type { App } from "#questpie"; // type-only — no runtime cycle
164
+
165
+ export default authConfig({
166
+ plugins: [
167
+ anonymous({
168
+ // Fires when an anonymous user signs in with a real account —
169
+ // re-point the guest's rows onto the new user before the plugin
170
+ // deletes the anonymous user.
171
+ onLinkAccount: async ({ anonymousUser, newUser }) => {
172
+ const { app } = getContext<App>();
173
+ // Bare { accessMode: "system" } elevates ONLY the mode —
174
+ // session, db, and locale inherit from the request scope (ALS).
175
+ await app.collections.memberships.updateMany(
176
+ {
177
+ where: { user: anonymousUser.user.id },
178
+ data: { user: newUser.user.id },
179
+ },
180
+ { accessMode: "system" },
181
+ );
182
+ },
183
+ }),
184
+ ],
185
+ });
186
+ ```
187
+
188
+ ### Partial Context Overrides
189
+
190
+ CRUD context normalization merges what you pass with the ambient request scope — priority: explicit param → ALS scope → defaults (`accessMode: "system"`, `locale: "en"`). Passing only `{ accessMode: "system" }` elevates the mode while the request's session/db/locale ride along. The inverse also holds: `{ accessMode: "user" }` inside system-scoped code re-enables access rules against the inherited session without re-threading it:
191
+
192
+ ```ts
193
+ // Inside any handler — session comes from the request ALS scope
194
+ await app.collections.posts.find({}, { accessMode: "user" }); // rules enforced for the current user
195
+ await app.collections.posts.find({}, { accessMode: "system" }); // rules bypassed, same session/locale
196
+ ```
197
+
198
+ ## Client-Side Auth (authClient)
199
+
200
+ For session state and sign-in/out on the frontend, create a typed Better Auth client. In admin-equipped apps use the typed wrapper (session includes your merged user fields):
201
+
202
+ ```ts
203
+ // src/lib/auth-client.ts
204
+ import { createAdminAuthClient } from "@questpie/admin/client";
205
+ import type { AppConfig } from "#questpie";
206
+ import { env } from "#questpie/env.client.vite"; // generated from env.client.ts
207
+
208
+ export const authClient = createAdminAuthClient<AppConfig>({
209
+ baseURL: typeof window !== "undefined" ? window.location.origin : env.APP_URL,
210
+ basePath: "/api/auth",
211
+ });
212
+ ```
213
+
214
+ ```tsx
215
+ const { data: session, isPending } = authClient.useSession();
216
+ await authClient.signIn.email({ email, password });
217
+ await authClient.signIn.anonymous(); // with the anonymous plugin
218
+ await authClient.signOut();
219
+ ```
220
+
221
+ Apps without `@questpie/admin` use Better Auth's own `createAuthClient` from `better-auth/react` pointed at `${APP_URL}/api/auth` — same call surface, without the app-inferred session typing.
222
+
108
223
  ## Environment Variables
109
224
 
110
225
  | Variable | Required | Description |
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: questpie-core-business-logic
3
- description: QUESTPIE routes jobs services emails route job service email background queue scheduling Zod input validation server-side logic reusable services email templates
3
+ description:
4
+ QUESTPIE routes jobs services emails route job service email background queue scheduling Zod input validation server-side logic reusable services email templates
4
5
  - questpie-core
5
6
  ---
6
7
 
@@ -16,7 +17,7 @@ JSON routes are typed server-side endpoints. Define an input schema with Zod, wr
16
17
 
17
18
  ```ts
18
19
  // routes/get-active-barbers.ts
19
- import { route } from "questpie";
20
+ import { route } from "questpie/services";
20
21
  import z from "zod";
21
22
 
22
23
  export default route()
@@ -37,7 +38,7 @@ JSON routes validate input with Zod automatically:
37
38
 
38
39
  ```ts
39
40
  // routes/create-booking.ts
40
- import { route } from "questpie";
41
+ import { route } from "questpie/services";
41
42
  import z from "zod";
42
43
 
43
44
  export default route()
@@ -77,15 +78,18 @@ export default route()
77
78
 
78
79
  Route handlers receive the full `AppContext`:
79
80
 
80
- | Property | Description |
81
- | ------------- | -------------------------------------- |
82
- | `input` | Validated data matching the Zod schema |
83
- | `collections` | Typed collection API |
84
- | `queue` | Publish background jobs |
85
- | `email` | Send emails |
86
- | `db` | Raw database access |
87
- | `session` | Current auth session |
88
- | `services` | Custom services from `services/` |
81
+ | Property | Description |
82
+ | ------------- | ---------------------------------------------------------- |
83
+ | `input` | Validated data matching the Zod schema |
84
+ | `collections` | Typed collection API |
85
+ | `queue` | Publish background jobs |
86
+ | `email` | Send emails |
87
+ | `db` | Raw database access |
88
+ | `session` | Current auth session |
89
+ | `services` | Custom services from `services/` |
90
+ | _extensions_ | `appConfig({ context })` result, flat (e.g. `workspaceId`) |
91
+
92
+ Derived request context (from `appConfig({ context })`) reaches route access rules and handlers alike — destructure the keys directly. Inside any nested code, `getContext<App>()` exposes the same keys (see `references/multi-tenancy.md`).
89
93
 
90
94
  ### Calling Routes
91
95
 
@@ -125,7 +129,7 @@ Jobs are background tasks that run outside the request lifecycle. Ideal for send
125
129
 
126
130
  ```ts
127
131
  // jobs/send-appointment-confirmation.ts
128
- import { job } from "questpie";
132
+ import { job } from "questpie/services";
129
133
  import z from "zod";
130
134
 
131
135
  export default job({
@@ -182,6 +186,31 @@ Publish from hooks, routes, or other jobs via the typed `queue` context:
182
186
 
183
187
  The `queue` object provides full autocompletion for all jobs and their payloads.
184
188
 
189
+ ### Recurring Jobs (Cron)
190
+
191
+ Jobs accept a job-level cron expression in `options.cron`. Schedules are registered automatically when the queue worker starts (`app.queue.listen()` calls `registerSchedules()`):
192
+
193
+ ```ts
194
+ // jobs/cleanup-expired.ts
195
+ import { job } from "questpie/services";
196
+ import z from "zod";
197
+
198
+ export default job({
199
+ name: "cleanupExpired",
200
+ schema: z.object({}),
201
+ options: { cron: "0 3 * * *" }, // every day at 03:00
202
+ handler: async ({ collections }) => {
203
+ await collections.sessions.deleteMany({
204
+ where: { expiresAt: { lt: new Date() } },
205
+ });
206
+ },
207
+ });
208
+ ```
209
+
210
+ Programmatic scheduling from any handler: `queue.cleanupExpired.schedule({}, "0 3 * * *")` and `queue.cleanupExpired.unschedule()`.
211
+
212
+ Use job-level cron for simple recurring tasks (cleanup, digests, syncs). Reach for **workflow-level cron** (`references/workflows.md`) only when the recurring process needs steps, durable waits, or replay — a workflow is the heavier primitive.
213
+
185
214
  ### Job Handler Context
186
215
 
187
216
  | Property | Description |
@@ -198,7 +227,8 @@ Configure the queue adapter in your runtime config:
198
227
 
199
228
  ```ts
200
229
  // questpie.config.ts
201
- import { pgBossAdapter, runtimeConfig } from "questpie";
230
+ import { runtimeConfig } from "questpie/app";
231
+ import { pgBossAdapter } from "questpie/adapters/pg-boss";
202
232
 
203
233
  export default runtimeConfig({
204
234
  queue: {
@@ -209,26 +239,28 @@ export default runtimeConfig({
209
239
  });
210
240
  ```
211
241
 
212
- ## Routes
242
+ ## Raw Routes
213
243
 
214
- Routes give raw HTTP request/response handling for webhooks, OAuth callbacks, health checks, file downloads, and streaming.
244
+ Raw routes (`route().raw()`) give raw HTTP request/response handling for webhooks, OAuth callbacks, health checks, file downloads, and streaming. The handler receives the standard `Request` and must return a `Response`.
215
245
 
216
- ### Defining a Route
246
+ ### Defining a Raw Route
217
247
 
218
248
  ```ts
219
249
  // routes/health.ts
220
- import { route } from "questpie";
250
+ import { sql } from "questpie/drizzle";
251
+ import { route } from "questpie/services";
221
252
 
222
- export default route({
223
- method: "GET",
224
- handler: async ({ db }) => {
253
+ export default route()
254
+ .get()
255
+ .raw()
256
+ .access(true)
257
+ .handler(async ({ db }) => {
225
258
  const healthy = await db
226
259
  .execute(sql`SELECT 1`)
227
260
  .then(() => true)
228
261
  .catch(() => false);
229
262
  return Response.json({ status: healthy ? "ok" : "degraded" });
230
- },
231
- });
263
+ });
232
264
  ```
233
265
 
234
266
  Place files in `routes/`. The file path maps to a flat URL under your `basePath` (`/api` by default):
@@ -243,27 +275,29 @@ routes/
243
275
 
244
276
  ### Route Methods
245
277
 
278
+ Chain HTTP method calls on the builder (multiple methods = multiple calls):
279
+
246
280
  ```ts
247
- route({ method: "POST", handler: ... }) // Single method
248
- route({ method: ["GET", "POST"], handler: ... }) // Multiple methods
249
- route({ handler: ... }) // All methods (default)
281
+ route().post().raw().handler(...) // POST only
282
+ route().get().post().raw().handler(...) // GET + POST
250
283
  ```
251
284
 
252
- Supported: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`.
285
+ Supported: `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()`. The built-in `/auth/*` catch-all is itself a raw route (`route().get().post().raw()` delegating to Better Auth) — raw handlers run inside `runWithContext`, so the full request context is live in any code they call.
253
286
 
254
287
  ### Route Handler Context
255
288
 
256
- | Property | Type | Description |
257
- | ------------- | ------------------------ | ------------------------ |
258
- | `request` | `Request` | Standard Web API Request |
259
- | `params` | `Record<string, string>` | URL path parameters |
260
- | `locale` | `string` | Current locale |
261
- | `db` | `Database` | Database instance |
262
- | `session` | `Session \| null` | Current auth session |
263
- | `collections` | `CollectionsAPI` | Typed collection API |
264
- | `queue` | `QueueClient` | Queue client |
265
- | `email` | `MailerService` | Email service |
266
- | `services` | | User-defined services |
289
+ | Property | Type | Description |
290
+ | ------------- | ------------------------ | -------------------------------------------- |
291
+ | `request` | `Request` | Standard Web API Request |
292
+ | `params` | `Record<string, string>` | URL path parameters |
293
+ | `locale` | `string` | Current locale |
294
+ | `db` | `Database` | Database instance |
295
+ | `session` | `Session \| null` | Current auth session |
296
+ | `collections` | `CollectionsAPI` | Typed collection API |
297
+ | `queue` | `QueueClient` | Queue client |
298
+ | `email` | `MailerService` | Email service |
299
+ | `services` | | User-defined services |
300
+ | _extensions_ | | `appConfig({ context })` result, flat |
267
301
 
268
302
  Route handlers must return a `Response` object.
269
303
 
@@ -279,27 +313,66 @@ Route handlers must return a `Response` object.
279
313
 
280
314
  **Rule of thumb**: Use JSON routes for typed input/output with automatic validation. Use raw routes for HTTP-level control (custom headers, binary data, streams, signature verification).
281
315
 
282
- ### Webhook Example
316
+ ### Webhook Example (Signature Verification)
317
+
318
+ Webhooks need the raw body for signature verification — exactly what `.raw()` is for:
283
319
 
284
320
  ```ts
285
321
  // routes/webhooks/stripe.ts
286
- import { route } from "questpie";
322
+ import { route } from "questpie/services";
287
323
 
288
- export default route({
289
- method: "POST",
290
- handler: async ({ request, db }) => {
324
+ export default route()
325
+ .post()
326
+ .raw()
327
+ .access(true) // signature IS the auth — verify it yourself below
328
+ .handler(async ({ request, collections, queue }) => {
291
329
  const body = await request.text();
292
330
  const signature = request.headers.get("stripe-signature");
293
- const event = verifyStripeWebhook(body, signature);
331
+ const event = verifyStripeWebhook(body, signature); // throws on bad signature
332
+ if (!event) return new Response("Invalid signature", { status: 401 });
294
333
 
295
- await db.insert(webhookEvents).values({
334
+ await collections.webhook_events.create({
296
335
  type: event.type,
297
336
  payload: body,
298
337
  });
338
+ await queue.processStripeEvent.publish({ eventId: event.id });
299
339
 
300
340
  return new Response("OK", { status: 200 });
301
- },
302
- });
341
+ });
342
+ ```
343
+
344
+ ### Streamed Response Example
345
+
346
+ Raw routes can return any `Response`, including streams — CSV exports, server-sent progress, large file proxies:
347
+
348
+ ```ts
349
+ // routes/export.ts
350
+ import { route } from "questpie/services";
351
+
352
+ export default route()
353
+ .get()
354
+ .raw()
355
+ .access(({ session }) => !!session)
356
+ .handler(async ({ collections }) => {
357
+ const { docs } = await collections.orders.find({ limit: 10_000 });
358
+
359
+ const stream = new ReadableStream({
360
+ start(controller) {
361
+ controller.enqueue("id,total,createdAt\n");
362
+ for (const order of docs) {
363
+ controller.enqueue(`${order.id},${order.total},${order.createdAt.toISOString()}\n`);
364
+ }
365
+ controller.close();
366
+ },
367
+ });
368
+
369
+ return new Response(stream, {
370
+ headers: {
371
+ "Content-Type": "text/csv; charset=utf-8",
372
+ "Content-Disposition": 'attachment; filename="orders.csv"',
373
+ },
374
+ });
375
+ });
303
376
  ```
304
377
 
305
378
  ## Services
@@ -310,8 +383,7 @@ Services are reusable units of logic injected into `AppContext` under the `servi
310
383
 
311
384
  ```ts
312
385
  // services/blog.ts
313
- import { service } from "questpie";
314
-
386
+ import { service } from "questpie/services";
315
387
  const WORDS_PER_MINUTE = 200;
316
388
 
317
389
  function stripHtml(html: string): string {
@@ -372,7 +444,7 @@ Services are available via `services` destructuring in any handler:
372
444
 
373
445
  ```ts
374
446
  // services/stripe.ts
375
- import { service } from "questpie";
447
+ import { service } from "questpie/services";
376
448
  import Stripe from "stripe";
377
449
 
378
450
  export default service({
@@ -385,8 +457,7 @@ export default service({
385
457
 
386
458
  ```ts
387
459
  // services/tenant-db.ts
388
- import { service } from "questpie";
389
-
460
+ import { service } from "questpie/services";
390
461
  export default service({
391
462
  lifecycle: "request",
392
463
  deps: ["db", "session"] as const,
@@ -403,8 +474,7 @@ Services can depend on other services and infrastructure via `deps`. Use `as con
403
474
 
404
475
  ```ts
405
476
  // services/analytics.ts
406
- import { service } from "questpie";
407
-
477
+ import { service } from "questpie/services";
408
478
  export default service({
409
479
  deps: ["db", "logger"] as const,
410
480
  create: ({ db, logger }) => {
@@ -449,7 +519,7 @@ Email templates are defined in `emails/` and discovered by codegen. Each templat
449
519
 
450
520
  ```ts
451
521
  // emails/appointment-confirmation.ts
452
- import { email } from "questpie";
522
+ import { email } from "questpie/services";
453
523
  import { z } from "zod";
454
524
 
455
525
  export default email({
@@ -500,7 +570,7 @@ Email handlers receive the full `AppContext` for fetching data:
500
570
 
501
571
  ```ts
502
572
  // emails/weekly-digest.ts
503
- import { email } from "questpie";
573
+ import { email } from "questpie/services";
504
574
  import { z } from "zod";
505
575
 
506
576
  export default email({