create-questpie 2.0.3 → 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.
- package/dist/index.mjs +244 -30
- package/package.json +1 -1
- package/skills/questpie/AGENTS.md +299 -98
- package/skills/questpie/SKILL.md +50 -17
- package/skills/questpie/coverage.json +213 -0
- package/skills/questpie/references/auth.md +119 -4
- package/skills/questpie/references/business-logic.md +126 -56
- package/skills/questpie/references/crud-api.md +231 -29
- package/skills/questpie/references/data-modeling.md +22 -6
- package/skills/questpie/references/extend.md +34 -7
- package/skills/questpie/references/field-types.md +14 -2
- package/skills/questpie/references/infrastructure-adapters.md +207 -32
- package/skills/questpie/references/mcp.md +147 -0
- package/skills/questpie/references/multi-tenancy.md +1 -2
- package/skills/questpie/references/production.md +218 -53
- package/skills/questpie/references/quickstart.md +6 -8
- package/skills/questpie/references/rules.md +86 -21
- package/skills/questpie/references/sandbox.md +110 -0
- package/skills/questpie/references/tanstack-query.md +34 -11
- package/skills/questpie/references/type-inference.md +167 -0
- package/skills/questpie/references/workflows.md +155 -0
- package/skills/questpie-admin/AGENTS.md +47 -40
- package/skills/questpie-admin/SKILL.md +46 -39
- package/skills/questpie-admin/references/custom-ui.md +1 -1
- package/templates/tanstack-start/AGENTS.md +15 -8
- package/templates/tanstack-start/CLAUDE.md +12 -5
- package/templates/tanstack-start/README.md +7 -6
- package/templates/tanstack-start/package.json +1 -0
- package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
- package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
- package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
- package/templates/tanstack-start/src/routes/api/$.ts +1 -2
- package/templates/tanstack-start/vite.config.ts +2 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# MCP Integration
|
|
2
|
+
|
|
3
|
+
Use `@questpie/mcp` when a QUESTPIE app should expose collections, globals, annotated JSON routes, schemas, or custom tools to Model Context Protocol clients.
|
|
4
|
+
|
|
5
|
+
## Static Module Pattern
|
|
6
|
+
|
|
7
|
+
MCP is codegen-aware. Keep `modules.ts` static and put options in `config/mcp.ts`.
|
|
8
|
+
|
|
9
|
+
```ts title="modules.ts"
|
|
10
|
+
import mcpModule from "@questpie/mcp";
|
|
11
|
+
|
|
12
|
+
export default [mcpModule] as const;
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts title="config/mcp.ts"
|
|
16
|
+
import { mcpConfig } from "@questpie/mcp";
|
|
17
|
+
|
|
18
|
+
export default mcpConfig({
|
|
19
|
+
crud: {
|
|
20
|
+
defaults: {
|
|
21
|
+
collections: { read: true, write: false, delete: false },
|
|
22
|
+
globals: { read: true, write: false },
|
|
23
|
+
},
|
|
24
|
+
collections: {
|
|
25
|
+
posts: { read: true, write: true },
|
|
26
|
+
users: false,
|
|
27
|
+
},
|
|
28
|
+
globals: {
|
|
29
|
+
siteSettings: { read: true, write: true },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
routes: {
|
|
33
|
+
exposeAnnotated: true,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Do not use `mcpModule(options)`. Runtime options belong in the plugin-discovered config file.
|
|
39
|
+
|
|
40
|
+
`mcpModule` carries its codegen plugin. Do not also add `mcpPlugin()` to `questpie.config.ts` unless you are doing a custom setup that deliberately omits `mcpModule` — double registration duplicates the plugin.
|
|
41
|
+
|
|
42
|
+
## CRUD Policy
|
|
43
|
+
|
|
44
|
+
Generated collection tools:
|
|
45
|
+
|
|
46
|
+
- `collections.{name}.list`
|
|
47
|
+
- `collections.{name}.count`
|
|
48
|
+
- `collections.{name}.get`
|
|
49
|
+
- `collections.{name}.create`
|
|
50
|
+
- `collections.{name}.update`
|
|
51
|
+
- `collections.{name}.delete`
|
|
52
|
+
|
|
53
|
+
Generated global tools:
|
|
54
|
+
|
|
55
|
+
- `globals.{name}.get`
|
|
56
|
+
- `globals.{name}.update`
|
|
57
|
+
|
|
58
|
+
Policy order:
|
|
59
|
+
|
|
60
|
+
1. Transport defaults.
|
|
61
|
+
2. CRUD defaults.
|
|
62
|
+
3. Per-entity override.
|
|
63
|
+
4. QUESTPIE access rules execute last and can still deny.
|
|
64
|
+
|
|
65
|
+
HTTP is user mode and read-oriented by default. HTTP cannot be made system mode with config or options. Stdio defaults to trusted system mode unless explicitly lowered to user mode.
|
|
66
|
+
|
|
67
|
+
Use `fields.include` / `fields.exclude` for top-level filtering. It applies to create/update input, CRUD outputs, list docs, global results, and schema resources. Nested relation projection is out of scope for v1.
|
|
68
|
+
|
|
69
|
+
## Route Tools
|
|
70
|
+
|
|
71
|
+
Only simple JSON routes are auto-converted:
|
|
72
|
+
|
|
73
|
+
- Route has `.schema(...)`.
|
|
74
|
+
- Route is not `.raw()`.
|
|
75
|
+
- Route has `meta.mcp.expose === true`.
|
|
76
|
+
- `routes.exposeAnnotated` is not `false`.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
route()
|
|
80
|
+
.post()
|
|
81
|
+
.schema(inputSchema)
|
|
82
|
+
.outputSchema(outputSchema)
|
|
83
|
+
.meta({
|
|
84
|
+
title: "Generate report",
|
|
85
|
+
mcp: {
|
|
86
|
+
expose: true,
|
|
87
|
+
name: "reports.generate",
|
|
88
|
+
annotations: { readOnlyHint: true },
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
.handler(async ({ input }) => ({ ok: true }));
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Routes without path params use the route input schema directly. Routes with params use `{ params, input }`. Route policy keys use the route key, not the overridden tool name.
|
|
95
|
+
|
|
96
|
+
## Resources
|
|
97
|
+
|
|
98
|
+
Built-in resources:
|
|
99
|
+
|
|
100
|
+
- `questpie://schema/collections`
|
|
101
|
+
- `questpie://schema/collections/{name}`
|
|
102
|
+
- `questpie://schema/globals`
|
|
103
|
+
- `questpie://schema/globals/{name}`
|
|
104
|
+
- `questpie://schema/routes`
|
|
105
|
+
- `questpie://schema/routes/{key}`
|
|
106
|
+
|
|
107
|
+
Resources honor MCP policy and QUESTPIE access visibility. Route resources include input/output JSON Schema when the route has Zod schemas.
|
|
108
|
+
|
|
109
|
+
## Custom Tools
|
|
110
|
+
|
|
111
|
+
Custom tools live in `mcp-tools/` and are discovered by codegen.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { mcpTool } from "@questpie/mcp";
|
|
115
|
+
import { z } from "zod";
|
|
116
|
+
|
|
117
|
+
export default mcpTool("generate-report", {
|
|
118
|
+
description: "Generate a report.",
|
|
119
|
+
inputSchema: z.object({ period: z.string() }),
|
|
120
|
+
access: ({ session }) => !!session,
|
|
121
|
+
}).handler(async ({ input, ctx }) => ({
|
|
122
|
+
structuredContent: await ctx.services.reports.generate(input),
|
|
123
|
+
}));
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Custom tool access is checked during `tools/list` and again during `tools/call`.
|
|
127
|
+
|
|
128
|
+
## Programmatic Servers
|
|
129
|
+
|
|
130
|
+
Use `createMcpServer(app, { transport: "http", request })` for programmatic HTTP setup. If no `ctx` is passed, the request is preserved through `app.createContext()`.
|
|
131
|
+
|
|
132
|
+
Use `startStdioServer(app)` for trusted stdio integrations:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { app } from "#questpie";
|
|
136
|
+
import { startStdioServer } from "@questpie/mcp/stdio";
|
|
137
|
+
|
|
138
|
+
await startStdioServer(app);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Gotchas
|
|
142
|
+
|
|
143
|
+
- Add `mcpModule` to static `modules.ts`, then run codegen.
|
|
144
|
+
- HTTP system mode is intentionally impossible until a future trusted-token design exists.
|
|
145
|
+
- Field filtering is top-level only.
|
|
146
|
+
- Raw routes and unannotated routes are not tools.
|
|
147
|
+
- Custom tool results should use `structuredContent` for machine-readable output.
|
|
@@ -278,8 +278,7 @@ For advanced cases, create a request-scoped service that provides a tenant-aware
|
|
|
278
278
|
|
|
279
279
|
```ts
|
|
280
280
|
// services/scoped-db.ts
|
|
281
|
-
import { service } from "questpie";
|
|
282
|
-
|
|
281
|
+
import { service } from "questpie/services";
|
|
283
282
|
export default service({
|
|
284
283
|
lifecycle: "request",
|
|
285
284
|
deps: ["db", "session"] as const,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: questpie-core/production
|
|
3
3
|
description:
|
|
4
|
-
QUESTPIE production deployment authentication better-auth OAuth database PostgreSQL Drizzle storage S3
|
|
4
|
+
QUESTPIE production deployment authentication better-auth OAuth database PostgreSQL Drizzle storage S3 Files SDK queue pg-boss jobs realtime SSE pgNotify Redis migrations email SMTP KV key-value logger Pino OpenAPI Docker environment variables adapters infrastructure
|
|
5
5
|
- questpie-core
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -11,15 +11,40 @@ This skill builds on questpie-core.
|
|
|
11
11
|
|
|
12
12
|
QUESTPIE uses an adapter-based architecture for all infrastructure. Development defaults work out of the box; production requires explicit adapter configuration in `questpie.config.ts`.
|
|
13
13
|
|
|
14
|
-
| Service | Dev Default | Production Adapter
|
|
15
|
-
| -------- | --------------------- |
|
|
16
|
-
| Database | PostgreSQL (local) | PostgreSQL (remote, SSL)
|
|
17
|
-
| Storage | Local filesystem |
|
|
18
|
-
| Queue | None (jobs skip) | pg-boss (`pgBossAdapter`)
|
|
19
|
-
| Realtime | pgNotify | Redis Streams (`redisStreamsAdapter`)
|
|
20
|
-
| Email | Console (logs output) | SMTP (`SmtpAdapter`)
|
|
21
|
-
| KV Store | In-memory | Redis (`
|
|
22
|
-
| Logger | Pino (console) | Pino (structured JSON)
|
|
14
|
+
| Service | Dev Default | Production Adapter |
|
|
15
|
+
| -------- | --------------------- | ----------------------------------------------- |
|
|
16
|
+
| Database | PostgreSQL (local) | PostgreSQL (remote, SSL) |
|
|
17
|
+
| Storage | Local filesystem | Files SDK provider adapter (`s3`, `r2`, etc.) |
|
|
18
|
+
| Queue | None (jobs skip) | pg-boss (`pgBossAdapter`) |
|
|
19
|
+
| Realtime | pgNotify | Redis Streams (`redisStreamsAdapter`) |
|
|
20
|
+
| Email | Console (logs output) | SMTP (`SmtpAdapter`) |
|
|
21
|
+
| KV Store | In-memory | Redis (`redisKVAdapter`) |
|
|
22
|
+
| Logger | Pino (console) | Pino (structured JSON) |
|
|
23
|
+
|
|
24
|
+
## Environment
|
|
25
|
+
|
|
26
|
+
Declare every env var once in `env.ts` (beside `questpie.config.ts`) — schema-validated at boot, typed everywhere. Never use raw `process.env.X` / `process.env.X!` in app code. Full reference: `references/env.md`.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// src/questpie/server/env.ts
|
|
30
|
+
import { env } from "questpie/env";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
export default env({
|
|
34
|
+
server: {
|
|
35
|
+
DATABASE_URL: z.url(),
|
|
36
|
+
BETTER_AUTH_SECRET: z.string().min(32),
|
|
37
|
+
S3_BUCKET: z.string().optional(),
|
|
38
|
+
S3_REGION: z.string().optional(),
|
|
39
|
+
S3_ACCESS_KEY: z.string().optional(),
|
|
40
|
+
S3_SECRET_KEY: z.string().optional(),
|
|
41
|
+
SMTP_HOST: z.string().optional(),
|
|
42
|
+
REDIS_URL: z.url().optional(),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
All snippets below assume `import env from "./env"` at the top of `questpie.config.ts`.
|
|
23
48
|
|
|
24
49
|
## Authentication
|
|
25
50
|
|
|
@@ -27,16 +52,18 @@ QUESTPIE uses [Better Auth](https://www.better-auth.com/). Configure via `config
|
|
|
27
52
|
|
|
28
53
|
```ts
|
|
29
54
|
// src/questpie/server/config/auth.ts
|
|
30
|
-
import { authConfig } from "questpie";
|
|
55
|
+
import { authConfig } from "questpie/app";
|
|
56
|
+
|
|
57
|
+
import env from "../env";
|
|
31
58
|
|
|
32
59
|
export default authConfig({
|
|
33
60
|
emailAndPassword: {
|
|
34
61
|
enabled: true,
|
|
35
62
|
requireEmailVerification: false,
|
|
36
63
|
},
|
|
37
|
-
baseURL:
|
|
64
|
+
baseURL: env.APP_URL ?? "http://localhost:3000",
|
|
38
65
|
basePath: "/api/auth",
|
|
39
|
-
secret:
|
|
66
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
40
67
|
});
|
|
41
68
|
```
|
|
42
69
|
|
|
@@ -73,7 +100,28 @@ handler: async ({ session }) => {
|
|
|
73
100
|
})
|
|
74
101
|
```
|
|
75
102
|
|
|
76
|
-
The `adminModule` provides
|
|
103
|
+
The `adminModule` provides the canonical Better Auth `user` collection for storing user accounts. That contract includes `user.role` (`admin` or `user`), which built-in admin setup and login guards depend on. Do not replace `collection("user")` from scratch in apps that use `adminModule`; merge `starterModule.collections.user` and extend it instead.
|
|
104
|
+
|
|
105
|
+
### Locking Down the REST Surface
|
|
106
|
+
|
|
107
|
+
Deny-all is actually deny-all — there are no implicit framework grants above
|
|
108
|
+
`defaultAccess`:
|
|
109
|
+
|
|
110
|
+
```ts title="config/app.ts"
|
|
111
|
+
export default appConfig({
|
|
112
|
+
access: { read: false, create: false, update: false, delete: false },
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
With this config, anonymous and authenticated callers get nothing unless a
|
|
117
|
+
collection opts in via `.access()`: no row listing (including
|
|
118
|
+
public-visibility upload collections like `assets`), and no schema/meta
|
|
119
|
+
introspection (gated by the same access system — visible iff at least one
|
|
120
|
+
operation is allowed, overridable with the `introspect` access kind). Public
|
|
121
|
+
upload files still serve by key (`GET /:collection/files/:key`) because
|
|
122
|
+
`visibility: "public"` declares the BYTES public — override with the `serve`
|
|
123
|
+
access kind. Do not wrap schema/meta routes in custom auth middleware; use
|
|
124
|
+
`introspect` rules instead.
|
|
77
125
|
|
|
78
126
|
## Database
|
|
79
127
|
|
|
@@ -82,7 +130,7 @@ PostgreSQL with Drizzle ORM. Schema is generated from your collection and global
|
|
|
82
130
|
```ts
|
|
83
131
|
export default runtimeConfig({
|
|
84
132
|
db: {
|
|
85
|
-
url:
|
|
133
|
+
url: env.DATABASE_URL,
|
|
86
134
|
},
|
|
87
135
|
});
|
|
88
136
|
```
|
|
@@ -122,7 +170,7 @@ Configure migration and seed directories in `questpie.config.ts` under `cli.migr
|
|
|
122
170
|
|
|
123
171
|
## Storage
|
|
124
172
|
|
|
125
|
-
QUESTPIE uses [
|
|
173
|
+
QUESTPIE uses [Files SDK](https://files-sdk.dev/) for file storage.
|
|
126
174
|
|
|
127
175
|
### Local (Development Default)
|
|
128
176
|
|
|
@@ -137,16 +185,36 @@ export default runtimeConfig({
|
|
|
137
185
|
### S3 (Production)
|
|
138
186
|
|
|
139
187
|
```ts
|
|
140
|
-
import {
|
|
188
|
+
import { s3 } from "files-sdk/s3";
|
|
141
189
|
|
|
142
190
|
export default runtimeConfig({
|
|
143
191
|
storage: {
|
|
144
192
|
basePath: "/api",
|
|
145
|
-
|
|
146
|
-
bucket:
|
|
147
|
-
region:
|
|
148
|
-
|
|
149
|
-
|
|
193
|
+
adapter: s3({
|
|
194
|
+
bucket: env.S3_BUCKET,
|
|
195
|
+
region: env.S3_REGION,
|
|
196
|
+
credentials: {
|
|
197
|
+
accessKeyId: env.S3_ACCESS_KEY,
|
|
198
|
+
secretAccessKey: env.S3_SECRET_KEY,
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Cloudflare R2 (Production)
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { r2 } from "files-sdk/r2";
|
|
209
|
+
|
|
210
|
+
export default runtimeConfig({
|
|
211
|
+
storage: {
|
|
212
|
+
basePath: "/api",
|
|
213
|
+
adapter: r2({
|
|
214
|
+
bucket: env.R2_BUCKET,
|
|
215
|
+
accountId: env.R2_ACCOUNT_ID,
|
|
216
|
+
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
217
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
150
218
|
}),
|
|
151
219
|
},
|
|
152
220
|
});
|
|
@@ -167,17 +235,22 @@ avatar: f.upload({
|
|
|
167
235
|
Background job processing with [pg-boss](https://github.com/timgit/pg-boss). Jobs stored in PostgreSQL.
|
|
168
236
|
|
|
169
237
|
```ts
|
|
170
|
-
import {
|
|
238
|
+
import { runtimeConfig } from "questpie/app";
|
|
239
|
+
import { pgBossAdapter } from "questpie/adapters/pg-boss";
|
|
171
240
|
|
|
172
241
|
export default runtimeConfig({
|
|
173
242
|
queue: {
|
|
174
243
|
adapter: pgBossAdapter({
|
|
175
|
-
connectionString:
|
|
244
|
+
connectionString: env.DATABASE_URL,
|
|
176
245
|
}),
|
|
177
246
|
},
|
|
178
247
|
});
|
|
179
248
|
```
|
|
180
249
|
|
|
250
|
+
> **Warning:** pg-boss uses `LISTEN/NOTIFY` internally. Do not point `connectionString` at a PgBouncer in transaction pool mode — jobs will queue but never wake the worker, so they fire only on the polling fallback (slow, sometimes never). See "PgBouncer Compatibility" for the full adapter matrix and routing options. Use a direct PG connection or `cloudflareQueuesAdapter`.
|
|
251
|
+
|
|
252
|
+
On Cloudflare Workers, queue processing is push-based. Configure `cloudflareQueuesAdapter` from `questpie/adapters/cloudflare` and export the Worker through `createCloudflareWorkerHandlers`; do not run `app.queue.listen()` in a Worker.
|
|
253
|
+
|
|
181
254
|
### Publishing Jobs
|
|
182
255
|
|
|
183
256
|
From hooks, functions, or other jobs:
|
|
@@ -197,15 +270,18 @@ The `queue` object is fully typed -- autocompletion shows all registered jobs an
|
|
|
197
270
|
|
|
198
271
|
SSE-based live updates via `POST /realtime` multiplexed endpoint.
|
|
199
272
|
|
|
273
|
+
> **Warning:** `pgNotifyAdapter` requires a direct PG connection (or PgBouncer in `session` mode). Behind PgBouncer transaction pooling, `LISTEN` is silently dropped and clients fall back to polling — events never fan out. See "PgBouncer Compatibility" below.
|
|
274
|
+
|
|
200
275
|
### pgNotify (Single Instance)
|
|
201
276
|
|
|
202
277
|
```ts
|
|
203
|
-
import {
|
|
278
|
+
import { runtimeConfig } from "questpie/app";
|
|
279
|
+
import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
|
|
204
280
|
|
|
205
281
|
export default runtimeConfig({
|
|
206
282
|
realtime: {
|
|
207
283
|
adapter: pgNotifyAdapter({
|
|
208
|
-
connectionString:
|
|
284
|
+
connectionString: env.DATABASE_URL,
|
|
209
285
|
}),
|
|
210
286
|
},
|
|
211
287
|
});
|
|
@@ -216,31 +292,80 @@ export default runtimeConfig({
|
|
|
216
292
|
Required for horizontal scaling:
|
|
217
293
|
|
|
218
294
|
```ts
|
|
219
|
-
import {
|
|
295
|
+
import { runtimeConfig } from "questpie/app";
|
|
296
|
+
import { redisStreamsAdapter } from "questpie/adapters/redis-streams";
|
|
220
297
|
|
|
221
298
|
export default runtimeConfig({
|
|
222
299
|
realtime: {
|
|
223
300
|
adapter: redisStreamsAdapter({
|
|
224
|
-
url:
|
|
301
|
+
url: env.REDIS_URL,
|
|
225
302
|
}),
|
|
226
303
|
},
|
|
227
304
|
});
|
|
228
305
|
```
|
|
229
306
|
|
|
307
|
+
> **Warning:** `pgNotifyAdapter` and `pgBossAdapter` both rely on PostgreSQL `LISTEN/NOTIFY`. They will silently fail behind PgBouncer in transaction pool mode. See "PgBouncer Compatibility" below.
|
|
308
|
+
|
|
309
|
+
### SSE Keepalive & Timeouts
|
|
310
|
+
|
|
311
|
+
The `POST /realtime` SSE stream sends a `ping` every **8s** by default (`realtime.keepAliveIntervalMs`). Every layer between browser and server must tolerate at least that idle window, or subscriptions die and reconnect in a loop:
|
|
312
|
+
|
|
313
|
+
| Layer | Setting | Recommendation |
|
|
314
|
+
| -------------------------- | ---------------------------------- | ------------------------------------------------------------------------- |
|
|
315
|
+
| Bun (`Bun.serve`) | `idleTimeout` (default 10s) | Default ping survives it; set `idleTimeout: 30` for headroom |
|
|
316
|
+
| nginx | `proxy_read_timeout` (default 60s) | Keep >= 60s; disable SSE response buffering (`proxy_buffering off`) |
|
|
317
|
+
| Load balancers (ALB, etc.) | idle timeout (often 60s) | Keep above `keepAliveIntervalMs` |
|
|
318
|
+
| Serverless platforms | response buffering / max duration | SSE needs streaming responses; buffered platforms break realtime entirely |
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
// Bun server entry — the app owns Bun.serve options, not the framework
|
|
322
|
+
export default {
|
|
323
|
+
port: 3000,
|
|
324
|
+
idleTimeout: 30, // seconds
|
|
325
|
+
fetch: server.fetch,
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## PgBouncer Compatibility
|
|
330
|
+
|
|
331
|
+
PgBouncer in `transaction` pool mode reassigns sessions per-transaction, so persistent listeners are impossible. Once `LISTEN` returns, the connection is handed to a different client and notifications are dropped. This breaks any feature that depends on session-bound state.
|
|
332
|
+
|
|
333
|
+
Bun SQL (`new SQL({ url })`) already pools connections internally. In single-instance and small-replica deployments, PgBouncer adds nothing on top of it. PgBouncer only earns its keep when you have 20+ replicas, run on serverless with cold-start churn, or share infra with non-Bun consumers.
|
|
334
|
+
|
|
335
|
+
### Adapter Compatibility Matrix
|
|
336
|
+
|
|
337
|
+
| Adapter | Direct PG | PgBouncer (transaction) | PgBouncer (session) |
|
|
338
|
+
| -------------------------------- | --------- | --------------------------------- | --------------------------- |
|
|
339
|
+
| `pgNotifyAdapter` (realtime) | works | broken — listens silently dropped | works (pooling neutralized) |
|
|
340
|
+
| `pgBossAdapter` (queue) | works | broken — LISTEN/NOTIFY required | works (pooling neutralized) |
|
|
341
|
+
| Drizzle queries via Bun SQL | works | works | works |
|
|
342
|
+
| `redisStreamsAdapter` (realtime) | n/a | n/a | n/a |
|
|
343
|
+
|
|
344
|
+
Prepared statements also break under transaction pooling. If you must use it, ensure your driver disables prepared statements end-to-end.
|
|
345
|
+
|
|
346
|
+
### DB Connection Routing
|
|
347
|
+
|
|
348
|
+
- **Default and recommended:** direct connection to the PG primary. Bun SQL pools internally; you do not need PgBouncer.
|
|
349
|
+
- **If you must use PgBouncer:** put it in `session` pool mode for any process that runs `pgBossAdapter` or `pgNotifyAdapter`. Session mode pins one server connection per client, which neutralizes pooling but keeps `LISTEN/NOTIFY` working.
|
|
350
|
+
- **Split topology:** route web traffic through PgBouncer (transaction mode) and run workers (pgBoss, pgNotify) on a direct connection. This works, but realtime fired from web handlers still routes through the same `QUESTPIE_DB`, so realtime in web fails. In practice, going direct everywhere is simpler.
|
|
351
|
+
- **TODO / current limitation:** the framework reads a single `QUESTPIE_DB` env var. There is no built-in split between a pooled URL and a direct URL for LISTEN consumers. Track this if you need a mixed topology.
|
|
352
|
+
|
|
230
353
|
## Email
|
|
231
354
|
|
|
232
355
|
Transactional email with typed templates. Two adapters: `SmtpAdapter` (production) and `ConsoleAdapter` (development).
|
|
233
356
|
|
|
234
357
|
```ts
|
|
235
|
-
import {
|
|
358
|
+
import { runtimeConfig } from "questpie/app";
|
|
359
|
+
import { ConsoleAdapter } from "questpie/adapters/console";
|
|
360
|
+
import { SmtpAdapter } from "questpie/adapters/smtp";
|
|
236
361
|
|
|
237
362
|
export default runtimeConfig({
|
|
238
363
|
email: {
|
|
239
364
|
adapter:
|
|
240
|
-
|
|
365
|
+
env.NODE_ENV === "development"
|
|
241
366
|
? new ConsoleAdapter({ logHtml: false })
|
|
242
367
|
: new SmtpAdapter({
|
|
243
|
-
transport: { host:
|
|
368
|
+
transport: { host: env.SMTP_HOST, port: 587, secure: true },
|
|
244
369
|
}),
|
|
245
370
|
},
|
|
246
371
|
});
|
|
@@ -268,12 +393,21 @@ const results = await client.search.search({
|
|
|
268
393
|
|
|
269
394
|
## KV Store
|
|
270
395
|
|
|
271
|
-
###
|
|
396
|
+
### Redis
|
|
272
397
|
|
|
273
398
|
```ts
|
|
399
|
+
import { createClient } from "redis";
|
|
400
|
+
import { redisKVAdapter } from "questpie/adapters/redis-kv";
|
|
401
|
+
|
|
402
|
+
async function getRedis() {
|
|
403
|
+
const redis = createClient({ url: env.REDIS_URL });
|
|
404
|
+
await redis.connect();
|
|
405
|
+
return redis;
|
|
406
|
+
}
|
|
407
|
+
|
|
274
408
|
export default runtimeConfig({
|
|
275
409
|
kv: {
|
|
276
|
-
adapter:
|
|
410
|
+
adapter: redisKVAdapter({ client: getRedis, keyPrefix: "my-app:" }),
|
|
277
411
|
defaultTtl: 3600,
|
|
278
412
|
},
|
|
279
413
|
});
|
|
@@ -291,7 +425,7 @@ kv: {
|
|
|
291
425
|
|
|
292
426
|
```ts
|
|
293
427
|
handler: async ({ kv }) => {
|
|
294
|
-
await kv.set("key", "value",
|
|
428
|
+
await kv.set("key", "value", 3600);
|
|
295
429
|
const value = await kv.get("key");
|
|
296
430
|
await kv.delete("key");
|
|
297
431
|
};
|
|
@@ -373,17 +507,17 @@ CMD ["bun", "run", ".output/server/index.mjs"]
|
|
|
373
507
|
|
|
374
508
|
```ts
|
|
375
509
|
// routes/health.ts
|
|
376
|
-
import {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
510
|
+
import { sql } from "questpie/drizzle";
|
|
511
|
+
import { route } from "questpie/services";
|
|
512
|
+
|
|
513
|
+
export default route()
|
|
514
|
+
.get()
|
|
515
|
+
.raw()
|
|
516
|
+
.access(true)
|
|
517
|
+
.handler(async ({ db }) => {
|
|
381
518
|
await db.execute(sql`SELECT 1`);
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
});
|
|
385
|
-
},
|
|
386
|
-
});
|
|
519
|
+
return Response.json({ status: "ok" });
|
|
520
|
+
});
|
|
387
521
|
```
|
|
388
522
|
|
|
389
523
|
## Common Mistakes
|
|
@@ -396,8 +530,8 @@ Without a strong secret, sessions can be forged. The default `"change-me"` is fo
|
|
|
396
530
|
// WRONG -- in production
|
|
397
531
|
secret: "change-me";
|
|
398
532
|
|
|
399
|
-
// CORRECT --
|
|
400
|
-
secret:
|
|
533
|
+
// CORRECT -- declared in env.ts as z.string().min(32), validated at boot
|
|
534
|
+
secret: env.BETTER_AUTH_SECRET;
|
|
401
535
|
```
|
|
402
536
|
|
|
403
537
|
### HIGH: Not running migrations after schema changes
|
|
@@ -422,15 +556,17 @@ The local storage adapter writes to the filesystem. In containerized deployments
|
|
|
422
556
|
storage: { basePath: "/api" }
|
|
423
557
|
|
|
424
558
|
// CORRECT -- persistent S3 storage
|
|
425
|
-
import {
|
|
559
|
+
import { s3 } from "files-sdk/s3";
|
|
426
560
|
|
|
427
561
|
storage: {
|
|
428
562
|
basePath: "/api",
|
|
429
|
-
|
|
430
|
-
bucket:
|
|
431
|
-
region:
|
|
432
|
-
|
|
433
|
-
|
|
563
|
+
adapter: s3({
|
|
564
|
+
bucket: env.S3_BUCKET,
|
|
565
|
+
region: env.S3_REGION,
|
|
566
|
+
credentials: {
|
|
567
|
+
accessKeyId: env.S3_ACCESS_KEY,
|
|
568
|
+
secretAccessKey: env.S3_SECRET_KEY,
|
|
569
|
+
},
|
|
434
570
|
}),
|
|
435
571
|
}
|
|
436
572
|
```
|
|
@@ -443,11 +579,40 @@ Without pg-boss configured, job `.publish()` calls silently do nothing. Jobs def
|
|
|
443
579
|
// REQUIRED for jobs to actually execute
|
|
444
580
|
queue: {
|
|
445
581
|
adapter: pgBossAdapter({
|
|
446
|
-
connectionString:
|
|
582
|
+
connectionString: env.DATABASE_URL,
|
|
447
583
|
}),
|
|
448
584
|
}
|
|
449
585
|
```
|
|
450
586
|
|
|
587
|
+
### HIGH: PgBouncer transaction pool with pgNotify/pgBoss
|
|
588
|
+
|
|
589
|
+
Pointing `QUESTPIE_DB` at a PgBouncer in transaction pool mode silently breaks `pgNotifyAdapter` and `pgBossAdapter`. Both rely on PostgreSQL `LISTEN/NOTIFY`, which requires a persistent session. PgBouncer transaction pooling reassigns the session per-transaction, so the listener is dropped right after `LISTEN` returns.
|
|
590
|
+
|
|
591
|
+
Symptoms:
|
|
592
|
+
|
|
593
|
+
- Realtime: SSE clients connect but never receive events; UI silently falls back to polling, or never refreshes
|
|
594
|
+
- Queue: jobs sit in the table; workers process them only on the polling tick (delayed by seconds to minutes), or never wake at all
|
|
595
|
+
|
|
596
|
+
Fix:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
// WRONG -- QUESTPIE_DB points at PgBouncer (transaction mode)
|
|
600
|
+
realtime: {
|
|
601
|
+
adapter: pgNotifyAdapter({ connectionString: env.QUESTPIE_DB });
|
|
602
|
+
}
|
|
603
|
+
queue: {
|
|
604
|
+
adapter: pgBossAdapter({ connectionString: env.QUESTPIE_DB });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// CORRECT -- direct PG connection (Bun SQL pools internally)
|
|
608
|
+
// Or switch to redisStreamsAdapter for realtime in multi-instance deployments
|
|
609
|
+
realtime: {
|
|
610
|
+
adapter: redisStreamsAdapter({ url: env.REDIS_URL });
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
If your infra mandates PgBouncer, use `session` pool mode for processes that run pgBoss or pgNotify. See "PgBouncer Compatibility" for the full matrix.
|
|
615
|
+
|
|
451
616
|
## Realtime and Live Preview
|
|
452
617
|
|
|
453
618
|
The realtime adapter (`pgNotifyAdapter` or `redisStreamsAdapter`) is relevant for **detached or shared preview sessions** — when the preview runs in a separate browser tab, or multiple collaborators view the same preview.
|
|
@@ -101,8 +101,7 @@ Only hyphens are camelized in factory args; underscores are preserved (`global("
|
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
103
|
// src/questpie/server/questpie.config.ts
|
|
104
|
-
import { runtimeConfig } from "questpie";
|
|
105
|
-
|
|
104
|
+
import { runtimeConfig } from "questpie/app";
|
|
106
105
|
export default runtimeConfig({
|
|
107
106
|
app: {
|
|
108
107
|
url: process.env.APP_URL || "http://localhost:3000",
|
|
@@ -162,7 +161,7 @@ Core: `text`, `number`, `boolean`, `date`, `datetime`, `time`, `select`, `relati
|
|
|
162
161
|
|
|
163
162
|
```ts
|
|
164
163
|
// src/questpie/server/routes/get-overdue-tasks.ts
|
|
165
|
-
import { route } from "questpie";
|
|
164
|
+
import { route } from "questpie/services";
|
|
166
165
|
import z from "zod";
|
|
167
166
|
|
|
168
167
|
export default route()
|
|
@@ -239,7 +238,7 @@ bunx questpie migrate:fresh
|
|
|
239
238
|
```ts
|
|
240
239
|
// src/routes/api/$.ts
|
|
241
240
|
import { createAPIFileRoute } from "@tanstack/react-start/api";
|
|
242
|
-
import { createFetchHandler } from "questpie";
|
|
241
|
+
import { createFetchHandler } from "questpie/http";
|
|
243
242
|
import { app } from "#questpie";
|
|
244
243
|
|
|
245
244
|
const handler = createFetchHandler(app, { basePath: "/api" });
|
|
@@ -285,7 +284,7 @@ bun add @questpie/admin
|
|
|
285
284
|
|
|
286
285
|
```ts
|
|
287
286
|
// src/questpie/server/modules.ts
|
|
288
|
-
import { adminModule } from "@questpie/admin/
|
|
287
|
+
import { adminModule } from "@questpie/admin/modules/admin";
|
|
289
288
|
|
|
290
289
|
export default [adminModule] as const;
|
|
291
290
|
```
|
|
@@ -443,8 +442,7 @@ export { default } from "./src/questpie/server/questpie.config";
|
|
|
443
442
|
|
|
444
443
|
```ts
|
|
445
444
|
// src/questpie/server/questpie.config.ts
|
|
446
|
-
import { runtimeConfig } from "questpie";
|
|
447
|
-
|
|
445
|
+
import { runtimeConfig } from "questpie/app";
|
|
448
446
|
export default runtimeConfig({
|
|
449
447
|
app: { url: process.env.APP_URL || "http://localhost:3000" },
|
|
450
448
|
db: { url: process.env.DATABASE_URL! },
|
|
@@ -469,7 +467,7 @@ export default collection("posts").fields(({ f }) => ({
|
|
|
469
467
|
```ts
|
|
470
468
|
// src/routes/api/$.ts
|
|
471
469
|
import { createAPIFileRoute } from "@tanstack/react-start/api";
|
|
472
|
-
import { createFetchHandler } from "questpie";
|
|
470
|
+
import { createFetchHandler } from "questpie/http";
|
|
473
471
|
import { app } from "#questpie";
|
|
474
472
|
|
|
475
473
|
const handler = createFetchHandler(app, { basePath: "/api" });
|