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.
Files changed (35) hide show
  1. package/dist/index.mjs +244 -30
  2. package/package.json +1 -1
  3. package/skills/questpie/AGENTS.md +299 -98
  4. package/skills/questpie/SKILL.md +50 -17
  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 +22 -6
  10. package/skills/questpie/references/extend.md +34 -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 +6 -8
  17. package/skills/questpie/references/rules.md +86 -21
  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 +47 -40
  23. package/skills/questpie-admin/SKILL.md +46 -39
  24. package/skills/questpie-admin/references/custom-ui.md +1 -1
  25. package/templates/tanstack-start/AGENTS.md +15 -8
  26. package/templates/tanstack-start/CLAUDE.md +12 -5
  27. package/templates/tanstack-start/README.md +7 -6
  28. package/templates/tanstack-start/package.json +1 -0
  29. package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
  30. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
  31. package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
  32. package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
  33. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
  34. package/templates/tanstack-start/src/routes/api/$.ts +1 -2
  35. 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.
@@ -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("users"),
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)` | 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" },
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
- // S3-compatible (R2, Minio, etc.)
1305
+ // Cloudflare R2
1186
1306
  runtimeConfig({
1187
- storage: { driver: myS3Driver },
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
- 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.
1314
1442
 
1315
1443
  ### How It Works
1316
1444
 
1317
- 1. Every create/update/delete writes a change event to `questpie_realtime_log` (outbox pattern)
1318
- 2. An adapter (pg_notify or Redis Streams) notifies connected clients
1319
- 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.
1320
1450
 
1321
1451
  ### Client-Side Usage
1322
1452
 
1323
1453
  ```ts
1324
- // With TanStack Query (automatic)
1325
- 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
+ );
1326
1458
 
1327
- // Manual subscription
1328
- const unsub = client.realtime.subscribe(
1329
- { resourceType: "collection", resource: "posts" },
1330
- (event) => {
1331
- console.log("Change:", event.operation, event.recordId);
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: DriveManager; // file storage operations
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; // publish realtime events
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 if outside a request scope
1465
- 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
1466
1639
 
1467
1640
  // Create a fresh context manually:
1468
- const ctx = await app.createContext({
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
- .update({ where, data }) → T[] (batch)
1690
+ .updateMany({ where, data }) → T[] (batch; deprecated alias: update)
1691
+ .updateBatch({ updates }) → T[] (per-record batch)
1511
1692
  .deleteById({ id }) → { success }
1512
- .delete({ where }) → { success, count } (batch)
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/server";
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 | Driver |
2353
- | ----------------------------- | ------------------------------ |
2354
- | Default | `FSDriver` (local `./uploads`) |
2355
- | `QUESTPIE_STORAGE_*` env vars | S3-compatible (auto-detected) |
2356
- | `{ 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 |
2357
2551
 
2358
2552
  ### Queue Adapters
2359
2553
 
2360
- | Adapter | Description |
2361
- | ------------------------- | ------------------------------------ |
2362
- | `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
2363
- | `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 |
2364
2559
 
2365
2560
  ### Search Adapters
2366
2561
 
2367
- | Adapter | Description |
2368
- | ----------------------- | --------------------------------- |
2369
- | `PostgresSearchAdapter` | pg_trgm + full-text search |
2370
- | `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`) |
2371
2566
 
2372
2567
  ### Realtime Adapters
2373
2568
 
2374
- | Adapter | Description |
2375
- | ----------------------- | ------------------------ |
2376
- | Default | Polling (2s interval) |
2377
- | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
2378
- | `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 |
2379
2575
 
2380
2576
  ### KV Adapters
2381
2577
 
2382
- | Adapter | Description |
2383
- | ------------------ | -------------------- |
2384
- | `MemoryKVAdapter` | In-process (default) |
2385
- | `IORedisKVAdapter` | Redis-backed |
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
- | `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 |
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 |