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
|
@@ -57,7 +57,7 @@ QuestPie is a **headless CMS framework** for TypeScript. You define your content
|
|
|
57
57
|
| Runtime | **Bun** |
|
|
58
58
|
| Database | **PostgreSQL** via **Drizzle ORM** |
|
|
59
59
|
| Auth | **Better Auth** |
|
|
60
|
-
| Storage | **
|
|
60
|
+
| Storage | **Files SDK** (local FS, S3, R2, GCS) |
|
|
61
61
|
| Admin UI | **React** + **TanStack Router** + **Tailwind** |
|
|
62
62
|
| HTTP | Custom trie-based router (no Express/Hono needed) |
|
|
63
63
|
| Build | **tsdown** (Rolldown-based) + **Turbo** monorepo |
|
|
@@ -72,6 +72,7 @@ packages/
|
|
|
72
72
|
hono/ ← Hono HTTP adapter
|
|
73
73
|
next/ ← Next.js adapter
|
|
74
74
|
openapi/ ← OpenAPI/Scalar plugin
|
|
75
|
+
mcp/ ← Model Context Protocol integration
|
|
75
76
|
tanstack-query/ ← TanStack Query integration
|
|
76
77
|
create-questpie/ ← Project scaffolder (bunx create-questpie)
|
|
77
78
|
```
|
|
@@ -89,11 +90,13 @@ A QuestPie project follows a **convention-over-configuration** file layout. The
|
|
|
89
90
|
questpie/
|
|
90
91
|
server/ ← Server root (all data + behavior)
|
|
91
92
|
questpie.config.ts ← runtimeConfig({ db, app, storage, ... })
|
|
93
|
+
env.ts ← env({ server, client?, refine? }) — boot-validated
|
|
94
|
+
env.client.ts ← clientEnv({ consumers, vars }) — client-safe vars
|
|
92
95
|
modules.ts ← export default [adminModule, ...] as const
|
|
93
96
|
app.ts ← re-export of .generated/index (stable import)
|
|
94
97
|
config/
|
|
95
|
-
auth.ts ← authConfig({...}) (from "questpie")
|
|
96
|
-
app.ts ← appConfig({...}) (from "questpie", optional)
|
|
98
|
+
auth.ts ← authConfig({...}) (from "questpie/app")
|
|
99
|
+
app.ts ← appConfig({...}) (from "questpie/app", optional)
|
|
97
100
|
admin.ts ← adminConfig({...}) (from "#questpie/factories")
|
|
98
101
|
collections/ ← One file per collection
|
|
99
102
|
globals/ ← One file per global
|
|
@@ -115,6 +118,7 @@ A QuestPie project follows a **convention-over-configuration** file layout. The
|
|
|
115
118
|
.generated/ ← DO NOT EDIT (codegen output)
|
|
116
119
|
index.ts
|
|
117
120
|
factories.ts
|
|
121
|
+
env.client.<consumer>.ts ← per-consumer typed client env (when env.client.ts exists)
|
|
118
122
|
admin/ ← Admin client config
|
|
119
123
|
admin.ts
|
|
120
124
|
modules.ts
|
|
@@ -145,6 +149,8 @@ A QuestPie project follows a **convention-over-configuration** file layout. The
|
|
|
145
149
|
| `blocks/` | **named** export | `block("name")` |
|
|
146
150
|
| `migrations/` | **default** export | `migration({...})` |
|
|
147
151
|
| `seeds/` | **default** export | `seed({...})` |
|
|
152
|
+
| `env.ts` | **default** export | `env({...})` |
|
|
153
|
+
| `env.client.ts`| **default** export | `clientEnv({...})` |
|
|
148
154
|
|
|
149
155
|
---
|
|
150
156
|
|
|
@@ -156,24 +162,40 @@ A QuestPie project follows a **convention-over-configuration** file layout. The
|
|
|
156
162
|
questpie.config.ts → modules.ts → codegen → .generated/index.ts → createApp()
|
|
157
163
|
```
|
|
158
164
|
|
|
159
|
-
**Step 1** — `questpie.config.ts` declares infrastructure (DB, storage, email, etc.):
|
|
165
|
+
**Step 1** — `env.ts` declares + validates environment variables (optional but recommended; see `references/env.md`), and `questpie.config.ts` declares infrastructure (DB, storage, email, etc.):
|
|
160
166
|
|
|
161
167
|
```ts
|
|
162
|
-
|
|
168
|
+
// env.ts
|
|
169
|
+
import { env } from "questpie/env";
|
|
170
|
+
import { z } from "zod";
|
|
171
|
+
|
|
172
|
+
export default env({
|
|
173
|
+
server: { DATABASE_URL: z.url() },
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// questpie.config.ts
|
|
179
|
+
import { runtimeConfig } from "questpie/app";
|
|
180
|
+
import { ConsoleAdapter } from "questpie/adapters/console";
|
|
181
|
+
|
|
182
|
+
import env from "./env";
|
|
163
183
|
|
|
164
184
|
export default runtimeConfig({
|
|
165
185
|
app: { url: "http://localhost:3000" },
|
|
166
|
-
db: { url:
|
|
186
|
+
db: { url: env.DATABASE_URL },
|
|
167
187
|
storage: { basePath: "/api" },
|
|
168
188
|
email: { adapter: new ConsoleAdapter() },
|
|
169
189
|
});
|
|
170
190
|
```
|
|
171
191
|
|
|
192
|
+
When `env.ts` exists, the generated app imports it FIRST — a misconfigured environment fails boot before adapters/auth/db init, with every offending var named (values never logged).
|
|
193
|
+
|
|
172
194
|
**Step 2** — `modules.ts` declares which module packages to use:
|
|
173
195
|
|
|
174
196
|
```ts
|
|
175
|
-
import { adminModule } from "@questpie/admin/
|
|
176
|
-
import { openApiModule } from "@questpie/openapi";
|
|
197
|
+
import { adminModule } from "@questpie/admin/modules/admin";
|
|
198
|
+
import { openApiModule } from "@questpie/openapi/modules/openapi";
|
|
177
199
|
|
|
178
200
|
export default [adminModule, openApiModule] as const;
|
|
179
201
|
```
|
|
@@ -200,7 +222,7 @@ export const app = await createApp(
|
|
|
200
222
|
|
|
201
223
|
```ts
|
|
202
224
|
// src/routes/api/$.ts (TanStack Start example)
|
|
203
|
-
import { createFetchHandler } from "questpie";
|
|
225
|
+
import { createFetchHandler } from "questpie/http";
|
|
204
226
|
import { app } from "@/questpie/server/app";
|
|
205
227
|
import { createAPIFileRoute } from "@tanstack/react-start/api";
|
|
206
228
|
|
|
@@ -227,8 +249,7 @@ This single handler serves all collection CRUD, auth, search, realtime, storage,
|
|
|
227
249
|
Modules are the **packaging unit** of the framework. They are plain static objects — no class instances, no runtime instantiation.
|
|
228
250
|
|
|
229
251
|
```ts
|
|
230
|
-
import { module } from "questpie";
|
|
231
|
-
|
|
252
|
+
import { module } from "questpie/app";
|
|
232
253
|
export const billingModule = module({
|
|
233
254
|
name: "billing",
|
|
234
255
|
modules: [stripeModule], // sub-dependencies
|
|
@@ -269,6 +290,8 @@ export const billingModule = module({
|
|
|
269
290
|
| `auditModule` | `@questpie/admin` | Audit log collection + cleanup job |
|
|
270
291
|
| `openApiModule` | `@questpie/openapi` | OpenAPI schema + Scalar docs UI |
|
|
271
292
|
|
|
293
|
+
The starter/admin auth contract includes the canonical Better Auth `user` collection with `user.role` (`admin` or `user`). Built-in admin setup checks for `role = "admin"`, and the admin UI guard expects `session.user.role === "admin"`. Apps using `adminModule` must not replace `collection("user")` from scratch; merge `starterModule.collections.user` and extend it when custom user fields or admin layout are needed.
|
|
294
|
+
|
|
272
295
|
### Module Resolution
|
|
273
296
|
|
|
274
297
|
Modules are resolved **depth-first**: sub-modules are processed before their parent. If two modules share the same `name`, the **last** occurrence wins (deduplication by name). This allows overriding.
|
|
@@ -296,7 +319,7 @@ export const posts = collection("posts")
|
|
|
296
319
|
slug: f.text(255).required(),
|
|
297
320
|
content: f.richText().localized(),
|
|
298
321
|
status: f.select(["internal", "featured"]).default("internal"),
|
|
299
|
-
author: f.relation("
|
|
322
|
+
author: f.relation("user"),
|
|
300
323
|
tags: f.relation("tags").manyToMany({ through: "post_tags" }),
|
|
301
324
|
cover: f.upload(),
|
|
302
325
|
}))
|
|
@@ -401,7 +424,7 @@ export const posts = collection("posts")
|
|
|
401
424
|
| `.validation({...})` | Zod validation overrides |
|
|
402
425
|
| `.upload({...})` | Turn into upload collection (see [Uploads](#16-uploads--storage)) |
|
|
403
426
|
| `.set(key, value)` | Plugin extension point |
|
|
404
|
-
| `.merge(other)` |
|
|
427
|
+
| `.merge(other)` | Extend another builder of the same name (see below) |
|
|
405
428
|
| `.admin({...})` | Admin panel metadata (label, icon, group) |
|
|
406
429
|
| `.list({...})` | List view config |
|
|
407
430
|
| `.form({...})` | Form view config |
|
|
@@ -410,6 +433,24 @@ export const posts = collection("posts")
|
|
|
410
433
|
|
|
411
434
|
> `.admin()`, `.list()`, `.form()`, `.preview()`, `.actions()` are added by the admin plugin — they're not available without `@questpie/admin`.
|
|
412
435
|
|
|
436
|
+
### Extending Collections — `.merge()`
|
|
437
|
+
|
|
438
|
+
Collections compose. To extend a collection a module already provides (starter `user`, `assets`, ...), **merge the module's builder — never redefine the collection from scratch** (registering the same key replaces the module's collection wholesale, dropping its fields, hooks, and auth wiring):
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
// collections/user.ts — extend the starter user
|
|
442
|
+
import { starterModule } from "questpie/app";
|
|
443
|
+
import { collection } from "#questpie/factories";
|
|
444
|
+
|
|
445
|
+
export default collection("user")
|
|
446
|
+
.merge(starterModule.collections.user)
|
|
447
|
+
.fields(({ f }) => ({
|
|
448
|
+
internalNotes: f.textarea(),
|
|
449
|
+
}));
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Semantics: fields/virtuals/relations/indexes/options/extension keys combine by key (merged-in builder wins on conflict); hooks concatenate (both run); `title`/`searchable`/`upload` take the merged-in value when set. `.fields()` after `.merge()` is cumulative — adds and overrides by key, never wipes. Everything stays fully typed (`$infer`, CRUD types include both sides).
|
|
453
|
+
|
|
413
454
|
---
|
|
414
455
|
|
|
415
456
|
## 6. Globals
|
|
@@ -515,21 +556,44 @@ f.text(255)
|
|
|
515
556
|
update: ({ session }) => session?.user.role === "admin",
|
|
516
557
|
})
|
|
517
558
|
.operators(ops) // override WHERE operator set for queries
|
|
518
|
-
.drizzle((col) => col) //
|
|
519
|
-
.zod((schema) => schema) //
|
|
559
|
+
.drizzle((col) => col.unique()) // raw Drizzle column builder — constraints/defaults land in DDL; $type<T>() narrows value type
|
|
560
|
+
.zod((schema) => schema.refine(check)) // extend/replace Zod schema (output narrows value type)
|
|
561
|
+
.$type<Layout>() // explicitly set TS value type (type-level only; mainly for json)
|
|
520
562
|
.fromDb((value) => value) // transform after reading from DB
|
|
521
563
|
.toDb((value) => value) // transform before writing to DB
|
|
522
564
|
.set(key, value) // plugin extension point (e.g. admin, form config)
|
|
523
565
|
.derive(extra); // derive additional runtime state
|
|
524
566
|
```
|
|
525
567
|
|
|
568
|
+
### Extending Fields — typed escape hatches
|
|
569
|
+
|
|
570
|
+
Built-in fields don't box you in. All three hatches propagate types into CRUD select/insert types:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
// extend Zod validation (type unchanged)
|
|
574
|
+
slug: f.text().zod((s) => s.refine(isKebabCase, "must be kebab-case")),
|
|
575
|
+
|
|
576
|
+
// replace the Zod schema — value type narrows to the schema output
|
|
577
|
+
settings: f.json().zod(() => z.object({ theme: z.enum(["light", "dark"]) })),
|
|
578
|
+
|
|
579
|
+
// extend the underlying Drizzle column — raw builder access, lands in DDL/migrations
|
|
580
|
+
slug: f.text(255).drizzle((col) => col.unique()),
|
|
581
|
+
viewCount: f.number().drizzle((col) => col.default(sql`0`)),
|
|
582
|
+
total: f.number().drizzle((col) => col.$type<Cents>()), // narrows value type
|
|
583
|
+
|
|
584
|
+
// explicitly type a json field (no runtime validation; pair with .zod() if needed)
|
|
585
|
+
layout: f.json().$type<{ rows: { id: string; span: number }[] }>(),
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Field schemas are enforced server-side: create/update validates each input key against its field's schema (incl. `.zod()` transforms, email format, select enums). System fields and relation/upload FKs validate against their column shape.
|
|
589
|
+
|
|
590
|
+
|
|
526
591
|
### Custom Field Types
|
|
527
592
|
|
|
528
593
|
Define reusable field types with `fieldType()`:
|
|
529
594
|
|
|
530
595
|
```ts
|
|
531
|
-
import { fieldType } from "questpie";
|
|
532
|
-
|
|
596
|
+
import { fieldType } from "questpie/builders";
|
|
533
597
|
export const colorField = fieldType("color", {
|
|
534
598
|
create: () => ({
|
|
535
599
|
type: "color",
|
|
@@ -554,8 +618,8 @@ All relationships are expressed via `f.relation(target)` with chain methods:
|
|
|
554
618
|
|
|
555
619
|
```ts
|
|
556
620
|
// belongsTo (default) — FK on this table
|
|
557
|
-
f.relation("
|
|
558
|
-
// Column: authorId varchar(36) → FK to
|
|
621
|
+
f.relation("user");
|
|
622
|
+
// Column: authorId varchar(36) → FK to user.id
|
|
559
623
|
|
|
560
624
|
// hasMany — virtual, FK lives on the target table
|
|
561
625
|
f.relation("comments").hasMany({
|
|
@@ -582,7 +646,7 @@ f.relation({ users: "users", teams: "teams" });
|
|
|
582
646
|
### Relation Targets
|
|
583
647
|
|
|
584
648
|
```ts
|
|
585
|
-
f.relation("
|
|
649
|
+
f.relation("user"); // string (collection name)
|
|
586
650
|
f.relation(() => users); // lazy reference (avoids circular imports)
|
|
587
651
|
f.relation({ users: "users", teams: "teams" }); // polymorphic map
|
|
588
652
|
```
|
|
@@ -590,7 +654,7 @@ f.relation({ users: "users", teams: "teams" }); // polymorphic map
|
|
|
590
654
|
### Additional Config
|
|
591
655
|
|
|
592
656
|
```ts
|
|
593
|
-
f.relation("
|
|
657
|
+
f.relation("user")
|
|
594
658
|
.onDelete("cascade" | "set null" | "restrict" | "no action")
|
|
595
659
|
.onUpdate("cascade" | ...)
|
|
596
660
|
.relationName("postAuthor") // disambiguate multiple relations to same target
|
|
@@ -794,6 +858,26 @@ await collections.posts.find({}, { accessMode: "user", session });
|
|
|
794
858
|
await collections.posts.find({}, { accessMode: "system" });
|
|
795
859
|
```
|
|
796
860
|
|
|
861
|
+
### Derived Request Context
|
|
862
|
+
|
|
863
|
+
`appConfig({ context })` derives per-request context (tenant, role, memberships) **once per HTTP request**; the result travels with the request and arrives **flat** on access rules, hooks, route handlers, field access, and `getContext()`:
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
// config/app.ts
|
|
867
|
+
appConfig({
|
|
868
|
+
context: async ({ request, session, collections }) => ({
|
|
869
|
+
workspaceId: request.headers.get("x-workspace") || null,
|
|
870
|
+
}),
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// Any access rule — typed by inference, narrow before use (absent in jobs/seeds)
|
|
874
|
+
access: {
|
|
875
|
+
read: ({ workspaceId }) => (workspaceId ? { workspace: workspaceId } : false),
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
The resolver receives the typed system-mode service surface (`collections`, `globals`, `logger`, `kv`, `queue`, `t`, `services`). Throwing from it fails the request before any rule runs. See `references/multi-tenancy.md`.
|
|
880
|
+
|
|
797
881
|
---
|
|
798
882
|
|
|
799
883
|
## 11. Routes
|
|
@@ -801,8 +885,7 @@ await collections.posts.find({}, { accessMode: "system" });
|
|
|
801
885
|
Custom HTTP routes for APIs that don't fit the collection CRUD pattern.
|
|
802
886
|
|
|
803
887
|
```ts
|
|
804
|
-
import { route } from "questpie";
|
|
805
|
-
|
|
888
|
+
import { route } from "questpie/services";
|
|
806
889
|
// JSON route with schema validation
|
|
807
890
|
export default route()
|
|
808
891
|
.post()
|
|
@@ -912,9 +995,10 @@ export default route()
|
|
|
912
995
|
| `search` | POST | Full-text search |
|
|
913
996
|
| `search/reindex/[collection]` | POST | Reindex collection |
|
|
914
997
|
| `realtime` | POST | SSE subscriptions |
|
|
915
|
-
| `storage/files/[...key]` | GET | Legacy file serving |
|
|
916
998
|
| `health` | GET | Health check |
|
|
917
999
|
|
|
1000
|
+
File serving stays collection-scoped through QUESTPIE routes such as `/api/:collection/files/:key` or `/api/assets/files/:key`, so file reads remain behind collection access rules instead of a global storage namespace.
|
|
1001
|
+
|
|
918
1002
|
---
|
|
919
1003
|
|
|
920
1004
|
## 12. Services
|
|
@@ -922,8 +1006,7 @@ export default route()
|
|
|
922
1006
|
Services are **injectable singletons or request-scoped factories** available in `AppContext`.
|
|
923
1007
|
|
|
924
1008
|
```ts
|
|
925
|
-
import { service } from "questpie";
|
|
926
|
-
|
|
1009
|
+
import { service } from "questpie/services";
|
|
927
1010
|
export const analyticsService = service()
|
|
928
1011
|
.lifecycle("singleton") // created once at startup
|
|
929
1012
|
.create(({ app }) => {
|
|
@@ -972,8 +1055,7 @@ async ({ db, session, services }) => {
|
|
|
972
1055
|
Background jobs for async processing — retries, scheduling, and queuing.
|
|
973
1056
|
|
|
974
1057
|
```ts
|
|
975
|
-
import { job } from "questpie";
|
|
976
|
-
|
|
1058
|
+
import { job } from "questpie/services";
|
|
977
1059
|
export default job({
|
|
978
1060
|
name: "sendWelcomeEmail",
|
|
979
1061
|
schema: z.object({
|
|
@@ -1008,12 +1090,32 @@ await ctx.queue.sendWelcomeEmail.publish({
|
|
|
1008
1090
|
|
|
1009
1091
|
The queue client exposes jobs as typed properties: `queue[jobName].publish(payload, options?)`. This gives you full type safety on the payload.
|
|
1010
1092
|
|
|
1093
|
+
### Recurring Jobs (Cron)
|
|
1094
|
+
|
|
1095
|
+
Jobs accept a job-level `options.cron`; schedules are registered automatically when the queue worker starts (`app.queue.listen()`):
|
|
1096
|
+
|
|
1097
|
+
```ts
|
|
1098
|
+
export default job({
|
|
1099
|
+
name: "cleanupExpired",
|
|
1100
|
+
schema: z.object({}),
|
|
1101
|
+
options: { cron: "0 3 * * *" },
|
|
1102
|
+
handler: async ({ collections }) => {
|
|
1103
|
+
await collections.sessions.deleteMany({
|
|
1104
|
+
where: { expiresAt: { lt: new Date() } },
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
Programmatic control: `queue.jobName.schedule(payload, cron)` / `queue.jobName.unschedule()`. Use job cron for simple recurring tasks; reserve **workflow-level cron** (`@questpie/workflows`) for recurring processes that need steps, waits, or replay.
|
|
1111
|
+
|
|
1011
1112
|
### Queue Adapters
|
|
1012
1113
|
|
|
1013
|
-
| Adapter
|
|
1014
|
-
|
|
|
1015
|
-
| `pgBossAdapter()`
|
|
1016
|
-
| `
|
|
1114
|
+
| Adapter | Use Case |
|
|
1115
|
+
| --------------------------- | ------------------------------------------------ |
|
|
1116
|
+
| `pgBossAdapter()` | PostgreSQL-based (default, great for most cases) |
|
|
1117
|
+
| `bullMQAdapter()` | Redis-based (BullMQ) |
|
|
1118
|
+
| `cloudflareQueuesAdapter()` | Cloudflare Workers Queues push consumers |
|
|
1017
1119
|
|
|
1018
1120
|
---
|
|
1019
1121
|
|
|
@@ -1022,8 +1124,7 @@ The queue client exposes jobs as typed properties: `queue[jobName].publish(paylo
|
|
|
1022
1124
|
Email templates define how emails look and what data they accept.
|
|
1023
1125
|
|
|
1024
1126
|
```tsx
|
|
1025
|
-
import { email } from "questpie";
|
|
1026
|
-
|
|
1127
|
+
import { email } from "questpie/services";
|
|
1027
1128
|
export default email({
|
|
1028
1129
|
name: "welcome",
|
|
1029
1130
|
schema: z.object({
|
|
@@ -1053,11 +1154,13 @@ await ctx.email.send("welcome", {
|
|
|
1053
1154
|
|
|
1054
1155
|
### Email Adapters
|
|
1055
1156
|
|
|
1056
|
-
| Adapter | Description |
|
|
1057
|
-
| ----------------------------- | -------------------------------- |
|
|
1058
|
-
| `ConsoleAdapter` | Logs to console (dev) |
|
|
1059
|
-
| `SmtpAdapter` | SMTP via Nodemailer |
|
|
1060
|
-
| `
|
|
1157
|
+
| Adapter | Import | Description |
|
|
1158
|
+
| ----------------------------- | ---------------------------- | -------------------------------- |
|
|
1159
|
+
| `ConsoleAdapter` | `questpie/adapters/console` | Logs to console (dev) |
|
|
1160
|
+
| `SmtpAdapter` | `questpie/adapters/smtp` | SMTP via Nodemailer |
|
|
1161
|
+
| `resendAdapter()` | `questpie/adapters/resend` | Resend HTTP API (and compatible) |
|
|
1162
|
+
| `plunkAdapter()` | `questpie/adapters/plunk` | Plunk transactional HTTP API |
|
|
1163
|
+
| `createEtherealSmtpAdapter()` | `questpie/adapters/smtp` | Auto-generated test SMTP account |
|
|
1061
1164
|
|
|
1062
1165
|
---
|
|
1063
1166
|
|
|
@@ -1066,8 +1169,7 @@ await ctx.email.send("welcome", {
|
|
|
1066
1169
|
### Migrations
|
|
1067
1170
|
|
|
1068
1171
|
```ts
|
|
1069
|
-
import { migration } from "questpie";
|
|
1070
|
-
|
|
1172
|
+
import { migration } from "questpie/migration";
|
|
1071
1173
|
export default migration({
|
|
1072
1174
|
id: "0001_create_categories_table",
|
|
1073
1175
|
up: async ({ db }) => {
|
|
@@ -1087,8 +1189,7 @@ export default migration({
|
|
|
1087
1189
|
### Seeds
|
|
1088
1190
|
|
|
1089
1191
|
```ts
|
|
1090
|
-
import { seed } from "questpie";
|
|
1091
|
-
|
|
1192
|
+
import { seed } from "questpie/services";
|
|
1092
1193
|
export default seed({
|
|
1093
1194
|
id: "seed-default-categories",
|
|
1094
1195
|
category: "required", // "required" | "dev" | "test"
|
|
@@ -1177,14 +1278,41 @@ f.upload({
|
|
|
1177
1278
|
### Storage Adapters
|
|
1178
1279
|
|
|
1179
1280
|
```ts
|
|
1281
|
+
import { fs } from "files-sdk/fs";
|
|
1282
|
+
import { r2 } from "files-sdk/r2";
|
|
1283
|
+
import { s3 } from "files-sdk/s3";
|
|
1284
|
+
|
|
1180
1285
|
// Local filesystem (default)
|
|
1181
1286
|
runtimeConfig({
|
|
1182
|
-
storage: {
|
|
1287
|
+
storage: { adapter: fs({ root: "./uploads" }), basePath: "/api" },
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// S3-compatible (MinIO, Spaces, etc.)
|
|
1291
|
+
runtimeConfig({
|
|
1292
|
+
storage: {
|
|
1293
|
+
adapter: s3({
|
|
1294
|
+
bucket: process.env.S3_BUCKET!,
|
|
1295
|
+
region: process.env.S3_REGION!,
|
|
1296
|
+
credentials: {
|
|
1297
|
+
accessKeyId: process.env.S3_ACCESS_KEY!,
|
|
1298
|
+
secretAccessKey: process.env.S3_SECRET_KEY!,
|
|
1299
|
+
},
|
|
1300
|
+
}),
|
|
1301
|
+
basePath: "/api",
|
|
1302
|
+
},
|
|
1183
1303
|
});
|
|
1184
1304
|
|
|
1185
|
-
//
|
|
1305
|
+
// Cloudflare R2
|
|
1186
1306
|
runtimeConfig({
|
|
1187
|
-
storage: {
|
|
1307
|
+
storage: {
|
|
1308
|
+
adapter: r2({
|
|
1309
|
+
bucket: process.env.R2_BUCKET!,
|
|
1310
|
+
accountId: process.env.R2_ACCOUNT_ID!,
|
|
1311
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
|
1312
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
|
1313
|
+
}),
|
|
1314
|
+
basePath: "/api",
|
|
1315
|
+
},
|
|
1188
1316
|
});
|
|
1189
1317
|
|
|
1190
1318
|
// Auto-detected from env vars:
|
|
@@ -1310,33 +1438,76 @@ await ctx.queue.indexRecords.publish({ collection: "posts" });
|
|
|
1310
1438
|
|
|
1311
1439
|
## 19. Realtime
|
|
1312
1440
|
|
|
1313
|
-
|
|
1441
|
+
Live queries over SSE. **Broadcasts are automatic** — every collection/global create/update/delete already writes a change event to the `questpie_realtime_log` outbox and notifies subscribers. Do NOT write `afterChange` hooks that "emit" realtime events; a custom emitter double-fires against the built-in broadcast hook.
|
|
1314
1442
|
|
|
1315
1443
|
### How It Works
|
|
1316
1444
|
|
|
1317
|
-
1. Every
|
|
1318
|
-
2.
|
|
1319
|
-
3.
|
|
1445
|
+
1. Every CRUD write appends a change event to the outbox; an adapter (pg_notify / Redis Streams) or 2s polling wakes subscribers
|
|
1446
|
+
2. The server **re-runs the subscribed query under the subscriber's auth** and pushes the full result as a `snapshot` — clients never receive raw change events (no `operation`/`recordId` on the client; snapshots are access-controlled)
|
|
1447
|
+
3. One SSE connection multiplexes all topics (`POST /realtime`)
|
|
1448
|
+
|
|
1449
|
+
Snapshots are idempotent state, not diffs — filtered subscriptions may receive unchanged snapshots on update/delete (over-refresh by design). Always render from the snapshot.
|
|
1320
1450
|
|
|
1321
1451
|
### Client-Side Usage
|
|
1322
1452
|
|
|
1323
1453
|
```ts
|
|
1324
|
-
//
|
|
1325
|
-
const { data } = useQuery(
|
|
1454
|
+
// React + TanStack Query — typed second arg, no casts needed
|
|
1455
|
+
const { data } = useQuery(
|
|
1456
|
+
qp.collections.posts.find({ where: { event: eventId }, limit: 50 }, { realtime: true }),
|
|
1457
|
+
);
|
|
1326
1458
|
|
|
1327
|
-
//
|
|
1328
|
-
const
|
|
1329
|
-
{
|
|
1330
|
-
(
|
|
1331
|
-
|
|
1332
|
-
|
|
1459
|
+
// Vanilla TS — live() is the live form of find(): same options in, same result type out
|
|
1460
|
+
const stop = client.collections.posts.live(
|
|
1461
|
+
{ where: { event: eventId }, with: { author: true }, orderBy: { createdAt: "desc" } },
|
|
1462
|
+
(snap) => render(snap.docs), // snap.docs[i].author is typed
|
|
1463
|
+
{ onError: (e) => console.error(e) },
|
|
1464
|
+
);
|
|
1465
|
+
stop(); // unsubscribe
|
|
1466
|
+
|
|
1467
|
+
// Async-iterable form (workers, agents, tests) — terminate via AbortSignal
|
|
1468
|
+
for await (const snap of client.collections.posts.liveIter(
|
|
1469
|
+
{ where: { event: eventId } },
|
|
1470
|
+
{ signal: controller.signal },
|
|
1471
|
+
)) {
|
|
1472
|
+
render(snap.docs);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Globals mirror get()
|
|
1476
|
+
client.globals.siteSettings.live(undefined, (settings) => applyTheme(settings));
|
|
1477
|
+
|
|
1478
|
+
// Low-level escape hatch — topic objects, never channel strings; data is untyped
|
|
1479
|
+
client.realtime.subscribe(
|
|
1480
|
+
{ resourceType: "collection", resource: "posts", where: { event: eventId } },
|
|
1481
|
+
(data) => {}, // unknown — prefer the typed live() wrappers
|
|
1333
1482
|
);
|
|
1334
1483
|
```
|
|
1335
1484
|
|
|
1485
|
+
Live options carry exactly what the wire protocol supports: `where`, `with`, `limit`, `offset`, `orderBy`, `locale` (no `columns`/`groupBy`/`search` — compile error).
|
|
1486
|
+
|
|
1487
|
+
### Wire Protocol (stable contract)
|
|
1488
|
+
|
|
1489
|
+
`POST <basePath>/realtime` body `{ topics: [{ id, resourceType: "collection" | "global", resource, where?, with?, limit?, offset?, orderBy?, locale? }] }` → SSE events:
|
|
1490
|
+
|
|
1491
|
+
| Event | Payload | Meaning |
|
|
1492
|
+
| ---------- | ------------------------ | ------------------------------------------------------ |
|
|
1493
|
+
| `snapshot` | `{ topicId, seq, data }` | Full `find()`/`get()` result under subscriber's auth |
|
|
1494
|
+
| `error` | `{ topicId, message }` | Topic-level failure (unknown resource, access denied) |
|
|
1495
|
+
| `ping` | `{ ts }` | Keep-alive (default every 8s) |
|
|
1496
|
+
|
|
1497
|
+
Ignore unknown SSE event types (forward compat).
|
|
1498
|
+
|
|
1499
|
+
### Keepalive (Bun)
|
|
1500
|
+
|
|
1501
|
+
The stream pings every 8s by default (`realtime.keepAliveIntervalMs`). Bun's default `idleTimeout` is 10s — the default ping survives it, but set headroom explicitly in the server entry:
|
|
1502
|
+
|
|
1503
|
+
```ts
|
|
1504
|
+
export default { port: 3000, idleTimeout: 30, fetch: server.fetch };
|
|
1505
|
+
```
|
|
1506
|
+
|
|
1336
1507
|
### Realtime Adapters
|
|
1337
1508
|
|
|
1338
1509
|
```ts
|
|
1339
|
-
import { pgNotifyAdapter } from "questpie";
|
|
1510
|
+
import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
|
|
1340
1511
|
|
|
1341
1512
|
runtimeConfig({
|
|
1342
1513
|
realtime: {
|
|
@@ -1434,11 +1605,11 @@ interface AppContext {
|
|
|
1434
1605
|
globals: { [name]: GlobalAPI }; // typed CRUD for all globals
|
|
1435
1606
|
queue: QueueClient; // dispatch background jobs
|
|
1436
1607
|
email: MailerService; // send emails
|
|
1437
|
-
storage:
|
|
1608
|
+
storage: Files; // direct typed Files SDK storage operations
|
|
1438
1609
|
kv: KVService; // key-value store
|
|
1439
1610
|
logger: LoggerService; // structured logging
|
|
1440
1611
|
search: SearchService; // full-text search
|
|
1441
|
-
realtime: RealtimeService; //
|
|
1612
|
+
realtime: RealtimeService; // server-side change-event subscription (broadcasts are automatic)
|
|
1442
1613
|
t: (key, params?, locale?) => string; // i18n translator
|
|
1443
1614
|
services: Record<string, unknown>; // user-defined services
|
|
1444
1615
|
}
|
|
@@ -1455,23 +1626,32 @@ interface AppContext {
|
|
|
1455
1626
|
| Access rules | Destructure: `({ session, data }) => boolean` |
|
|
1456
1627
|
| Seeds | `async ({ collections, log }) => { ... }` |
|
|
1457
1628
|
| Services | `create: ({ app }) => ...` (app instance only, not full context) |
|
|
1629
|
+
| Better Auth callbacks (`onLinkAccount`, `databaseHooks`, `sendMagicLink`, plugin hooks) | `getContext<App>()` — `/auth/*` is a raw route executed inside `runWithContext`, so the request scope is live there (see `references/auth.md`) |
|
|
1458
1630
|
|
|
1459
1631
|
### Getting Context Programmatically
|
|
1460
1632
|
|
|
1461
1633
|
```ts
|
|
1462
|
-
import { getContext, tryGetContext } from "questpie";
|
|
1634
|
+
import { getContext, tryGetContext } from "questpie/types";
|
|
1635
|
+
import type { App } from "#questpie"; // type-only — no runtime cycle
|
|
1463
1636
|
|
|
1464
|
-
const ctx = getContext(); // throws
|
|
1465
|
-
const
|
|
1637
|
+
const ctx = getContext<App>(); // typed app/session/extensions; throws outside a request scope
|
|
1638
|
+
const maybe = tryGetContext(); // returns null if outside scope
|
|
1466
1639
|
|
|
1467
1640
|
// Create a fresh context manually:
|
|
1468
|
-
const
|
|
1641
|
+
const fresh = await app.createContext({
|
|
1469
1642
|
session: null,
|
|
1470
1643
|
locale: "en",
|
|
1471
1644
|
accessMode: "system",
|
|
1472
1645
|
});
|
|
1473
1646
|
```
|
|
1474
1647
|
|
|
1648
|
+
**Partial context overrides:** the second argument of every CRUD call merges with the ambient request scope (priority: explicit param → ALS scope → defaults). A bare `{ accessMode: "system" }` elevates **only** the mode — `session`, `db`, and `locale` inherit from the request automatically. The inverse works too: `{ accessMode: "user" }` inside system-scoped code re-enables access rules against the inherited session. Never re-thread session/locale by hand:
|
|
1649
|
+
|
|
1650
|
+
```ts
|
|
1651
|
+
await app.collections.posts.find({}, { accessMode: "system" }); // mode elevated, request session/locale ride along
|
|
1652
|
+
await app.collections.posts.find({}, { accessMode: "user" }); // rules enforced for the inherited session
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1475
1655
|
---
|
|
1476
1656
|
|
|
1477
1657
|
## 22. Server-Side CRUD API
|
|
@@ -1507,9 +1687,10 @@ const post = await app.collections.posts.findOne({
|
|
|
1507
1687
|
// WRITE
|
|
1508
1688
|
.create(data) → T
|
|
1509
1689
|
.updateById({ id, data }) → T
|
|
1510
|
-
.
|
|
1690
|
+
.updateMany({ where, data }) → T[] (batch; deprecated alias: update)
|
|
1691
|
+
.updateBatch({ updates }) → T[] (per-record batch)
|
|
1511
1692
|
.deleteById({ id }) → { success }
|
|
1512
|
-
.
|
|
1693
|
+
.deleteMany({ where }) → { success, count } (batch; deprecated alias: delete)
|
|
1513
1694
|
.restoreById({ id }) → T (soft-delete)
|
|
1514
1695
|
|
|
1515
1696
|
// VERSIONING
|
|
@@ -1539,7 +1720,7 @@ const post = await app.collections.posts.findOne({
|
|
|
1539
1720
|
author: true,
|
|
1540
1721
|
tags: { columns: { name: true } },
|
|
1541
1722
|
},
|
|
1542
|
-
orderBy: { createdAt: "desc" },
|
|
1723
|
+
orderBy: { createdAt: "desc" }, // or multi-field: [{ status: "desc" }, { createdAt: "desc" }]
|
|
1543
1724
|
limit: 10,
|
|
1544
1725
|
offset: 0,
|
|
1545
1726
|
search: "keyword", // full-text ILIKE on title
|
|
@@ -1815,7 +1996,7 @@ The admin panel is a **server-driven React SPA**. The server declares what shoul
|
|
|
1815
1996
|
|
|
1816
1997
|
```ts
|
|
1817
1998
|
// modules.ts
|
|
1818
|
-
import { adminModule } from "@questpie/admin/
|
|
1999
|
+
import { adminModule } from "@questpie/admin/modules/admin";
|
|
1819
2000
|
export default [adminModule] as const;
|
|
1820
2001
|
|
|
1821
2002
|
// config/admin.ts
|
|
@@ -2347,50 +2528,68 @@ export const { GET, POST, PUT, PATCH, DELETE } =
|
|
|
2347
2528
|
questpieNextRouteHandlers(questpieApp);
|
|
2348
2529
|
```
|
|
2349
2530
|
|
|
2531
|
+
### MCP Integration
|
|
2532
|
+
|
|
2533
|
+
Use `@questpie/mcp` as a static module:
|
|
2534
|
+
|
|
2535
|
+
```ts
|
|
2536
|
+
// modules.ts
|
|
2537
|
+
import mcpModule from "@questpie/mcp";
|
|
2538
|
+
|
|
2539
|
+
export default [mcpModule] as const;
|
|
2540
|
+
```
|
|
2541
|
+
|
|
2542
|
+
Configure it through plugin-discovered `config/mcp.ts`, not `mcpModule(options)`. It exposes generated CRUD tools, annotated JSON route tools, schema resources, and custom `mcp-tools/` definitions. HTTP MCP is always user mode and cannot be elevated to system mode; stdio defaults to trusted system mode unless explicitly lowered.
|
|
2543
|
+
|
|
2350
2544
|
### Storage Adapters
|
|
2351
2545
|
|
|
2352
|
-
| Config
|
|
2353
|
-
|
|
|
2354
|
-
| Default
|
|
2355
|
-
| `QUESTPIE_STORAGE_*` env vars
|
|
2356
|
-
| `{
|
|
2546
|
+
| Config | Storage backend |
|
|
2547
|
+
| ----------------------------------- | -------------------------------- |
|
|
2548
|
+
| Default | Filesystem adapter (`./uploads`) |
|
|
2549
|
+
| `QUESTPIE_STORAGE_*` env vars | S3-compatible (auto-detected) |
|
|
2550
|
+
| `{ adapter: customAdapter }` | Files SDK adapter instance |
|
|
2357
2551
|
|
|
2358
2552
|
### Queue Adapters
|
|
2359
2553
|
|
|
2360
|
-
| Adapter
|
|
2361
|
-
|
|
|
2362
|
-
| `pgBossAdapter()`
|
|
2363
|
-
| `
|
|
2554
|
+
| Adapter | Description |
|
|
2555
|
+
| --------------------------- | ------------------------------------ |
|
|
2556
|
+
| `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
|
|
2557
|
+
| `bullMQAdapter()` | Redis-based job queue (BullMQ) |
|
|
2558
|
+
| `cloudflareQueuesAdapter()` | Cloudflare Workers Queues |
|
|
2364
2559
|
|
|
2365
2560
|
### Search Adapters
|
|
2366
2561
|
|
|
2367
|
-
| Adapter
|
|
2368
|
-
|
|
|
2369
|
-
| `
|
|
2370
|
-
| `
|
|
2562
|
+
| Adapter | Description |
|
|
2563
|
+
| -------------------------------- | --------------------------------- |
|
|
2564
|
+
| `createPostgresSearchAdapter()` | pg_trgm + full-text search |
|
|
2565
|
+
| `createPgVectorSearchAdapter()` | Hybrid semantic search (pgvector + embedding provider — see `references/infrastructure-adapters.md`) |
|
|
2371
2566
|
|
|
2372
2567
|
### Realtime Adapters
|
|
2373
2568
|
|
|
2374
|
-
| Adapter
|
|
2375
|
-
|
|
|
2376
|
-
| Default
|
|
2377
|
-
| `pgNotifyAdapter()`
|
|
2378
|
-
| `redisStreamsAdapter()`
|
|
2569
|
+
| Adapter | Description |
|
|
2570
|
+
| ----------------------------- | -------------------------- |
|
|
2571
|
+
| Default | Polling (2s interval) |
|
|
2572
|
+
| `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
|
|
2573
|
+
| `redisStreamsAdapter()` | Redis Streams |
|
|
2574
|
+
| `cloudflareRealtimeAdapter()` | Cloudflare Durable Objects |
|
|
2379
2575
|
|
|
2380
2576
|
### KV Adapters
|
|
2381
2577
|
|
|
2382
|
-
| Adapter
|
|
2383
|
-
|
|
|
2384
|
-
| `MemoryKVAdapter`
|
|
2385
|
-
| `
|
|
2578
|
+
| Adapter | Description |
|
|
2579
|
+
| ----------------------- | --------------------- |
|
|
2580
|
+
| `MemoryKVAdapter` | In-process (default) |
|
|
2581
|
+
| `RedisKVAdapter` | Redis-backed |
|
|
2582
|
+
| `cloudflareKVAdapter()` | Cloudflare Workers KV |
|
|
2386
2583
|
|
|
2387
2584
|
### Email Adapters
|
|
2388
2585
|
|
|
2389
|
-
| Adapter | Description
|
|
2390
|
-
| ----------------------------- |
|
|
2391
|
-
| `ConsoleAdapter` | Logs to console
|
|
2392
|
-
| `SmtpAdapter` | Nodemailer SMTP
|
|
2393
|
-
| `
|
|
2586
|
+
| Adapter | Description |
|
|
2587
|
+
| ----------------------------- | -------------------------------- |
|
|
2588
|
+
| `ConsoleAdapter` | Logs to console |
|
|
2589
|
+
| `SmtpAdapter` | Nodemailer SMTP |
|
|
2590
|
+
| `resendAdapter()` | Resend HTTP API (and compatible) |
|
|
2591
|
+
| `plunkAdapter()` | Plunk transactional HTTP API |
|
|
2592
|
+
| `createEtherealSmtpAdapter()` | Auto-generated test account |
|
|
2394
2593
|
|
|
2395
2594
|
### Logger
|
|
2396
2595
|
|
|
@@ -2618,7 +2817,7 @@ module() Packaging unit (groups related entities)
|
|
|
2618
2817
|
├── block() Content builder block ← admin plugin
|
|
2619
2818
|
├── migration() DB schema change
|
|
2620
2819
|
├── seed() DB seed data
|
|
2621
|
-
├── appConfig() App-level config (locale, access, hooks)
|
|
2820
|
+
├── appConfig() App-level config (locale, access, hooks, context)
|
|
2622
2821
|
├── authConfig() Auth config (Better Auth options)
|
|
2623
2822
|
└── adminConfig() Admin config (sidebar, dashboard, branding) ← admin plugin
|
|
2624
2823
|
```
|
|
@@ -2658,8 +2857,10 @@ module() Packaging unit (groups related entities)
|
|
|
2658
2857
|
| Seed | `seed({...})` | `questpie` | DB seed data |
|
|
2659
2858
|
| Module | `module({...})` | `questpie` | Packaging unit |
|
|
2660
2859
|
| Runtime Config | `runtimeConfig({...})` | `questpie` | Infrastructure config |
|
|
2661
|
-
| App Config | `appConfig({...})` | `questpie` | Locale, access, hooks
|
|
2860
|
+
| App Config | `appConfig({...})` | `questpie` | Locale, access, hooks, context |
|
|
2662
2861
|
| Auth Config | `authConfig({...})` | `questpie` | Better Auth options |
|
|
2862
|
+
| Env | `env({...})` | `questpie/env` | Boot-validated typed env |
|
|
2863
|
+
| Client Env | `clientEnv({...})` | `questpie/env` | Client-safe env definition |
|
|
2663
2864
|
| Admin Config | `adminConfig({...})` | `#questpie/factories` | Sidebar, dashboard, branding |
|
|
2664
2865
|
| View | `view(name, {kind, component})` | `@questpie/admin/client` | Admin view component |
|
|
2665
2866
|
| Widget | `widget(name, {component})` | `@questpie/admin/client` | Dashboard widget |
|