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
@@ -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 | **FlyDrive** (local FS, S3, R2, GCS) |
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
- import { runtimeConfig } from "questpie";
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: process.env.DATABASE_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/server";
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.
@@ -295,8 +318,8 @@ export const posts = collection("posts")
295
318
  title: f.text(255).required().label("Title"),
296
319
  slug: f.text(255).required(),
297
320
  content: f.richText().localized(),
298
- status: f.select(["draft", "published"]).default("draft"),
299
- author: f.relation("users"),
321
+ status: f.select(["internal", "featured"]).default("internal"),
322
+ author: f.relation("user"),
300
323
  tags: f.relation("tags").manyToMany({ through: "post_tags" }),
301
324
  cover: f.upload(),
302
325
  }))
@@ -376,9 +399,9 @@ export const posts = collection("posts")
376
399
  description: "It will become visible to all readers.",
377
400
  },
378
401
  handler: async ({ record, collections }) => {
379
- await collections.posts.updateById({
402
+ await collections.posts.transitionStage({
380
403
  id: record.id,
381
- data: { status: "published" },
404
+ stage: "published",
382
405
  });
383
406
  return { type: "success", toast: { message: "Published!" } };
384
407
  },
@@ -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)` | Combine two builders |
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) // escape hatch: modify Drizzle column
519
- .zod((schema) => schema) // escape hatch: modify Zod 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("users");
558
- // Column: authorId varchar(36) → FK to users.id
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("users"); // string (collection name)
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("users")
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 | Use Case |
1014
- | ------------------------- | ------------------------------------------------ |
1015
- | `pgBossAdapter()` | PostgreSQL-based (default, great for most cases) |
1016
- | `CloudflareQueuesAdapter` | Cloudflare Workers |
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
- | `createEtherealSmtpAdapter()` | Auto-generated test SMTP account |
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: { location: "./uploads", basePath: "/api" },
1287
+ storage: { adapter: fs({ root: "./uploads" }), basePath: "/api" },
1183
1288
  });
1184
1289
 
1185
- // S3-compatible (R2, Minio, etc.)
1290
+ // S3-compatible (MinIO, Spaces, etc.)
1186
1291
  runtimeConfig({
1187
- storage: { driver: myS3Driver },
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
+ },
1303
+ });
1304
+
1305
+ // Cloudflare R2
1306
+ runtimeConfig({
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:
@@ -1238,6 +1366,8 @@ collection("pages").options({
1238
1366
  - `transitionStage({ id, stage: "published" })` → move between workflow stages
1239
1367
  - `beforeTransition` / `afterTransition` hooks
1240
1368
 
1369
+ For publishable pages with workflow enabled, workflow stage is the publication source. Public reads must pass `stage: "published"`. If public client/HTTP access is enabled, anonymous read access should require `input?.stage === "published"` so callers cannot omit `stage` and fetch the working draft. Preview/draft-mode reads may omit `stage` to show the working stage to authorized editors. Do not add duplicate `isPublished` guidance when workflow already controls publishing.
1370
+
1241
1371
  ### API Usage
1242
1372
 
1243
1373
  ```ts
@@ -1308,33 +1438,76 @@ await ctx.queue.indexRecords.publish({ collection: "posts" });
1308
1438
 
1309
1439
  ## 19. Realtime
1310
1440
 
1311
- Server-sent events for live data updates.
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.
1312
1442
 
1313
1443
  ### How It Works
1314
1444
 
1315
- 1. Every create/update/delete writes a change event to `questpie_realtime_log` (outbox pattern)
1316
- 2. An adapter (pg_notify or Redis Streams) notifies connected clients
1317
- 3. Clients subscribe via SSE to specific resources
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.
1318
1450
 
1319
1451
  ### Client-Side Usage
1320
1452
 
1321
1453
  ```ts
1322
- // With TanStack Query (automatic)
1323
- const { data } = useQuery(qp.collections.posts.find({}, { realtime: true }));
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
+ );
1324
1458
 
1325
- // Manual subscription
1326
- const unsub = client.realtime.subscribe(
1327
- { resourceType: "collection", resource: "posts" },
1328
- (event) => {
1329
- console.log("Change:", event.operation, event.recordId);
1330
- },
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
1331
1482
  );
1332
1483
  ```
1333
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
+
1334
1507
  ### Realtime Adapters
1335
1508
 
1336
1509
  ```ts
1337
- import { pgNotifyAdapter } from "questpie";
1510
+ import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
1338
1511
 
1339
1512
  runtimeConfig({
1340
1513
  realtime: {
@@ -1432,11 +1605,11 @@ interface AppContext {
1432
1605
  globals: { [name]: GlobalAPI }; // typed CRUD for all globals
1433
1606
  queue: QueueClient; // dispatch background jobs
1434
1607
  email: MailerService; // send emails
1435
- storage: DriveManager; // file storage operations
1608
+ storage: Files; // direct typed Files SDK storage operations
1436
1609
  kv: KVService; // key-value store
1437
1610
  logger: LoggerService; // structured logging
1438
1611
  search: SearchService; // full-text search
1439
- realtime: RealtimeService; // publish realtime events
1612
+ realtime: RealtimeService; // server-side change-event subscription (broadcasts are automatic)
1440
1613
  t: (key, params?, locale?) => string; // i18n translator
1441
1614
  services: Record<string, unknown>; // user-defined services
1442
1615
  }
@@ -1453,23 +1626,32 @@ interface AppContext {
1453
1626
  | Access rules | Destructure: `({ session, data }) => boolean` |
1454
1627
  | Seeds | `async ({ collections, log }) => { ... }` |
1455
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`) |
1456
1630
 
1457
1631
  ### Getting Context Programmatically
1458
1632
 
1459
1633
  ```ts
1460
- import { getContext, tryGetContext } from "questpie";
1634
+ import { getContext, tryGetContext } from "questpie/types";
1635
+ import type { App } from "#questpie"; // type-only — no runtime cycle
1461
1636
 
1462
- const ctx = getContext(); // throws if outside a request scope
1463
- const ctx = tryGetContext(); // returns null if outside scope
1637
+ const ctx = getContext<App>(); // typed app/session/extensions; throws outside a request scope
1638
+ const maybe = tryGetContext(); // returns null if outside scope
1464
1639
 
1465
1640
  // Create a fresh context manually:
1466
- const ctx = await app.createContext({
1641
+ const fresh = await app.createContext({
1467
1642
  session: null,
1468
1643
  locale: "en",
1469
1644
  accessMode: "system",
1470
1645
  });
1471
1646
  ```
1472
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
+
1473
1655
  ---
1474
1656
 
1475
1657
  ## 22. Server-Side CRUD API
@@ -1505,9 +1687,10 @@ const post = await app.collections.posts.findOne({
1505
1687
  // WRITE
1506
1688
  .create(data) → T
1507
1689
  .updateById({ id, data }) → T
1508
- .update({ where, data }) → T[] (batch)
1690
+ .updateMany({ where, data }) → T[] (batch; deprecated alias: update)
1691
+ .updateBatch({ updates }) → T[] (per-record batch)
1509
1692
  .deleteById({ id }) → { success }
1510
- .delete({ where }) → { success, count } (batch)
1693
+ .deleteMany({ where }) → { success, count } (batch; deprecated alias: delete)
1511
1694
  .restoreById({ id }) → T (soft-delete)
1512
1695
 
1513
1696
  // VERSIONING
@@ -1537,7 +1720,7 @@ const post = await app.collections.posts.findOne({
1537
1720
  author: true,
1538
1721
  tags: { columns: { name: true } },
1539
1722
  },
1540
- orderBy: { createdAt: "desc" },
1723
+ orderBy: { createdAt: "desc" }, // or multi-field: [{ status: "desc" }, { createdAt: "desc" }]
1541
1724
  limit: 10,
1542
1725
  offset: 0,
1543
1726
  search: "keyword", // full-text ILIKE on title
@@ -1813,7 +1996,7 @@ The admin panel is a **server-driven React SPA**. The server declares what shoul
1813
1996
 
1814
1997
  ```ts
1815
1998
  // modules.ts
1816
- import { adminModule } from "@questpie/admin/server";
1999
+ import { adminModule } from "@questpie/admin/modules/admin";
1817
2000
  export default [adminModule] as const;
1818
2001
 
1819
2002
  // config/admin.ts
@@ -2012,6 +2195,10 @@ collection("posts").preview({
2012
2195
  });
2013
2196
  ```
2014
2197
 
2198
+ Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names. Preserve save, autosave, Cmd+S, history, workflow transitions, locks, and actions in the normal form lifecycle.
2199
+
2200
+ Frontend visual editing needs `useCollectionPreview`, `PreviewProvider`, `PreviewField`, and usually `BlockRenderer`; load the `questpie-admin` skill for the full frontend preparation checklist.
2201
+
2015
2202
  ### `.actions()` — Server Actions
2016
2203
 
2017
2204
  ```ts
@@ -2028,9 +2215,9 @@ collection("posts").actions(({ a, c, f }) => ({
2028
2215
  destructive: false,
2029
2216
  },
2030
2217
  handler: async ({ record, collections }) => {
2031
- await collections.posts.updateById({
2218
+ await collections.posts.transitionStage({
2032
2219
  id: record.id,
2033
- data: { status: "published" },
2220
+ stage: "published",
2034
2221
  });
2035
2222
  return { type: "success", toast: { message: "Published!" } };
2036
2223
  },
@@ -2341,50 +2528,68 @@ export const { GET, POST, PUT, PATCH, DELETE } =
2341
2528
  questpieNextRouteHandlers(questpieApp);
2342
2529
  ```
2343
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
+
2344
2544
  ### Storage Adapters
2345
2545
 
2346
- | Config | Driver |
2347
- | ----------------------------- | ------------------------------ |
2348
- | Default | `FSDriver` (local `./uploads`) |
2349
- | `QUESTPIE_STORAGE_*` env vars | S3-compatible (auto-detected) |
2350
- | `{ driver: customDriver }` | Any FlyDrive `DriverContract` |
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 |
2351
2551
 
2352
2552
  ### Queue Adapters
2353
2553
 
2354
- | Adapter | Description |
2355
- | ------------------------- | ------------------------------------ |
2356
- | `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
2357
- | `CloudflareQueuesAdapter` | Cloudflare Workers Queues |
2554
+ | Adapter | Description |
2555
+ | --------------------------- | ------------------------------------ |
2556
+ | `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
2557
+ | `bullMQAdapter()` | Redis-based job queue (BullMQ) |
2558
+ | `cloudflareQueuesAdapter()` | Cloudflare Workers Queues |
2358
2559
 
2359
2560
  ### Search Adapters
2360
2561
 
2361
- | Adapter | Description |
2362
- | ----------------------- | --------------------------------- |
2363
- | `PostgresSearchAdapter` | pg_trgm + full-text search |
2364
- | `PgVectorSearchAdapter` | Hybrid semantic search (pgvector) |
2562
+ | Adapter | Description |
2563
+ | -------------------------------- | --------------------------------- |
2564
+ | `createPostgresSearchAdapter()` | pg_trgm + full-text search |
2565
+ | `createPgVectorSearchAdapter()` | Hybrid semantic search (pgvector + embedding provider — see `references/infrastructure-adapters.md`) |
2365
2566
 
2366
2567
  ### Realtime Adapters
2367
2568
 
2368
- | Adapter | Description |
2369
- | ----------------------- | ------------------------ |
2370
- | Default | Polling (2s interval) |
2371
- | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
2372
- | `redisStreamsAdapter()` | Redis Streams |
2569
+ | Adapter | Description |
2570
+ | ----------------------------- | -------------------------- |
2571
+ | Default | Polling (2s interval) |
2572
+ | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
2573
+ | `redisStreamsAdapter()` | Redis Streams |
2574
+ | `cloudflareRealtimeAdapter()` | Cloudflare Durable Objects |
2373
2575
 
2374
2576
  ### KV Adapters
2375
2577
 
2376
- | Adapter | Description |
2377
- | ------------------ | -------------------- |
2378
- | `MemoryKVAdapter` | In-process (default) |
2379
- | `IORedisKVAdapter` | Redis-backed |
2578
+ | Adapter | Description |
2579
+ | ----------------------- | --------------------- |
2580
+ | `MemoryKVAdapter` | In-process (default) |
2581
+ | `RedisKVAdapter` | Redis-backed |
2582
+ | `cloudflareKVAdapter()` | Cloudflare Workers KV |
2380
2583
 
2381
2584
  ### Email Adapters
2382
2585
 
2383
- | Adapter | Description |
2384
- | ----------------------------- | --------------------------- |
2385
- | `ConsoleAdapter` | Logs to console |
2386
- | `SmtpAdapter` | Nodemailer SMTP |
2387
- | `createEtherealSmtpAdapter()` | Auto-generated test account |
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 |
2388
2593
 
2389
2594
  ### Logger
2390
2595
 
@@ -2612,7 +2817,7 @@ module() Packaging unit (groups related entities)
2612
2817
  ├── block() Content builder block ← admin plugin
2613
2818
  ├── migration() DB schema change
2614
2819
  ├── seed() DB seed data
2615
- ├── appConfig() App-level config (locale, access, hooks)
2820
+ ├── appConfig() App-level config (locale, access, hooks, context)
2616
2821
  ├── authConfig() Auth config (Better Auth options)
2617
2822
  └── adminConfig() Admin config (sidebar, dashboard, branding) ← admin plugin
2618
2823
  ```
@@ -2652,8 +2857,10 @@ module() Packaging unit (groups related entities)
2652
2857
  | Seed | `seed({...})` | `questpie` | DB seed data |
2653
2858
  | Module | `module({...})` | `questpie` | Packaging unit |
2654
2859
  | Runtime Config | `runtimeConfig({...})` | `questpie` | Infrastructure config |
2655
- | App Config | `appConfig({...})` | `questpie` | Locale, access, hooks |
2860
+ | App Config | `appConfig({...})` | `questpie` | Locale, access, hooks, context |
2656
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 |
2657
2864
  | Admin Config | `adminConfig({...})` | `#questpie/factories` | Sidebar, dashboard, branding |
2658
2865
  | View | `view(name, {kind, component})` | `@questpie/admin/client` | Admin view component |
2659
2866
  | Widget | `widget(name, {component})` | `@questpie/admin/client` | Dashboard widget |