create-questpie 2.0.4 → 2.1.0

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 (152) hide show
  1. package/dist/index.mjs +362 -119
  2. package/package.json +2 -3
  3. package/templates/elysia/AGENTS.md +56 -0
  4. package/templates/elysia/CLAUDE.md +39 -0
  5. package/templates/elysia/Dockerfile +24 -0
  6. package/templates/elysia/README.md +148 -0
  7. package/templates/elysia/docker/init-extensions.sql +11 -0
  8. package/templates/elysia/docker-compose.yml +21 -0
  9. package/templates/elysia/env.example +16 -0
  10. package/templates/elysia/gitignore +6 -0
  11. package/templates/elysia/package.json +47 -0
  12. package/templates/elysia/questpie.config.ts +12 -0
  13. package/templates/elysia/src/index.ts +21 -0
  14. package/templates/elysia/src/lib/auth-client.ts +32 -0
  15. package/templates/elysia/src/lib/client.ts +13 -0
  16. package/templates/elysia/src/lib/env.ts +24 -0
  17. package/templates/elysia/src/lib/query-client.ts +18 -0
  18. package/templates/elysia/src/lib/query.ts +18 -0
  19. package/templates/elysia/src/questpie/server/.generated/context.gen.ts +200 -0
  20. package/templates/elysia/src/questpie/server/.generated/entities.gen.ts +84 -0
  21. package/templates/elysia/src/questpie/server/.generated/factories.ts +65 -0
  22. package/templates/elysia/src/questpie/server/.generated/index.ts +131 -0
  23. package/templates/elysia/src/questpie/server/.generated/names.gen.ts +25 -0
  24. package/templates/elysia/src/questpie/server/app.ts +10 -0
  25. package/templates/elysia/src/questpie/server/collections/index.ts +1 -0
  26. package/templates/elysia/src/questpie/server/collections/posts.collection.ts +10 -0
  27. package/templates/elysia/src/questpie/server/config/auth.ts +8 -0
  28. package/templates/elysia/src/questpie/server/config/openapi.ts +10 -0
  29. package/templates/elysia/src/questpie/server/globals/index.ts +1 -0
  30. package/templates/elysia/src/questpie/server/globals/site-settings.global.ts +10 -0
  31. package/templates/elysia/src/questpie/server/modules.ts +8 -0
  32. package/templates/elysia/src/questpie/server/questpie.config.ts +21 -0
  33. package/templates/elysia/tsconfig.json +28 -0
  34. package/templates/hono/AGENTS.md +56 -0
  35. package/templates/hono/CLAUDE.md +39 -0
  36. package/templates/hono/Dockerfile +24 -0
  37. package/templates/hono/README.md +148 -0
  38. package/templates/hono/docker/init-extensions.sql +11 -0
  39. package/templates/hono/docker-compose.yml +21 -0
  40. package/templates/hono/env.example +16 -0
  41. package/templates/hono/gitignore +6 -0
  42. package/templates/hono/package.json +47 -0
  43. package/templates/hono/questpie.config.ts +12 -0
  44. package/templates/hono/src/index.ts +30 -0
  45. package/templates/hono/src/lib/auth-client.ts +32 -0
  46. package/templates/hono/src/lib/client.ts +13 -0
  47. package/templates/hono/src/lib/env.ts +24 -0
  48. package/templates/hono/src/lib/query-client.ts +18 -0
  49. package/templates/hono/src/lib/query.ts +18 -0
  50. package/templates/hono/src/questpie/server/.generated/context.gen.ts +200 -0
  51. package/templates/hono/src/questpie/server/.generated/entities.gen.ts +84 -0
  52. package/templates/hono/src/questpie/server/.generated/factories.ts +65 -0
  53. package/templates/hono/src/questpie/server/.generated/index.ts +131 -0
  54. package/templates/hono/src/questpie/server/.generated/names.gen.ts +25 -0
  55. package/templates/hono/src/questpie/server/app.ts +10 -0
  56. package/templates/hono/src/questpie/server/collections/index.ts +1 -0
  57. package/templates/hono/src/questpie/server/collections/posts.collection.ts +10 -0
  58. package/templates/hono/src/questpie/server/config/auth.ts +8 -0
  59. package/templates/hono/src/questpie/server/config/openapi.ts +10 -0
  60. package/templates/hono/src/questpie/server/globals/index.ts +1 -0
  61. package/templates/hono/src/questpie/server/globals/site-settings.global.ts +10 -0
  62. package/templates/hono/src/questpie/server/modules.ts +8 -0
  63. package/templates/hono/src/questpie/server/questpie.config.ts +21 -0
  64. package/templates/hono/tsconfig.json +28 -0
  65. package/templates/next/AGENTS.md +55 -0
  66. package/templates/next/CLAUDE.md +39 -0
  67. package/templates/next/Dockerfile +25 -0
  68. package/templates/next/README.md +148 -0
  69. package/templates/next/components.json +22 -0
  70. package/templates/next/docker/init-extensions.sql +11 -0
  71. package/templates/next/docker-compose.yml +21 -0
  72. package/templates/next/env.example +16 -0
  73. package/templates/next/gitignore +10 -0
  74. package/templates/next/next-env.d.ts +5 -0
  75. package/templates/next/next.config.ts +20 -0
  76. package/templates/next/package.json +54 -0
  77. package/templates/next/postcss.config.mjs +8 -0
  78. package/templates/next/public/.gitkeep +0 -0
  79. package/templates/next/questpie.config.ts +12 -0
  80. package/templates/next/src/app/admin/[[...all]]/page.tsx +34 -0
  81. package/templates/next/src/app/admin/admin.css +4 -0
  82. package/templates/next/src/app/admin/layout.tsx +63 -0
  83. package/templates/next/src/app/api/[...all]/route.ts +24 -0
  84. package/templates/next/src/app/layout.tsx +24 -0
  85. package/templates/next/src/app/not-found.tsx +18 -0
  86. package/templates/next/src/app/page.tsx +74 -0
  87. package/templates/next/src/app/providers.tsx +11 -0
  88. package/templates/next/src/lib/auth-client.ts +12 -0
  89. package/templates/next/src/lib/client.ts +13 -0
  90. package/templates/next/src/lib/env.ts +24 -0
  91. package/templates/next/src/lib/query-client.ts +18 -0
  92. package/templates/next/src/lib/query.ts +18 -0
  93. package/templates/next/src/questpie/admin/.generated/client.ts +13 -0
  94. package/templates/next/src/questpie/admin/admin.ts +9 -0
  95. package/templates/next/src/questpie/admin/modules.ts +3 -0
  96. package/templates/next/src/questpie/server/.generated/context.gen.ts +204 -0
  97. package/templates/next/src/questpie/server/.generated/entities.gen.ts +100 -0
  98. package/templates/next/src/questpie/server/.generated/factories.ts +204 -0
  99. package/templates/next/src/questpie/server/.generated/index.ts +139 -0
  100. package/templates/next/src/questpie/server/.generated/names.gen.ts +31 -0
  101. package/templates/next/src/questpie/server/app.ts +10 -0
  102. package/templates/next/src/questpie/server/collections/index.ts +1 -0
  103. package/templates/next/src/questpie/server/collections/posts.collection.ts +58 -0
  104. package/templates/next/src/questpie/server/config/admin.ts +80 -0
  105. package/templates/next/src/questpie/server/config/auth.ts +8 -0
  106. package/templates/next/src/questpie/server/config/openapi.ts +10 -0
  107. package/templates/next/src/questpie/server/globals/index.ts +1 -0
  108. package/templates/next/src/questpie/server/globals/site-settings.global.ts +19 -0
  109. package/templates/next/src/questpie/server/modules.ts +9 -0
  110. package/templates/next/src/questpie/server/questpie.config.ts +21 -0
  111. package/templates/next/src/styles.css +125 -0
  112. package/templates/next/tsconfig.json +37 -0
  113. package/templates/tanstack-start/AGENTS.md +35 -607
  114. package/templates/tanstack-start/CLAUDE.md +26 -134
  115. package/templates/tanstack-start/README.md +13 -1
  116. package/templates/tanstack-start/docker/init-extensions.sql +11 -0
  117. package/templates/tanstack-start/docker-compose.yml +1 -0
  118. package/templates/tanstack-start/src/lib/auth-client.ts +1 -1
  119. package/templates/tanstack-start/src/lib/client.ts +1 -1
  120. package/templates/tanstack-start/src/lib/query.ts +18 -0
  121. package/templates/tanstack-start/src/questpie/server/collections/index.ts +1 -1
  122. package/templates/tanstack-start/src/questpie/server/globals/index.ts +1 -1
  123. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
  124. package/templates/tanstack-start/src/routes/__root.tsx +31 -1
  125. package/templates/tanstack-start/src/routes/api/$.ts +1 -1
  126. package/templates/tanstack-start/src/routes/index.tsx +97 -0
  127. package/skills/questpie/AGENTS.md +0 -2871
  128. package/skills/questpie/SKILL.md +0 -293
  129. package/skills/questpie/coverage.json +0 -213
  130. package/skills/questpie/references/auth.md +0 -236
  131. package/skills/questpie/references/business-logic.md +0 -620
  132. package/skills/questpie/references/codegen-plugin-api.md +0 -382
  133. package/skills/questpie/references/crud-api.md +0 -580
  134. package/skills/questpie/references/data-modeling.md +0 -509
  135. package/skills/questpie/references/extend.md +0 -584
  136. package/skills/questpie/references/field-types.md +0 -398
  137. package/skills/questpie/references/infrastructure-adapters.md +0 -720
  138. package/skills/questpie/references/mcp.md +0 -147
  139. package/skills/questpie/references/multi-tenancy.md +0 -363
  140. package/skills/questpie/references/production.md +0 -640
  141. package/skills/questpie/references/query-operators.md +0 -125
  142. package/skills/questpie/references/quickstart.md +0 -562
  143. package/skills/questpie/references/rules.md +0 -454
  144. package/skills/questpie/references/sandbox.md +0 -110
  145. package/skills/questpie/references/tanstack-query.md +0 -543
  146. package/skills/questpie/references/type-inference.md +0 -167
  147. package/skills/questpie/references/workflows.md +0 -155
  148. package/skills/questpie-admin/AGENTS.md +0 -1515
  149. package/skills/questpie-admin/SKILL.md +0 -443
  150. package/skills/questpie-admin/references/blocks.md +0 -331
  151. package/skills/questpie-admin/references/custom-ui.md +0 -305
  152. package/skills/questpie-admin/references/views.md +0 -449
@@ -1,2871 +0,0 @@
1
- # QuestPie Framework 101
2
-
3
- Everything a developer needs to understand and work with QuestPie CMS.
4
-
5
- ---
6
-
7
- ## Table of Contents
8
-
9
- 1. [Architecture Overview](#1-architecture-overview)
10
- 2. [Project Structure & File Conventions](#2-project-structure--file-conventions)
11
- 3. [App Bootstrap](#3-app-bootstrap)
12
- 4. [Modules](#4-modules)
13
- 5. [Collections](#5-collections)
14
- 6. [Globals](#6-globals)
15
- 7. [Fields](#7-fields)
16
- 8. [Relations](#8-relations)
17
- 9. [Hooks](#9-hooks)
18
- 10. [Access Control](#10-access-control)
19
- 11. [Routes](#11-routes)
20
- 12. [Services](#12-services)
21
- 13. [Jobs](#13-jobs)
22
- 14. [Email Templates](#14-email-templates)
23
- 15. [Migrations & Seeds](#15-migrations--seeds)
24
- 16. [Uploads & Storage](#16-uploads--storage)
25
- 17. [Versioning & Workflow](#17-versioning--workflow)
26
- 18. [Search](#18-search)
27
- 19. [Realtime](#19-realtime)
28
- 20. [i18n / Localization](#20-i18n--localization)
29
- 21. [AppContext — What's Available Everywhere](#21-appcontext--whats-available-everywhere)
30
- 22. [Server-Side CRUD API](#22-server-side-crud-api)
31
- 23. [Client SDK](#23-client-sdk)
32
- 24. [TanStack Query Integration](#24-tanstack-query-integration)
33
- 25. [Admin Panel](#25-admin-panel)
34
- 26. [Admin Builder Extensions](#26-admin-builder-extensions)
35
- 27. [Admin Fields, Views, Widgets, Blocks, Components](#27-admin-fields-views-widgets-blocks-components)
36
- 28. [Codegen & the .generated/ Directory](#28-codegen--the-generated-directory)
37
- 29. [Adapters & Infrastructure](#29-adapters--infrastructure)
38
- 30. [TypeScript Type Augmentation](#30-typescript-type-augmentation)
39
- 31. [CLI Reference](#31-cli-reference)
40
- 32. [How It All Connects — The Big Picture](#32-how-it-all-connects--the-big-picture)
41
-
42
- ---
43
-
44
- ## 1. Architecture Overview
45
-
46
- QuestPie is a **headless CMS framework** for TypeScript. You define your content model declaratively (collections, globals, fields), and the framework gives you:
47
-
48
- - A fully typed **REST API** (auto-generated from your schema)
49
- - A pluggable **admin panel** (React, server-driven)
50
- - **Typed client SDK** + TanStack Query hooks
51
- - Background **jobs**, **email**, **storage**, **search**, **realtime** — all pluggable
52
-
53
- ### Tech Stack
54
-
55
- | Layer | Technology |
56
- | -------- | ------------------------------------------------- |
57
- | Runtime | **Bun** |
58
- | Database | **PostgreSQL** via **Drizzle ORM** |
59
- | Auth | **Better Auth** |
60
- | Storage | **Files SDK** (local FS, S3, R2, GCS) |
61
- | Admin UI | **React** + **TanStack Router** + **Tailwind** |
62
- | HTTP | Custom trie-based router (no Express/Hono needed) |
63
- | Build | **tsdown** (Rolldown-based) + **Turbo** monorepo |
64
-
65
- ### Monorepo Packages
66
-
67
- ```
68
- packages/
69
- questpie/ ← Core framework (server, client, CLI, shared)
70
- admin/ ← Admin panel (server augmentation + React client)
71
- elysia/ ← Elysia HTTP adapter
72
- hono/ ← Hono HTTP adapter
73
- next/ ← Next.js adapter
74
- openapi/ ← OpenAPI/Scalar plugin
75
- mcp/ ← Model Context Protocol integration
76
- tanstack-query/ ← TanStack Query integration
77
- create-questpie/ ← Project scaffolder (bunx create-questpie)
78
- ```
79
-
80
- ---
81
-
82
- ## 2. Project Structure & File Conventions
83
-
84
- A QuestPie project follows a **convention-over-configuration** file layout. The codegen system scans these directories and auto-wires everything.
85
-
86
- ```
87
- <project>/
88
- questpie.config.ts ← CLI entry (wraps app + cli config)
89
- src/
90
- questpie/
91
- server/ ← Server root (all data + behavior)
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
95
- modules.ts ← export default [adminModule, ...] as const
96
- app.ts ← re-export of .generated/index (stable import)
97
- config/
98
- auth.ts ← authConfig({...}) (from "questpie/app")
99
- app.ts ← appConfig({...}) (from "questpie/app", optional)
100
- admin.ts ← adminConfig({...}) (from "#questpie/factories")
101
- collections/ ← One file per collection
102
- globals/ ← One file per global
103
- routes/ ← API routes (recursive, default export)
104
- jobs/ ← Background jobs
105
- services/ ← Custom services
106
- emails/ ← Email templates (.tsx)
107
- blocks/ ← Content blocks
108
- fields/ ← Custom field types
109
- migrations/ ← DB migrations
110
- seeds/ ← DB seeds
111
- messages/ ← i18n messages
112
- features/ ← Feature-first alternative layout
113
- <feature>/
114
- collections/
115
- routes/
116
- jobs/
117
- ...
118
- .generated/ ← DO NOT EDIT (codegen output)
119
- index.ts
120
- factories.ts
121
- env.client.<consumer>.ts ← per-consumer typed client env (when env.client.ts exists)
122
- admin/ ← Admin client config
123
- admin.ts
124
- modules.ts
125
- blocks/ ← Block renderer components
126
- .generated/
127
- client.ts
128
- routes/
129
- api/$.ts ← HTTP catch-all → createFetchHandler(app)
130
- ```
131
-
132
- ### Key Rules
133
-
134
- - **Files starting with `_`** are private/utility — skipped by discovery.
135
- - **`index.ts`** files are always ignored by the scanner (use them as barrel re-exports if needed).
136
- - **File names become keys**: `site-settings.ts` → `siteSettings` (kebab → camelCase). Underscores are preserved (`my_table.ts` → `my_table`) for PostgreSQL naming.
137
- - **`features/`** mirrors the same directory structure — entities from both flat and feature layouts are merged.
138
-
139
- ### Export Conventions Per Directory
140
-
141
- | Directory | Export Style | Factory |
142
- | -------------- | ------------------------- | -------------------- |
143
- | `collections/` | **named** export | `collection("name")` |
144
- | `globals/` | **named** export | `global("name")` |
145
- | `routes/` | **default** export | `route()` |
146
- | `jobs/` | **default** export | `job({...})` |
147
- | `services/` | **named** export | `service()` |
148
- | `emails/` | **default** export (.tsx) | `email({...})` |
149
- | `blocks/` | **named** export | `block("name")` |
150
- | `migrations/` | **default** export | `migration({...})` |
151
- | `seeds/` | **default** export | `seed({...})` |
152
- | `env.ts` | **default** export | `env({...})` |
153
- | `env.client.ts`| **default** export | `clientEnv({...})` |
154
-
155
- ---
156
-
157
- ## 3. App Bootstrap
158
-
159
- ### How It Starts
160
-
161
- ```
162
- questpie.config.ts → modules.ts → codegen → .generated/index.ts → createApp()
163
- ```
164
-
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.):
166
-
167
- ```ts
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";
183
-
184
- export default runtimeConfig({
185
- app: { url: "http://localhost:3000" },
186
- db: { url: env.DATABASE_URL },
187
- storage: { basePath: "/api" },
188
- email: { adapter: new ConsoleAdapter() },
189
- });
190
- ```
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
-
194
- **Step 2** — `modules.ts` declares which module packages to use:
195
-
196
- ```ts
197
- import { adminModule } from "@questpie/admin/modules/admin";
198
- import { openApiModule } from "@questpie/openapi/modules/openapi";
199
-
200
- export default [adminModule, openApiModule] as const;
201
- ```
202
-
203
- **Step 3** — `questpie generate` scans everything and writes `.generated/index.ts` which calls:
204
-
205
- ```ts
206
- export const app = await createApp(
207
- { modules, collections, globals, routes, jobs, seeds, migrations, ... },
208
- runtime
209
- );
210
- ```
211
-
212
- **Step 4** — Inside `createApp()`:
213
-
214
- 1. Auto-prepends `coreModule` (built-in routes, services, field types)
215
- 2. Flattens all modules **depth-first** (sub-modules first, parent last)
216
- 3. **Merges** contributions per key — later modules override earlier ones
217
- 4. Wraps user-level entities as `__user` module (appended **last** = user always wins)
218
- 5. Creates the `Questpie` instance with merged config
219
- 6. Initializes all services (`db`, `auth`, `storage`, `queue`, `email`, `kv`, `logger`, `search`, `realtime`)
220
-
221
- **Step 5** — The HTTP handler connects it all:
222
-
223
- ```ts
224
- // src/routes/api/$.ts (TanStack Start example)
225
- import { createFetchHandler } from "questpie/http";
226
- import { app } from "@/questpie/server/app";
227
- import { createAPIFileRoute } from "@tanstack/react-start/api";
228
-
229
- const handler = createFetchHandler(app, { basePath: "/api" });
230
-
231
- export const APIRoute = createAPIFileRoute("/api/$")({
232
- GET: ({ request }) => handler(request),
233
- POST: ({ request }) => handler(request),
234
- PUT: ({ request }) => handler(request),
235
- DELETE: ({ request }) => handler(request),
236
- PATCH: ({ request }) => handler(request),
237
- });
238
-
239
- // For standalone Bun.serve:
240
- // Bun.serve({ fetch: createFetchHandler(app) });
241
- ```
242
-
243
- This single handler serves all collection CRUD, auth, search, realtime, storage, and custom routes via a **trie-based dispatcher**. The exact wiring depends on your framework — TanStack Start uses `createAPIFileRoute`, Hono uses middleware, Next.js uses route handlers.
244
-
245
- ---
246
-
247
- ## 4. Modules
248
-
249
- Modules are the **packaging unit** of the framework. They are plain static objects — no class instances, no runtime instantiation.
250
-
251
- ```ts
252
- import { module } from "questpie/app";
253
- export const billingModule = module({
254
- name: "billing",
255
- modules: [stripeModule], // sub-dependencies
256
- collections: { invoices, subscriptions },
257
- globals: { billingSettings },
258
- routes: { "create-checkout": createCheckoutRoute },
259
- jobs: { retryPayment },
260
- services: { stripe: stripeService },
261
- migrations: [billingMigration001],
262
- seeds: [billingSeeds],
263
- messages: { en: { "billing.title": "Billing" } },
264
- config: { app: billingAppConfig },
265
- });
266
- ```
267
-
268
- ### What Modules Can Contribute
269
-
270
- | Key | Type | Merge Strategy |
271
- | ------------- | ------------------------------------- | --------------------------- |
272
- | `collections` | `Record<string, CollectionBuilder>` | spread (later wins per key) |
273
- | `globals` | `Record<string, GlobalBuilder>` | spread |
274
- | `routes` | `Record<string, RouteDefinition>` | spread |
275
- | `jobs` | `Record<string, JobDefinition>` | spread |
276
- | `services` | `Record<string, ServiceBuilder>` | spread |
277
- | `fields` | `Record<string, FieldTypeDefinition>` | spread |
278
- | `migrations` | `Migration[]` | array concat |
279
- | `seeds` | `Seed[]` | array concat |
280
- | `messages` | `Record<locale, Record<key, string>>` | deep merge per locale |
281
- | `config` | `{ app?, auth?, admin?, ... }` | per-key strategy |
282
-
283
- ### Built-in Modules
284
-
285
- | Module | Package | Provides |
286
- | --------------- | --------------------------- | ------------------------------------------------------------------------------------------- |
287
- | `coreModule` | `questpie` (auto-prepended) | All REST routes, all services, built-in field types |
288
- | `starterModule` | `questpie` | Auth collections (user, session, account, verification, apikey, assets), Better Auth config |
289
- | `adminModule` | `@questpie/admin` | Admin panel routes, views, components, admin-specific collections |
290
- | `auditModule` | `@questpie/admin` | Audit log collection + cleanup job |
291
- | `openApiModule` | `@questpie/openapi` | OpenAPI schema + Scalar docs UI |
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
-
295
- ### Module Resolution
296
-
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.
298
-
299
- ---
300
-
301
- ## 5. Collections
302
-
303
- Collections are the **primary data primitive** — each one maps to a PostgreSQL table with auto-generated CRUD, hooks, access control, and admin UI.
304
-
305
- ```ts
306
- import { collection } from "#questpie/factories";
307
-
308
- export const posts = collection("posts")
309
- .options({
310
- timestamps: true, // adds createdAt, updatedAt
311
- softDelete: true, // adds deletedAt (records are "trashed", not deleted)
312
- versioning: {
313
- // creates a _versions table
314
- workflow: true, // enables draft → published stages
315
- },
316
- })
317
- .fields(({ f }) => ({
318
- title: f.text(255).required().label("Title"),
319
- slug: f.text(255).required(),
320
- content: f.richText().localized(),
321
- status: f.select(["internal", "featured"]).default("internal"),
322
- author: f.relation("user"),
323
- tags: f.relation("tags").manyToMany({ through: "post_tags" }),
324
- cover: f.upload(),
325
- }))
326
- .title(({ f }) => f.title)
327
- .indexes(({ table }) => [uniqueIndex("posts_slug_unique").on(table.slug)])
328
- .access({
329
- read: true, // public
330
- create: ({ session }) => !!session, // authenticated only
331
- update: ({ session, data }) => data.authorId === session?.user.id, // own records
332
- delete: ({ session }) => session?.user.role === "admin",
333
- })
334
- .hooks({
335
- beforeChange: async ({ data, operation }) => {
336
- if (operation === "create") {
337
- data.slug = slugify(data.title);
338
- }
339
- return data;
340
- },
341
- afterChange: async ({ data, queue }) => {
342
- await queue.indexRecords.publish({ collection: "posts", ids: [data.id] });
343
- },
344
- })
345
- .searchable({ content: (r) => r.title })
346
- .admin(({ c }) => ({
347
- label: "Blog Posts",
348
- icon: c.icon({ name: "ph:article" }),
349
- group: "content",
350
- }))
351
- .list(({ v, f, a }) =>
352
- v.collectionTable({
353
- columns: [f.title, f.status, f.author, f.createdAt],
354
- defaultSort: { field: f.createdAt, direction: "desc" },
355
- filterable: [f.status, f.author],
356
- actions: {
357
- header: { primary: [a.create()] },
358
- row: [a.delete()],
359
- bulk: [a.deleteMany()],
360
- },
361
- }),
362
- )
363
- .form(({ v, f }) =>
364
- v.collectionForm({
365
- fields: [
366
- f.title,
367
- f.slug,
368
- { type: "section", label: "Content", fields: [f.content] },
369
- {
370
- type: "tabs",
371
- tabs: [
372
- { id: "media", label: "Media", fields: [f.cover] },
373
- {
374
- id: "seo",
375
- label: "SEO",
376
- fields: [f.metaTitle, f.metaDescription],
377
- },
378
- ],
379
- },
380
- ],
381
- sidebar: {
382
- fields: [f.status, f.author, f.tags],
383
- },
384
- }),
385
- )
386
- .preview({
387
- enabled: true,
388
- url: ({ record, locale }) => `/blog/${record.slug}?locale=${locale}`,
389
- position: "right",
390
- })
391
- .actions(({ a, c }) => ({
392
- custom: [
393
- a.action({
394
- id: "publish",
395
- label: "Publish Now",
396
- icon: c.icon({ name: "ph:rocket" }),
397
- confirmation: {
398
- title: "Publish this post?",
399
- description: "It will become visible to all readers.",
400
- },
401
- handler: async ({ record, collections }) => {
402
- await collections.posts.transitionStage({
403
- id: record.id,
404
- stage: "published",
405
- });
406
- return { type: "success", toast: { message: "Published!" } };
407
- },
408
- }),
409
- ],
410
- }));
411
- ```
412
-
413
- ### CollectionBuilder Methods
414
-
415
- | Method | Purpose |
416
- | -------------------------------- | ----------------------------------------------------------------- |
417
- | `.fields(({ f }) => {...})` | Define fields (see [Fields](#7-fields)) |
418
- | `.options({...})` | timestamps, softDelete, versioning |
419
- | `.title(({ f }) => f.name)` | Which field is the display title |
420
- | `.indexes(({ table }) => [...])` | Drizzle indexes/constraints |
421
- | `.access({...})` | Permission rules (see [Access Control](#10-access-control)) |
422
- | `.hooks({...})` | Lifecycle hooks (see [Hooks](#9-hooks)) |
423
- | `.searchable({...})` | Full-text search config |
424
- | `.validation({...})` | Zod validation overrides |
425
- | `.upload({...})` | Turn into upload collection (see [Uploads](#16-uploads--storage)) |
426
- | `.set(key, value)` | Plugin extension point |
427
- | `.merge(other)` | Extend another builder of the same name (see below) |
428
- | `.admin({...})` | Admin panel metadata (label, icon, group) |
429
- | `.list({...})` | List view config |
430
- | `.form({...})` | Form view config |
431
- | `.preview({...})` | Live preview config |
432
- | `.actions({...})` | Custom server actions |
433
-
434
- > `.admin()`, `.list()`, `.form()`, `.preview()`, `.actions()` are added by the admin plugin — they're not available without `@questpie/admin`.
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
-
454
- ---
455
-
456
- ## 6. Globals
457
-
458
- Globals are **singleton documents** — one row per global (or one per tenant if `scoped`). Think "site settings", "homepage config", "footer links".
459
-
460
- ```ts
461
- import { global } from "#questpie/factories";
462
-
463
- export const siteSettings = global("site_settings")
464
- .fields(({ f }) => ({
465
- siteName: f.text(255).required().default("My Site"),
466
- description: f.textarea(),
467
- logo: f.upload(),
468
- socialLinks: f.object({
469
- twitter: f.url(),
470
- github: f.url(),
471
- linkedin: f.url(),
472
- }),
473
- }))
474
- .options({
475
- timestamps: true,
476
- scoped: ({ session }) => session?.user.tenantId, // multi-tenant scoping
477
- })
478
- .access({
479
- read: true,
480
- update: ({ session }) => session?.user.role === "admin",
481
- })
482
- .admin(({ c }) => ({
483
- label: "Site Settings",
484
- icon: c.icon({ name: "ph:gear" }),
485
- }))
486
- .form(({ v, f }) =>
487
- v.globalForm({
488
- fields: [f.siteName, f.description, f.logo, f.socialLinks],
489
- }),
490
- );
491
- ```
492
-
493
- ### GlobalBuilder Methods
494
-
495
- Same as CollectionBuilder but without `.list()`, `.preview()`, `.actions()`, `.indexes()`, `.searchable()`, `.upload()`. Globals have `get` + `update` instead of full CRUD.
496
-
497
- ---
498
-
499
- ## 7. Fields
500
-
501
- Fields define the shape of your data. Every field starts from a **field type factory** and can be customized with chained methods.
502
-
503
- ### Built-in Field Types
504
-
505
- All accessed via `f` in the `.fields()` callback:
506
-
507
- | Factory | DB Column | JS Type | Key Options |
508
- | ---------------------- | ------------------------------------ | -------------- | -------------------------------------------------------------------------------- |
509
- | `f.text(maxLength?)` | `varchar(n)` / `text` | `string` | `.pattern(re)`, `.trim()`, `.lowercase()`, `.uppercase()`, `.min(n)`, `.max(n)` |
510
- | `f.textarea()` | `text` | `string` | `.min(n)`, `.max(n)` |
511
- | `f.email(maxLength?)` | `varchar(255)` | `string` | `.min(n)`, `.max(n)` |
512
- | `f.url(maxLength?)` | `varchar(2048)` | `string` | `.min(n)`, `.max(n)` |
513
- | `f.number(mode?)` | `integer` / `real` / `numeric` / ... | `number` | `.min(n)`, `.max(n)`, `.positive()`, `.int()`, `.step(n)` |
514
- | `f.boolean()` | `boolean` | `boolean` | — |
515
- | `f.date()` | `date` | `string` (ISO) | `.autoNow()`, `.autoNowUpdate()` |
516
- | `f.datetime()` | `timestamp` | `Date` | `.autoNow()`, `.autoNowUpdate()` |
517
- | `f.time()` | `time` | `string` | — |
518
- | `f.select(options[])` | `varchar` | `string` | `.enum(name)` |
519
- | `f.relation(target)` | `varchar(36)` FK | `string` | See [Relations](#8-relations) |
520
- | `f.upload(config?)` | `varchar(36)` FK | `string` | `.multiple()` |
521
- | `f.object(fields)` | `jsonb` | `{...}` | Nested field definitions |
522
- | `f.json(config?)` | `jsonb` / `json` | `JsonValue` | `{ mode: "jsonb" \| "json" }` |
523
- | `f.richText()` | `jsonb` | TipTap doc | _Admin plugin only_ |
524
- | `f.blocks()` | `jsonb` | Block tree | _Admin plugin only_ |
525
- | `f.from(column, zod?)` | custom | `unknown` | `.type(name)` — escape hatch (PostGIS, etc.). Internal type string is `"custom"` |
526
-
527
- ### Common Field Methods (Available on All Types)
528
-
529
- ```ts
530
- f.text(255)
531
- .required() // NOT NULL
532
- .default("untitled") // default value (or function)
533
- .label("Post Title") // display label (I18nText)
534
- .description("The main title") // help text
535
- .localized() // stored in i18n table, per-locale
536
- .virtual(sql`...`) // computed column, no DB storage
537
- .array() // wrap as JSONB array
538
- .minItems(1)
539
- .maxItems(10)
540
- .inputFalse() // exclude from create/update input (read-only)
541
- .inputOptional() // always optional in input
542
- .inputTrue() // force into input even if virtual
543
- .outputFalse() // exclude from output (write-only, e.g. password)
544
- .hooks({
545
- // field-level hooks
546
- beforeChange: (value, ctx) => value.trim(),
547
- afterRead: (value, ctx) => value,
548
- validate: (value, ctx) => {
549
- if (!value) throw new Error("Required");
550
- },
551
- })
552
- .access({
553
- // field-level access
554
- read: true,
555
- create: ({ session }) => !!session,
556
- update: ({ session }) => session?.user.role === "admin",
557
- })
558
- .operators(ops) // override WHERE operator set for queries
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)
562
- .fromDb((value) => value) // transform after reading from DB
563
- .toDb((value) => value) // transform before writing to DB
564
- .set(key, value) // plugin extension point (e.g. admin, form config)
565
- .derive(extra); // derive additional runtime state
566
- ```
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
-
591
- ### Custom Field Types
592
-
593
- Define reusable field types with `fieldType()`:
594
-
595
- ```ts
596
- import { fieldType } from "questpie/builders";
597
- export const colorField = fieldType("color", {
598
- create: () => ({
599
- type: "color",
600
- drizzleType: "varchar",
601
- drizzleArgs: [7],
602
- }),
603
- methods: {
604
- palette: (field, colors: string[]) => field.set("palette", colors),
605
- },
606
- });
607
-
608
- // Usage: f.color().palette(["#ff0000", "#00ff00"])
609
- ```
610
-
611
- ---
612
-
613
- ## 8. Relations
614
-
615
- All relationships are expressed via `f.relation(target)` with chain methods:
616
-
617
- ### Relationship Types
618
-
619
- ```ts
620
- // belongsTo (default) — FK on this table
621
- f.relation("user");
622
- // Column: authorId varchar(36) → FK to user.id
623
-
624
- // hasMany — virtual, FK lives on the target table
625
- f.relation("comments").hasMany({
626
- foreignKey: "postId",
627
- onDelete: "cascade",
628
- });
629
-
630
- // manyToMany — virtual, uses a junction table
631
- f.relation("tags").manyToMany({
632
- through: "post_tags",
633
- sourceField: "postId", // optional, inferred
634
- targetField: "tagId", // optional, inferred
635
- });
636
-
637
- // multiple — stores array of IDs as JSONB
638
- f.relation("assets").multiple();
639
- // Column: imageIds jsonb (["uuid1", "uuid2", ...])
640
-
641
- // polymorphic (morphTo) — two columns: type + id
642
- f.relation({ users: "users", teams: "teams" });
643
- // Columns: assigneeType varchar, assigneeId varchar
644
- ```
645
-
646
- ### Relation Targets
647
-
648
- ```ts
649
- f.relation("user"); // string (collection name)
650
- f.relation(() => users); // lazy reference (avoids circular imports)
651
- f.relation({ users: "users", teams: "teams" }); // polymorphic map
652
- ```
653
-
654
- ### Additional Config
655
-
656
- ```ts
657
- f.relation("user")
658
- .onDelete("cascade" | "set null" | "restrict" | "no action")
659
- .onUpdate("cascade" | ...)
660
- .relationName("postAuthor") // disambiguate multiple relations to same target
661
- ```
662
-
663
- ---
664
-
665
- ## 9. Hooks
666
-
667
- Hooks are lifecycle callbacks that run at specific points during CRUD operations.
668
-
669
- ### Execution Order
670
-
671
- ```
672
- CREATE: beforeOperation → beforeValidate → beforeChange → [DB INSERT] → afterChange → afterRead
673
- UPDATE: beforeOperation → beforeValidate → beforeChange → [DB UPDATE] → afterChange → afterRead
674
- DELETE: beforeOperation → beforeDelete → [DB DELETE] → afterDelete → afterRead
675
- READ: beforeOperation → beforeRead → [DB SELECT] → afterRead
676
- ```
677
-
678
- ### Collection Hooks
679
-
680
- ```ts
681
- collection("posts").hooks({
682
- // Runs before ANY operation — logging, rate limiting
683
- beforeOperation: async (ctx) => { ... },
684
-
685
- // Runs before validation on create/update — normalize input
686
- beforeValidate: async (ctx) => {
687
- ctx.data.email = ctx.data.email?.toLowerCase();
688
- return ctx.data;
689
- },
690
-
691
- // Runs after validation, before DB write — business logic, derived fields
692
- beforeChange: async (ctx) => {
693
- if (ctx.operation === "create") {
694
- ctx.data.slug = slugify(ctx.data.title);
695
- }
696
- return ctx.data;
697
- },
698
-
699
- // Runs after create/update — notifications, webhooks, side effects
700
- afterChange: async (ctx) => {
701
- // ctx.original is available on update (the record before changes)
702
- ctx.onAfterCommit(async () => {
703
- await ctx.email.send("post-updated", { to: ctx.data.authorEmail });
704
- });
705
- },
706
-
707
- // Runs before DB SELECT — modify query
708
- beforeRead: async (ctx) => { ... },
709
-
710
- // Runs after any operation that returns data
711
- afterRead: async (ctx) => {
712
- ctx.data.displayName = `${ctx.data.firstName} ${ctx.data.lastName}`;
713
- return ctx.data;
714
- },
715
-
716
- // Delete-specific
717
- beforeDelete: async (ctx) => { ... },
718
- afterDelete: async (ctx) => { ... },
719
-
720
- // Workflow transitions
721
- beforeTransition: async (ctx) => { ... },
722
- afterTransition: async (ctx) => { ... },
723
- })
724
- ```
725
-
726
- ### Hook Context
727
-
728
- Every hook receives `HookContext` which includes:
729
-
730
- ```ts
731
- {
732
- // AppContext (always available)
733
- db, session, collections, globals, queue, email, storage,
734
- kv, logger, search, realtime, t,
735
-
736
- // Hook-specific
737
- data, // the record being processed
738
- original, // previous version (update only)
739
- operation, // "create" | "update" | "delete" | "read"
740
- locale,
741
- accessMode, // "system" | "user"
742
- onAfterCommit(callback), // run after DB transaction commits
743
-
744
- // Batch operations
745
- isBatch, recordIds, records, count,
746
- }
747
- ```
748
-
749
- ### Important Rules
750
-
751
- - **`before*` hooks** — errors abort the operation (throw to reject)
752
- - **`afterDelete`** (single) and **`afterChange`** (batch update) — errors are caught and logged (non-fatal)
753
- - **`afterRead`** — errors **propagate** (fatal) — keep `afterRead` hooks safe
754
- - **`afterChange`** (single create/update) — collection-level errors propagate; use `onAfterCommit` for risky side effects
755
- - **Hooks can be arrays**: `.hooks({ afterChange: [hook1, hook2] })` — executed sequentially
756
- - Use **`onAfterCommit`** for side effects (emails, jobs) that should only fire if the DB write succeeds
757
- - **`deleteMany`** does NOT trigger `afterRead` — it returns `{ success, count }` directly
758
-
759
- ### Global Hooks
760
-
761
- Apply hooks to **all** collections at once via `config/app.ts`. Available hook names for global hooks: `beforeChange`, `afterChange`, `beforeDelete`, `afterDelete`, `beforeTransition`, `afterTransition`.
762
-
763
- ```ts
764
- appConfig({
765
- hooks: {
766
- collections: [
767
- {
768
- hook: "afterChange",
769
- include: ["posts", "pages"], // optional filter
770
- handler: async (ctx) => {
771
- /* audit log */
772
- },
773
- },
774
- ],
775
- },
776
- });
777
- ```
778
-
779
- ---
780
-
781
- ## 10. Access Control
782
-
783
- Access control determines **who can do what**. It's evaluated automatically on every API request.
784
-
785
- ### Collection-Level Access
786
-
787
- ```ts
788
- collection("posts").access({
789
- read: true, // public
790
- create: ({ session }) => !!session, // any authenticated user
791
- update: ({ session, data }) => ..., // function → boolean or filter
792
- delete: false, // nobody
793
- transition: ({ session }) => ..., // workflow transitions
794
- })
795
- ```
796
-
797
- ### Access Rule Types
798
-
799
- ```ts
800
- true // allow everyone
801
- false // deny everyone
802
- undefined // require authenticated session (DEFAULT)
803
- (ctx) => boolean // function returning allow/deny
804
- (ctx) => AccessWhere // function returning a WHERE filter
805
- ```
806
-
807
- **`AccessWhere`** is a SQL-like filter that gets merged into the query automatically:
808
-
809
- ```ts
810
- // "Users can only read their own posts"
811
- access: {
812
- read: ({ session }) => ({
813
- authorId: session.user.id, // simple equality
814
- }),
815
- // Complex filters:
816
- read: ({ session }) => ({
817
- OR: [
818
- { status: "published" },
819
- { authorId: session.user.id },
820
- ],
821
- }),
822
- }
823
- ```
824
-
825
- ### Field-Level Access
826
-
827
- Control individual fields:
828
-
829
- ```ts
830
- f.text(255).access({
831
- read: true,
832
- create: ({ session }) => session?.user.role === "admin",
833
- update: false, // immutable after creation
834
- });
835
-
836
- // Or at collection level:
837
- collection("users").access({
838
- fields: {
839
- email: { update: ({ session }) => session?.user.role === "admin" },
840
- password: { read: false },
841
- },
842
- });
843
- ```
844
-
845
- ### Default Behavior
846
-
847
- When no access rule is set, the default is **`!!session`** — require an authenticated session. This is secure-by-default.
848
-
849
- ### System Mode
850
-
851
- Server-side code (hooks, jobs, seeds) runs in **system mode** (`accessMode: "system"`) by default, which **bypasses all access control**. HTTP requests run in **user mode** (`accessMode: "user"`).
852
-
853
- ```ts
854
- // Force user mode in server code:
855
- await collections.posts.find({}, { accessMode: "user", session });
856
-
857
- // Force system mode explicitly (the default for server code):
858
- await collections.posts.find({}, { accessMode: "system" });
859
- ```
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
-
881
- ---
882
-
883
- ## 11. Routes
884
-
885
- Custom HTTP routes for APIs that don't fit the collection CRUD pattern.
886
-
887
- ```ts
888
- import { route } from "questpie/services";
889
- // JSON route with schema validation
890
- export default route()
891
- .post()
892
- .schema(
893
- z.object({
894
- name: z.string(),
895
- email: z.string().email(),
896
- }),
897
- )
898
- .outputSchema(
899
- z.object({
900
- success: z.boolean(),
901
- id: z.string(),
902
- }),
903
- )
904
- .access(({ session }) => !!session)
905
- .handler(async ({ input, db, collections, session }) => {
906
- const user = await collections.users.create({ ...input });
907
- return { success: true, id: user.id };
908
- });
909
- ```
910
-
911
- ### Route Methods
912
-
913
- ```ts
914
- route()
915
- .get() // HTTP method
916
- .post()
917
- .put()
918
- .delete()
919
- .patch()
920
- .raw() // raw Request/Response (no JSON parsing)
921
- .schema(zodSchema) // input validation
922
- .outputSchema(zodSchema) // response type (for OpenAPI)
923
- .access(rule) // access control
924
- .handler(fn); // terminal — returns RouteDefinition
925
- ```
926
-
927
- ### File-Path Routing
928
-
929
- Route file paths map to URL paths:
930
-
931
- ```
932
- routes/get-stats.ts → GET /api/get-stats
933
- routes/webhooks/stripe.ts → POST /api/webhooks/stripe
934
- routes/[collection].ts → /api/:collection (parameterized)
935
- routes/[...path].ts → /api/* (wildcard)
936
- ```
937
-
938
- ### Raw Routes
939
-
940
- For webhooks, file downloads, or custom responses:
941
-
942
- ```ts
943
- export default route()
944
- .post()
945
- .raw()
946
- .handler(async ({ request }) => {
947
- const body = await request.text();
948
- // ... verify webhook signature
949
- return new Response("OK", { status: 200 });
950
- });
951
- ```
952
-
953
- ### Built-in Routes (Auto-registered by Core Module)
954
-
955
- **Collection routes:**
956
-
957
- | Path | Method | Purpose |
958
- | ------------------------------ | ------ | --------------------------- |
959
- | `[collection]` | GET | Find (list with pagination) |
960
- | `[collection]` | POST | Create |
961
- | `[collection]` | PATCH | Update many |
962
- | `[collection]/[id]` | GET | Find one |
963
- | `[collection]/[id]` | PATCH | Update |
964
- | `[collection]/[id]` | DELETE | Delete |
965
- | `[collection]/count` | GET | Count |
966
- | `[collection]/delete-many` | POST | Delete many |
967
- | `[collection]/[id]/versions` | GET | List versions |
968
- | `[collection]/[id]/revert` | POST | Revert to version |
969
- | `[collection]/[id]/transition` | POST | Workflow transition |
970
- | `[collection]/[id]/restore` | POST | Restore soft-deleted |
971
- | `[collection]/[id]/audit` | GET | Audit log |
972
- | `[collection]/upload` | POST | File upload |
973
- | `[collection]/files/[...key]` | GET | Serve file |
974
- | `[collection]/schema` | GET | Introspected schema |
975
- | `[collection]/meta` | GET | Collection metadata |
976
-
977
- **Global routes:**
978
-
979
- | Path | Method | Purpose |
980
- | --------------------------- | ------ | ------------------- |
981
- | `globals/[name]` | GET | Get global |
982
- | `globals/[name]` | PATCH | Update global |
983
- | `globals/[name]/versions` | GET | List versions |
984
- | `globals/[name]/revert` | POST | Revert to version |
985
- | `globals/[name]/transition` | POST | Workflow transition |
986
- | `globals/[name]/schema` | GET | Introspected schema |
987
- | `globals/[name]/meta` | GET | Global metadata |
988
- | `globals/[name]/audit` | GET | Audit log |
989
-
990
- **System routes:**
991
-
992
- | Path | Method | Purpose |
993
- | ----------------------------- | ------ | ------------------- |
994
- | `auth/[...path]` | \* | Better Auth handler |
995
- | `search` | POST | Full-text search |
996
- | `search/reindex/[collection]` | POST | Reindex collection |
997
- | `realtime` | POST | SSE subscriptions |
998
- | `health` | GET | Health check |
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
-
1002
- ---
1003
-
1004
- ## 12. Services
1005
-
1006
- Services are **injectable singletons or request-scoped factories** available in `AppContext`.
1007
-
1008
- ```ts
1009
- import { service } from "questpie/services";
1010
- export const analyticsService = service()
1011
- .lifecycle("singleton") // created once at startup
1012
- .create(({ app }) => {
1013
- return new AnalyticsClient(app.config.analytics);
1014
- })
1015
- .dispose((client) => {
1016
- client.close();
1017
- });
1018
- ```
1019
-
1020
- ### Namespaces
1021
-
1022
- ```ts
1023
- // Top-level (ctx.analytics)
1024
- service().namespace(null).create(...)
1025
-
1026
- // Nested (ctx.services.analytics)
1027
- service().create(...) // default namespace: "services"
1028
-
1029
- // Custom namespace (ctx.myNamespace.analytics)
1030
- service().namespace("myNamespace").create(...)
1031
- ```
1032
-
1033
- ### Lifecycle
1034
-
1035
- | Lifecycle | When Created | When Disposed |
1036
- | ------------- | ------------------------- | ----------------------- |
1037
- | `"singleton"` | Once at app startup | At `app.destroy()` |
1038
- | `"request"` | Per incoming HTTP request | After request completes |
1039
-
1040
- ### Using Services in Hooks/Routes
1041
-
1042
- Services in the default namespace are on `ctx.services.*`. Top-level services (e.g., `db`, `auth`, `storage`) are directly on `ctx`:
1043
-
1044
- ```ts
1045
- // In a hook or route handler:
1046
- async ({ db, session, services }) => {
1047
- await services.analytics.track("page_view", { userId: session.user.id });
1048
- };
1049
- ```
1050
-
1051
- ---
1052
-
1053
- ## 13. Jobs
1054
-
1055
- Background jobs for async processing — retries, scheduling, and queuing.
1056
-
1057
- ```ts
1058
- import { job } from "questpie/services";
1059
- export default job({
1060
- name: "sendWelcomeEmail",
1061
- schema: z.object({
1062
- userId: z.string(),
1063
- locale: z.string().optional(),
1064
- }),
1065
- options: {
1066
- retryLimit: 3,
1067
- retryDelay: 5, // seconds (not ms!)
1068
- retryBackoff: true, // exponential
1069
- },
1070
- handler: async ({ payload, email, collections }) => {
1071
- const user = await collections.users.findOne({
1072
- where: { id: payload.userId },
1073
- });
1074
- await email.send("welcome", { to: user.email, data: { name: user.name } });
1075
- },
1076
- });
1077
- ```
1078
-
1079
- > **Note:** Job handlers receive `payload` (not `input`). Email handlers receive `input`.
1080
-
1081
- ### Dispatching Jobs
1082
-
1083
- ```ts
1084
- // In any hook, route, or service:
1085
- await ctx.queue.sendWelcomeEmail.publish({
1086
- userId: "abc-123",
1087
- locale: "en",
1088
- });
1089
- ```
1090
-
1091
- The queue client exposes jobs as typed properties: `queue[jobName].publish(payload, options?)`. This gives you full type safety on the payload.
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
-
1112
- ### Queue Adapters
1113
-
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 |
1119
-
1120
- ---
1121
-
1122
- ## 14. Email Templates
1123
-
1124
- Email templates define how emails look and what data they accept.
1125
-
1126
- ```tsx
1127
- import { email } from "questpie/services";
1128
- export default email({
1129
- name: "welcome",
1130
- schema: z.object({
1131
- name: z.string(),
1132
- verifyUrl: z.string().url(),
1133
- }),
1134
- handler: ({ input }) => ({
1135
- subject: `Welcome, ${input.name}!`,
1136
- html: `
1137
- <h1>Welcome to our platform</h1>
1138
- <p>Hi ${input.name}, please verify your email:</p>
1139
- <a href="${input.verifyUrl}">Verify Email</a>
1140
- `,
1141
- text: `Welcome ${input.name}! Verify: ${input.verifyUrl}`,
1142
- }),
1143
- });
1144
- ```
1145
-
1146
- ### Sending Emails
1147
-
1148
- ```ts
1149
- await ctx.email.send("welcome", {
1150
- to: user.email,
1151
- data: { name: user.name, verifyUrl: "https://..." },
1152
- });
1153
- ```
1154
-
1155
- ### Email Adapters
1156
-
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 |
1164
-
1165
- ---
1166
-
1167
- ## 15. Migrations & Seeds
1168
-
1169
- ### Migrations
1170
-
1171
- ```ts
1172
- import { migration } from "questpie/migration";
1173
- export default migration({
1174
- id: "0001_create_categories_table",
1175
- up: async ({ db }) => {
1176
- await db.execute(sql`
1177
- CREATE TABLE categories (
1178
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1179
- name varchar(255) NOT NULL
1180
- )
1181
- `);
1182
- },
1183
- down: async ({ db }) => {
1184
- await db.execute(sql`DROP TABLE categories`);
1185
- },
1186
- });
1187
- ```
1188
-
1189
- ### Seeds
1190
-
1191
- ```ts
1192
- import { seed } from "questpie/services";
1193
- export default seed({
1194
- id: "seed-default-categories",
1195
- category: "required", // "required" | "dev" | "test"
1196
- run: async ({ collections, log }) => {
1197
- log("Creating default categories...");
1198
- await collections.categories.create({ name: "Uncategorized" });
1199
- await collections.categories.create({ name: "News" });
1200
- },
1201
- undo: async ({ collections }) => {
1202
- await collections.categories.delete({
1203
- where: { name: { in: ["Uncategorized", "News"] } },
1204
- });
1205
- },
1206
- });
1207
- ```
1208
-
1209
- ### CLI Commands
1210
-
1211
- ```bash
1212
- bun questpie migrate # Run pending migrations
1213
- bun questpie migrate:generate # Generate migration from schema diff
1214
- bun questpie migrate:status # Show status
1215
- bun questpie migrate:down # Rollback
1216
- bun questpie migrate:reset # Rollback all
1217
- bun questpie migrate:fresh # Reset + re-run all
1218
-
1219
- bun questpie seed # Run pending seeds
1220
- bun questpie seed:status # Show status
1221
- bun questpie seed:undo # Undo seeds
1222
- bun questpie seed:reset # Reset tracking
1223
-
1224
- bun questpie push # Push schema directly (dev only, like drizzle-kit push)
1225
- ```
1226
-
1227
- ### Auto-Migration/Seed
1228
-
1229
- In `questpie.config.ts`:
1230
-
1231
- ```ts
1232
- runtimeConfig({
1233
- autoMigrate: true, // run migrations on app startup
1234
- autoSeed: true, // run seeds on startup
1235
- // autoSeed: "required", // or only specific categories ("required" | "dev" | "test")
1236
- });
1237
- ```
1238
-
1239
- ---
1240
-
1241
- ## 16. Uploads & Storage
1242
-
1243
- ### Upload Collections
1244
-
1245
- Any collection can become a file storage collection:
1246
-
1247
- ```ts
1248
- collection("assets")
1249
- .upload({
1250
- visibility: "public", // "public" | "private"
1251
- maxSize: 10_000_000, // 10MB
1252
- allowedTypes: ["image/*", "application/pdf"],
1253
- })
1254
- .fields(({ f }) => ({
1255
- alt: f.text(255),
1256
- caption: f.textarea(),
1257
- }));
1258
- ```
1259
-
1260
- `.upload()` automatically adds system fields: `key`, `filename`, `mimeType`, `size`, `visibility`, plus HTTP routes for upload and file serving.
1261
-
1262
- ### Upload Field
1263
-
1264
- Reference files from other collections:
1265
-
1266
- ```ts
1267
- f.upload(); // single file → FK to "assets"
1268
- f.upload({ to: "documents" }); // custom upload collection
1269
- f.upload().multiple(); // multiple files (JSONB array)
1270
- f.upload({
1271
- // many-to-many via junction
1272
- through: "post_images",
1273
- mimeTypes: ["image/*"],
1274
- maxSize: 5_000_000,
1275
- });
1276
- ```
1277
-
1278
- ### Storage Adapters
1279
-
1280
- ```ts
1281
- import { fs } from "files-sdk/fs";
1282
- import { r2 } from "files-sdk/r2";
1283
- import { s3 } from "files-sdk/s3";
1284
-
1285
- // Local filesystem (default)
1286
- runtimeConfig({
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
- },
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
- },
1316
- });
1317
-
1318
- // Auto-detected from env vars:
1319
- // QUESTPIE_STORAGE_ENDPOINT, QUESTPIE_STORAGE_BUCKET, QUESTPIE_STORAGE_REGION,
1320
- // QUESTPIE_STORAGE_ACCESS_KEY, QUESTPIE_STORAGE_SECRET_KEY
1321
- ```
1322
-
1323
- ### Programmatic Upload
1324
-
1325
- ```ts
1326
- const file = await collections.assets.upload(
1327
- { stream: readableStream, filename: "photo.jpg", mimeType: "image/jpeg" },
1328
- { accessMode: "system" },
1329
- { alt: "A nice photo" }, // additional field data
1330
- );
1331
- ```
1332
-
1333
- ---
1334
-
1335
- ## 17. Versioning & Workflow
1336
-
1337
- ### Enabling Versioning
1338
-
1339
- ```ts
1340
- collection("pages").options({
1341
- versioning: true, // basic versioning (max 50 versions)
1342
- // or:
1343
- versioning: {
1344
- maxVersions: 100,
1345
- workflow: true, // enables stage transitions
1346
- },
1347
- // or with custom stages:
1348
- versioning: {
1349
- workflow: {
1350
- initialStage: "draft",
1351
- stages: {
1352
- draft: { transitions: ["review"] },
1353
- review: { transitions: ["published", "draft"] },
1354
- published: { transitions: ["draft"] },
1355
- },
1356
- },
1357
- },
1358
- });
1359
- ```
1360
-
1361
- ### What Versioning Gives You
1362
-
1363
- - A `{collection}_versions` table stores every change
1364
- - `findVersions({ id })` → list all versions of a record
1365
- - `revertToVersion({ id, version: 3 })` → restore a previous version
1366
- - `transitionStage({ id, stage: "published" })` → move between workflow stages
1367
- - `beforeTransition` / `afterTransition` hooks
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
-
1371
- ### API Usage
1372
-
1373
- ```ts
1374
- // Server-side
1375
- const versions = await collections.pages.findVersions({ id: "abc" });
1376
- await collections.pages.revertToVersion({ id: "abc", version: 5 });
1377
- await collections.pages.transitionStage({ id: "abc", stage: "published" });
1378
-
1379
- // Client-side
1380
- const versions = await client.collections.pages.findVersions({ id: "abc" });
1381
- await client.collections.pages.transitionStage({
1382
- id: "abc",
1383
- stage: "published",
1384
- });
1385
- ```
1386
-
1387
- ---
1388
-
1389
- ## 18. Search
1390
-
1391
- Full-text search across collections.
1392
-
1393
- ### Configuring Search
1394
-
1395
- ```ts
1396
- collection("posts").searchable({
1397
- content: (record) => record.title,
1398
- // or multiple fields:
1399
- content: (record) => `${record.title} ${record.excerpt}`,
1400
- });
1401
- ```
1402
-
1403
- ### Search Adapters
1404
-
1405
- | Adapter | Description |
1406
- | ----------------------- | ------------------------------------ |
1407
- | `PostgresSearchAdapter` | pg_trgm + FTS (default, zero-config) |
1408
- | `PgVectorSearchAdapter` | Hybrid semantic search with pgvector |
1409
-
1410
- ### API Usage
1411
-
1412
- ```ts
1413
- // Client-side
1414
- const results = await client.search.search({
1415
- query: "typescript guide",
1416
- collections: ["posts", "pages"],
1417
- limit: 10,
1418
- });
1419
-
1420
- // Server-side
1421
- const results = await ctx.search.search({
1422
- query: "typescript guide",
1423
- collections: ["posts"],
1424
- });
1425
- ```
1426
-
1427
- ### Reindexing
1428
-
1429
- ```ts
1430
- // Via API
1431
- await client.collections.posts.reindex();
1432
-
1433
- // Via job
1434
- await ctx.queue.indexRecords.publish({ collection: "posts" });
1435
- ```
1436
-
1437
- ---
1438
-
1439
- ## 19. Realtime
1440
-
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.
1442
-
1443
- ### How It Works
1444
-
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.
1450
-
1451
- ### Client-Side Usage
1452
-
1453
- ```ts
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
- );
1458
-
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
1482
- );
1483
- ```
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
-
1507
- ### Realtime Adapters
1508
-
1509
- ```ts
1510
- import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
1511
-
1512
- runtimeConfig({
1513
- realtime: {
1514
- adapter: pgNotifyAdapter({ connectionString: process.env.DATABASE_URL }),
1515
- },
1516
- });
1517
- ```
1518
-
1519
- | Adapter | Description |
1520
- | ----------------------- | ---------------------------- |
1521
- | _None_ (default) | Polling-based (every 2s) |
1522
- | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
1523
- | `redisStreamsAdapter()` | Redis Streams consumer group |
1524
-
1525
- ---
1526
-
1527
- ## 20. i18n / Localization
1528
-
1529
- ### Content Localization
1530
-
1531
- Mark fields as localized — stored per-locale in a separate i18n table:
1532
-
1533
- ```ts
1534
- f.text(255).localized(); // stored in {collection}_i18n table
1535
- ```
1536
-
1537
- ### Configuring Locales
1538
-
1539
- ```ts
1540
- // config/app.ts
1541
- appConfig({
1542
- locale: {
1543
- locales: ["en", "sk", "de"],
1544
- defaultLocale: "en",
1545
- fallback: { sk: "en", de: "en" },
1546
- },
1547
- });
1548
- ```
1549
-
1550
- ### Querying Localized Content
1551
-
1552
- ```ts
1553
- // Server
1554
- const post = await collections.posts.findOne({
1555
- where: { id: "abc" },
1556
- locale: "sk",
1557
- localeFallback: true, // fall back to "en" if "sk" not available
1558
- });
1559
-
1560
- // Client
1561
- const post = await client.collections.posts.findOne({
1562
- where: { id: "abc" },
1563
- locale: "sk",
1564
- });
1565
- ```
1566
-
1567
- ### Admin UI Translations
1568
-
1569
- ```ts
1570
- // config/admin.ts
1571
- adminConfig({
1572
- locale: {
1573
- locales: ["en", "sk"],
1574
- defaultLocale: "en",
1575
- },
1576
- });
1577
-
1578
- // messages/en.ts — admin UI string translations
1579
- export default {
1580
- "admin.posts.title": "Blog Posts",
1581
- "admin.posts.description": "Manage blog posts",
1582
- };
1583
- ```
1584
-
1585
- ### Translation Function
1586
-
1587
- Available in all hooks, routes, and services as `ctx.t(key, params?, locale?)`:
1588
-
1589
- ```ts
1590
- const msg = ctx.t("billing.invoice_sent", { amount: 100 }, "en");
1591
- ```
1592
-
1593
- ---
1594
-
1595
- ## 21. AppContext — What's Available Everywhere
1596
-
1597
- Every hook, route handler, job handler, and service receives `AppContext`. This is the **core runtime interface** — learn it once, use it everywhere.
1598
-
1599
- ```ts
1600
- interface AppContext {
1601
- app: Questpie; // the app instance
1602
- db: DrizzleClient; // Drizzle ORM (may be a transaction in hooks)
1603
- session: { user; session } | null; // current auth session
1604
- collections: { [name]: CollectionAPI }; // typed CRUD for all collections
1605
- globals: { [name]: GlobalAPI }; // typed CRUD for all globals
1606
- queue: QueueClient; // dispatch background jobs
1607
- email: MailerService; // send emails
1608
- storage: Files; // direct typed Files SDK storage operations
1609
- kv: KVService; // key-value store
1610
- logger: LoggerService; // structured logging
1611
- search: SearchService; // full-text search
1612
- realtime: RealtimeService; // server-side change-event subscription (broadcasts are automatic)
1613
- t: (key, params?, locale?) => string; // i18n translator
1614
- services: Record<string, unknown>; // user-defined services
1615
- }
1616
- ```
1617
-
1618
- ### Where AppContext Is Available
1619
-
1620
- | Context | How to Access |
1621
- | ---------------- | ---------------------------------------------------------------- |
1622
- | Collection hooks | First argument: `async (ctx) => { ... }` |
1623
- | Route handlers | Destructure: `async ({ db, session, collections }) => { ... }` |
1624
- | Job handlers | Destructure: `async ({ payload, queue, email }) => { ... }` |
1625
- | Email templates | Destructure: `async ({ input, collections }) => { ... }` |
1626
- | Access rules | Destructure: `({ session, data }) => boolean` |
1627
- | Seeds | `async ({ collections, log }) => { ... }` |
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`) |
1630
-
1631
- ### Getting Context Programmatically
1632
-
1633
- ```ts
1634
- import { getContext, tryGetContext } from "questpie/types";
1635
- import type { App } from "#questpie"; // type-only — no runtime cycle
1636
-
1637
- const ctx = getContext<App>(); // typed app/session/extensions; throws outside a request scope
1638
- const maybe = tryGetContext(); // returns null if outside scope
1639
-
1640
- // Create a fresh context manually:
1641
- const fresh = await app.createContext({
1642
- session: null,
1643
- locale: "en",
1644
- accessMode: "system",
1645
- });
1646
- ```
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
-
1655
- ---
1656
-
1657
- ## 22. Server-Side CRUD API
1658
-
1659
- Access collections and globals programmatically from anywhere in server code.
1660
-
1661
- ### Collections
1662
-
1663
- ```ts
1664
- // via AppContext (in hooks, routes, jobs)
1665
- const posts = await ctx.collections.posts.find({
1666
- where: { status: "published" },
1667
- orderBy: { createdAt: "desc" },
1668
- limit: 10,
1669
- with: { author: true, tags: true },
1670
- });
1671
-
1672
- // via app instance (in startup scripts, CLI)
1673
- const post = await app.collections.posts.findOne({
1674
- where: { slug: "hello-world" },
1675
- columns: { title: true, content: true },
1676
- });
1677
- ```
1678
-
1679
- ### Collection Methods
1680
-
1681
- ```ts
1682
- // READ
1683
- .find(options?) → PaginatedResult<T>
1684
- .findOne(options?) → T | null
1685
- .count(options?) → number
1686
-
1687
- // WRITE
1688
- .create(data) → T
1689
- .updateById({ id, data }) → T
1690
- .updateMany({ where, data }) → T[] (batch; deprecated alias: update)
1691
- .updateBatch({ updates }) → T[] (per-record batch)
1692
- .deleteById({ id }) → { success }
1693
- .deleteMany({ where }) → { success, count } (batch; deprecated alias: delete)
1694
- .restoreById({ id }) → T (soft-delete)
1695
-
1696
- // VERSIONING
1697
- .findVersions({ id }) → VersionRecord[]
1698
- .revertToVersion({ id, version }) → T
1699
- .transitionStage({ id, stage }) → T
1700
-
1701
- // UPLOAD (when .upload() is configured)
1702
- .upload(file, ctx?, additionalData?) → T
1703
- .uploadMany(files, ctx?, additionalData?) → T[]
1704
- ```
1705
-
1706
- ### FindOptions
1707
-
1708
- ```ts
1709
- {
1710
- where: {
1711
- status: "published", // equality
1712
- title: { like: "%guide%" }, // LIKE
1713
- createdAt: { gte: new Date() }, // comparison operators
1714
- OR: [{ a: 1 }, { b: 2 }], // logical
1715
- AND: [...],
1716
- NOT: { ... },
1717
- },
1718
- columns: { title: true, content: true }, // select specific fields
1719
- with: { // include relations
1720
- author: true,
1721
- tags: { columns: { name: true } },
1722
- },
1723
- orderBy: { createdAt: "desc" }, // or multi-field: [{ status: "desc" }, { createdAt: "desc" }]
1724
- limit: 10,
1725
- offset: 0,
1726
- search: "keyword", // full-text ILIKE on title
1727
- locale: "en",
1728
- localeFallback: true,
1729
- includeDeleted: false, // include soft-deleted
1730
- stage: "published", // versioning stage filter
1731
- extras: { wordCount: sql`...` }, // computed columns
1732
- }
1733
- ```
1734
-
1735
- ### PaginatedResult
1736
-
1737
- ```ts
1738
- {
1739
- docs: T[],
1740
- totalDocs: number,
1741
- limit: number,
1742
- totalPages: number,
1743
- page: number,
1744
- pagingCounter: number,
1745
- hasPrevPage: boolean,
1746
- hasNextPage: boolean,
1747
- prevPage: number | null,
1748
- nextPage: number | null,
1749
- }
1750
- ```
1751
-
1752
- ### Globals
1753
-
1754
- ```ts
1755
- const settings = await ctx.globals.siteSettings.get({
1756
- with: { logo: true },
1757
- locale: "en",
1758
- });
1759
-
1760
- await ctx.globals.siteSettings.update({
1761
- siteName: "New Name",
1762
- description: "Updated description",
1763
- });
1764
- ```
1765
-
1766
- ### Global Methods
1767
-
1768
- ```ts
1769
- .get(options?) → T | null
1770
- .update(data, ctx?, options?) → T
1771
- .findVersions(options?) → VersionRecord[]
1772
- .revertToVersion({ version }) → T
1773
- .transitionStage({ stage }) → T
1774
- ```
1775
-
1776
- ### Request Context
1777
-
1778
- All CRUD methods accept an optional second argument for context:
1779
-
1780
- ```ts
1781
- await collections.posts.find(
1782
- { where: { status: "published" } },
1783
- {
1784
- session: mySession, // override session
1785
- locale: "sk", // override locale
1786
- accessMode: "user", // force access checks (default: "system")
1787
- stage: "published", // version stage filter
1788
- db: transactionClient, // run inside a transaction
1789
- },
1790
- );
1791
- ```
1792
-
1793
- ---
1794
-
1795
- ## 23. Client SDK
1796
-
1797
- The typed client for calling the QuestPie API from frontend or external code.
1798
-
1799
- ### Setup
1800
-
1801
- ```ts
1802
- import { createClient } from "questpie/client";
1803
- import type { App } from "@/questpie/server/app";
1804
-
1805
- export const client = createClient<App>({
1806
- baseURL: "http://localhost:3000",
1807
- basePath: "/api", // matches your catch-all route
1808
- });
1809
- ```
1810
-
1811
- ### Collections
1812
-
1813
- ```ts
1814
- // Read
1815
- const posts = await client.collections.posts.find({
1816
- where: { status: "published" },
1817
- limit: 10,
1818
- with: { author: true },
1819
- });
1820
-
1821
- const post = await client.collections.posts.findOne({
1822
- where: { id: "abc" },
1823
- });
1824
-
1825
- const count = await client.collections.posts.count({
1826
- where: { status: "draft" },
1827
- });
1828
-
1829
- // Write
1830
- const newPost = await client.collections.posts.create({
1831
- title: "Hello",
1832
- content: "...",
1833
- });
1834
-
1835
- const updated = await client.collections.posts.update({
1836
- id: "abc",
1837
- data: { title: "Updated" },
1838
- });
1839
-
1840
- await client.collections.posts.delete({ id: "abc" });
1841
-
1842
- // Upload
1843
- const asset = await client.collections.assets.upload(fileObject);
1844
-
1845
- // Versioning
1846
- const versions = await client.collections.posts.findVersions({ id: "abc" });
1847
- await client.collections.posts.revertToVersion({ id: "abc", version: 3 });
1848
- await client.collections.posts.transitionStage({
1849
- id: "abc",
1850
- stage: "published",
1851
- });
1852
-
1853
- // Schema introspection
1854
- const schema = await client.collections.posts.schema();
1855
- ```
1856
-
1857
- ### Globals
1858
-
1859
- ```ts
1860
- const settings = await client.globals.siteSettings.get();
1861
- await client.globals.siteSettings.update({ siteName: "New Name" });
1862
- ```
1863
-
1864
- ### Custom Routes
1865
-
1866
- ```ts
1867
- // Route: routes/admin/stats.ts → key "admin/stats"
1868
- const stats = await client.routes.admin.stats({ period: "week" });
1869
- ```
1870
-
1871
- ### Search
1872
-
1873
- ```ts
1874
- const results = await client.search.search({
1875
- query: "typescript",
1876
- collections: ["posts"],
1877
- limit: 10,
1878
- });
1879
- ```
1880
-
1881
- ### Locale
1882
-
1883
- ```ts
1884
- client.setLocale("sk"); // set for all subsequent requests
1885
- const locale = client.getLocale();
1886
- ```
1887
-
1888
- ### Error Handling
1889
-
1890
- ```ts
1891
- import { QuestpieClientError } from "questpie/client";
1892
-
1893
- try {
1894
- await client.collections.posts.create({ title: "" });
1895
- } catch (err) {
1896
- if (err instanceof QuestpieClientError) {
1897
- err.status; // HTTP status
1898
- err.code; // error code
1899
- err.fieldErrors; // per-field validation errors
1900
- err.getFieldError("title"); // specific field error
1901
- err.isCode("VALIDATION_ERROR"); // check error type
1902
- }
1903
- }
1904
- ```
1905
-
1906
- ---
1907
-
1908
- ## 24. TanStack Query Integration
1909
-
1910
- Pre-built query/mutation options for `@tanstack/react-query`.
1911
-
1912
- ### Setup
1913
-
1914
- ```ts
1915
- import { createQuestpieQueryOptions } from "@questpie/tanstack-query";
1916
- import { client } from "@/lib/client";
1917
- import type { App } from "@/questpie/server/app";
1918
-
1919
- export const qp = createQuestpieQueryOptions<App>(client, {
1920
- keyPrefix: ["questpie"],
1921
- });
1922
- ```
1923
-
1924
- ### Queries
1925
-
1926
- ```ts
1927
- import { useQuery, useMutation } from "@tanstack/react-query";
1928
-
1929
- // List
1930
- const { data: posts } = useQuery(
1931
- qp.collections.posts.find({
1932
- where: { status: "published" },
1933
- limit: 10,
1934
- }),
1935
- );
1936
-
1937
- // Single
1938
- const { data: post } = useQuery(
1939
- qp.collections.posts.findOne({ where: { id: postId } }),
1940
- );
1941
-
1942
- // Count
1943
- const { data: count } = useQuery(
1944
- qp.collections.posts.count({ where: { status: "draft" } }),
1945
- );
1946
-
1947
- // Global
1948
- const { data: settings } = useQuery(qp.globals.siteSettings.get());
1949
-
1950
- // With realtime (auto-refetches on server changes)
1951
- const { data: posts } = useQuery(
1952
- qp.collections.posts.find({}, { realtime: true }),
1953
- );
1954
- ```
1955
-
1956
- ### Mutations
1957
-
1958
- ```ts
1959
- const createPost = useMutation(qp.collections.posts.create());
1960
- const updatePost = useMutation(qp.collections.posts.update());
1961
- const deletePost = useMutation(qp.collections.posts.delete());
1962
-
1963
- // Usage
1964
- createPost.mutate({ title: "New Post", content: "..." });
1965
- updatePost.mutate({ id: "abc", data: { title: "Updated" } });
1966
- deletePost.mutate({ id: "abc" });
1967
- ```
1968
-
1969
- ### Custom Routes
1970
-
1971
- ```ts
1972
- // Query (GET routes)
1973
- const { data } = useQuery(qp.routes.admin.stats.query({ period: "week" }));
1974
-
1975
- // Mutation (POST/PATCH/DELETE routes)
1976
- const action = useMutation(qp.routes.webhooks.stripe.mutation());
1977
- ```
1978
-
1979
- ### Query Keys
1980
-
1981
- Consistent key structure for cache invalidation:
1982
-
1983
- ```ts
1984
- ["questpie", "collections", "posts", "find", locale, stage, options][
1985
- ("questpie", "globals", "siteSettings", "get", locale, stage, options)
1986
- ][("questpie", "routes", "admin", "stats", "query", locale, options)];
1987
- ```
1988
-
1989
- ---
1990
-
1991
- ## 25. Admin Panel
1992
-
1993
- The admin panel is a **server-driven React SPA**. The server declares what should be shown (via `.admin()`, `.list()`, `.form()`), and the client renders it.
1994
-
1995
- ### Enabling the Admin
1996
-
1997
- ```ts
1998
- // modules.ts
1999
- import { adminModule } from "@questpie/admin/modules/admin";
2000
- export default [adminModule] as const;
2001
-
2002
- // config/admin.ts
2003
- import { adminConfig } from "#questpie/factories";
2004
-
2005
- export default adminConfig({
2006
- branding: {
2007
- name: "My CMS",
2008
- },
2009
- locale: {
2010
- locales: ["en", "sk"],
2011
- defaultLocale: "en",
2012
- },
2013
- sidebar: ({ s, c }) => ({
2014
- sections: [
2015
- s.section({
2016
- id: "content",
2017
- title: "Content",
2018
- icon: c.icon({ name: "ph:article" }),
2019
- }),
2020
- s.section({
2021
- id: "settings",
2022
- title: "Settings",
2023
- icon: c.icon({ name: "ph:gear" }),
2024
- }),
2025
- ],
2026
- items: [
2027
- s.item({ sectionId: "content", type: "collection", collection: "posts" }),
2028
- s.item({ sectionId: "content", type: "collection", collection: "pages" }),
2029
- s.item({
2030
- sectionId: "settings",
2031
- type: "global",
2032
- global: "site_settings",
2033
- }),
2034
- s.item({
2035
- sectionId: "settings",
2036
- type: "link",
2037
- label: "API Docs",
2038
- href: "/api/docs",
2039
- external: true,
2040
- }),
2041
- s.item({ sectionId: "settings", type: "divider" }),
2042
- ],
2043
- }),
2044
- dashboard: ({ d, c, a }) => ({
2045
- title: "Dashboard",
2046
- sections: [
2047
- d.section({ id: "overview", label: "Overview", columns: 4 }),
2048
- d.section({ id: "recent", label: "Recent Activity", columns: 2 }),
2049
- ],
2050
- items: [
2051
- d.stats({
2052
- sectionId: "overview",
2053
- id: "total-posts",
2054
- label: "Total Posts",
2055
- collection: "posts",
2056
- span: 1,
2057
- }),
2058
- d.recentItems({
2059
- sectionId: "recent",
2060
- id: "recent-posts",
2061
- label: "Recent Posts",
2062
- collection: "posts",
2063
- dateField: "createdAt",
2064
- limit: 5,
2065
- span: 1,
2066
- }),
2067
- ],
2068
- actions: [a.create({ collection: "posts", label: "New Post" })],
2069
- }),
2070
- });
2071
- ```
2072
-
2073
- ### AdminState
2074
-
2075
- The admin client receives a pre-built `AdminState` object containing all registered fields, views, pages, widgets, blocks, components, and translations. This is generated at `.generated/client.ts` by codegen.
2076
-
2077
- ### How the Admin Renders a Collection
2078
-
2079
- 1. Client navigates to `/admin/collections/posts`
2080
- 2. Fetches `GET /api/posts/schema` → `CollectionSchema` (fields, access, options, admin config)
2081
- 3. Looks up the configured **list view** (`collection-table`) from AdminState
2082
- 4. Renders the view component with schema-driven columns, filters, actions
2083
- 5. On row click → navigates to `/admin/collections/posts/{id}`
2084
- 6. Fetches `GET /api/posts/{id}` + schema
2085
- 7. Looks up the configured **form view** (`collection-form`)
2086
- 8. Renders form fields using the field registry (each field type maps to a React component)
2087
-
2088
- ---
2089
-
2090
- ## 26. Admin Builder Extensions
2091
-
2092
- These methods are added to `CollectionBuilder`, `GlobalBuilder`, and `Field` by the admin plugin's codegen.
2093
-
2094
- ### `.admin()` — Collection/Global Metadata
2095
-
2096
- ```ts
2097
- collection("posts").admin(({ c }) => ({
2098
- label: "Blog Posts", // display name
2099
- description: "Manage blog posts", // subtitle
2100
- icon: c.icon({ name: "ph:article" }),
2101
- hidden: false, // hide from sidebar
2102
- group: "content", // sidebar group key
2103
- order: 1, // order within group
2104
- audit: true, // enable audit logging
2105
- }));
2106
- ```
2107
-
2108
- ### `.list()` — List View Config
2109
-
2110
- ```ts
2111
- collection("posts").list(({ v, f, a }) =>
2112
- v.collectionTable({
2113
- columns: [f.title, f.status, f.author, f.createdAt],
2114
- defaultSort: { field: f.createdAt, direction: "desc" },
2115
- searchable: [f.title, f.content],
2116
- filterable: [f.status, f.author],
2117
- actions: {
2118
- header: { primary: [a.create()], secondary: [a.custom("export")] },
2119
- row: [a.delete(), a.duplicate()],
2120
- bulk: [a.deleteMany(), a.custom("bulkPublish")],
2121
- },
2122
- }),
2123
- );
2124
- ```
2125
-
2126
- ### `.form()` — Form View Config
2127
-
2128
- ```ts
2129
- collection("posts").form(({ v, f }) =>
2130
- v.collectionForm({
2131
- fields: [
2132
- f.title,
2133
- f.slug,
2134
- {
2135
- type: "section",
2136
- label: "Content",
2137
- layout: "stack",
2138
- fields: [f.content, f.excerpt],
2139
- },
2140
- {
2141
- type: "tabs",
2142
- tabs: [
2143
- {
2144
- id: "media",
2145
- label: "Media",
2146
- icon: { type: "icon", props: { name: "ph:image" } },
2147
- fields: [f.cover, f.gallery],
2148
- },
2149
- { id: "seo", label: "SEO", fields: [f.metaTitle, f.metaDescription] },
2150
- ],
2151
- },
2152
- ],
2153
- sidebar: {
2154
- position: "right",
2155
- fields: [f.status, f.author, f.tags, f.publishedAt],
2156
- },
2157
- }),
2158
- );
2159
- ```
2160
-
2161
- #### Form Layout Primitives
2162
-
2163
- | Type | Description |
2164
- | ---------------------------------------------------------------- | ---------------------------------------- |
2165
- | `string` (e.g. `f.title`) | Bare field — renders with default config |
2166
- | `{ field, hidden?, readOnly?, disabled?, compute?, className? }` | Field with overrides |
2167
- | `{ type: "section", label?, layout?, columns?, fields: [...] }` | Visual grouping |
2168
- | `{ type: "tabs", tabs: [{ id, label, icon?, fields }] }` | Tabbed layout |
2169
-
2170
- #### Reactive Field Config
2171
-
2172
- Fields can be dynamically hidden, read-only, disabled, or computed based on other field values:
2173
-
2174
- ```ts
2175
- {
2176
- field: f.publishedAt,
2177
- hidden: ({ data }) => data.status !== "published",
2178
- readOnly: { deps: [f.status], handler: ({ data }) => data.status === "archived" },
2179
- compute: {
2180
- deps: [f.firstName, f.lastName],
2181
- handler: ({ data }) => `${data.firstName} ${data.lastName}`,
2182
- debounce: 300,
2183
- },
2184
- }
2185
- ```
2186
-
2187
- ### `.preview()` — Live Preview
2188
-
2189
- ```ts
2190
- collection("posts").preview({
2191
- enabled: true,
2192
- url: ({ record, locale }) => `/blog/${record.slug}?locale=${locale}`,
2193
- position: "right", // "left" | "right" | "bottom"
2194
- defaultWidth: 50, // percentage
2195
- });
2196
- ```
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
-
2202
- ### `.actions()` — Server Actions
2203
-
2204
- ```ts
2205
- collection("posts").actions(({ a, c, f }) => ({
2206
- builtin: [a.create(), a.save(), a.delete()],
2207
- custom: [
2208
- a.action({
2209
- id: "publish",
2210
- label: "Publish",
2211
- icon: c.icon({ name: "ph:rocket" }),
2212
- scope: "single", // "single" | "bulk" | "header" | "row"
2213
- confirmation: {
2214
- title: "Publish this post?",
2215
- destructive: false,
2216
- },
2217
- handler: async ({ record, collections }) => {
2218
- await collections.posts.transitionStage({
2219
- id: record.id,
2220
- stage: "published",
2221
- });
2222
- return { type: "success", toast: { message: "Published!" } };
2223
- },
2224
- }),
2225
- a.bulkAction({
2226
- id: "bulkArchive",
2227
- label: "Archive Selected",
2228
- form: {
2229
- title: "Archive Posts",
2230
- fields: {
2231
- reason: f.textarea().label("Reason").required(),
2232
- },
2233
- },
2234
- handler: async ({ records, formData, collections }) => {
2235
- for (const record of records) {
2236
- await collections.posts.updateById({
2237
- id: record.id,
2238
- data: { status: "archived", archiveReason: formData.reason },
2239
- });
2240
- }
2241
- return {
2242
- type: "success",
2243
- toast: { message: `Archived ${records.length} posts` },
2244
- };
2245
- },
2246
- }),
2247
- ],
2248
- }));
2249
- ```
2250
-
2251
- #### Action Result Types
2252
-
2253
- ```ts
2254
- { type: "success", toast?: { message, title? }, effects?: { closeModal?, invalidate?, redirect? } }
2255
- { type: "error", toast?: { message, title? }, errors?: Record<string, string> }
2256
- { type: "redirect", url: string, external?: boolean }
2257
- { type: "download", file: { name, content, mimeType } }
2258
- ```
2259
-
2260
- ### Field-Level `.admin()` and `.form()`
2261
-
2262
- ```ts
2263
- f.text().admin({
2264
- type: "password", // override the input type
2265
- autoComplete: "new-password",
2266
- placeholder: "Enter password",
2267
- })
2268
-
2269
- f.object({ ... }).form(({ f }) => ({
2270
- fields: [
2271
- { type: "section", label: "Address", layout: "grid", columns: 2,
2272
- fields: [f.street, f.city, f.zip, f.country] },
2273
- ],
2274
- }))
2275
- ```
2276
-
2277
- ---
2278
-
2279
- ## 27. Admin Fields, Views, Widgets, Blocks, Components
2280
-
2281
- ### Fields (Client-Side)
2282
-
2283
- Each server field type maps to a React component in the admin:
2284
-
2285
- | Field Type | Component | Cell (in tables) |
2286
- | -------------- | ------------------- | ---------------- |
2287
- | `text` | `TextField` | `TextCell` |
2288
- | `textarea` | `TextareaField` | `TextCell` |
2289
- | `number` | `NumberField` | primitive |
2290
- | `email` | `EmailField` | primitive |
2291
- | `url` | `UrlField` | primitive |
2292
- | `boolean` | `BooleanField` | primitive |
2293
- | `date` | `DateField` | primitive |
2294
- | `datetime` | `DatetimeField` | primitive |
2295
- | `time` | `TimeField` | primitive |
2296
- | `select` | `SelectField` | primitive |
2297
- | `relation` | `RelationField` | `RelationCell` |
2298
- | `upload` | `UploadField` | `UploadCell` |
2299
- | `richText` | `RichTextField` | — |
2300
- | `json` | `JsonField` | — |
2301
- | `object` | `ObjectField` | `ObjectCell` |
2302
- | `array` | `ArrayField` | `ArrayCell` |
2303
- | `blocks` | `BlocksField` | `BlocksCell` |
2304
- | `assetPreview` | `AssetPreviewField` | — |
2305
-
2306
- Register custom field types:
2307
-
2308
- ```ts
2309
- import { field } from "@questpie/admin/client";
2310
-
2311
- export const colorPicker = field("color", {
2312
- component: lazy(() => import("./color-picker-field")),
2313
- cell: lazy(() => import("./color-picker-cell")),
2314
- });
2315
- ```
2316
-
2317
- ### Views
2318
-
2319
- | View | Kind | Description |
2320
- | ------------------ | ------ | ---------------------------------------- |
2321
- | `collection-table` | `list` | Default table view for collection lists |
2322
- | `collection-form` | `form` | Default form view for collection editing |
2323
- | `global-form` | `form` | Default form view for globals |
2324
-
2325
- Register custom views:
2326
-
2327
- ```ts
2328
- import { view } from "@questpie/admin/client";
2329
-
2330
- export const kanbanView = view("kanban-board", {
2331
- kind: "list",
2332
- component: lazy(() => import("./kanban-view")),
2333
- });
2334
- ```
2335
-
2336
- ### Widgets (Dashboard)
2337
-
2338
- 8 built-in widget types (use via `d.stats()`, `d.chart()`, etc. in dashboard config):
2339
-
2340
- | Widget Type | Dashboard Builder | Description |
2341
- | -------------- | ----------------------- | ----------------------------------------------- |
2342
- | `stats` | `d.stats({...})` | Single number with optional trend indicator |
2343
- | `value` | `d.value({...})` | Custom formatted value with subtitle/footer |
2344
- | `chart` | `d.chart({...})` | Line, bar, area, or pie chart |
2345
- | `recentItems` | `d.recentItems({...})` | List of recent records from a collection |
2346
- | `quickActions` | `d.quickActions({...})` | Grid of action buttons |
2347
- | `table` | `d.table({...})` | Mini data table |
2348
- | `timeline` | `d.timeline({...})` | Chronological event list |
2349
- | `progress` | `d.progress({...})` | Progress bar toward a target |
2350
- | `custom` | `d.custom({...})` | Fully custom widget component (user-registered) |
2351
-
2352
- ### Blocks (Content Builder)
2353
-
2354
- Blocks are user-defined content components for the page builder (`f.blocks()` field). Defined on the server, rendered on both admin and frontend.
2355
-
2356
- ```ts
2357
- // Server: blocks/hero.ts
2358
- import { block } from "#questpie/factories";
2359
-
2360
- export const hero = block("hero")
2361
- .fields(({ f }) => ({
2362
- heading: f.text(255).required(),
2363
- subheading: f.textarea(),
2364
- image: f.upload(),
2365
- cta: f.object({
2366
- label: f.text(100),
2367
- url: f.url(),
2368
- }),
2369
- }))
2370
- .admin(({ c }) => ({
2371
- label: "Hero Banner",
2372
- icon: c.icon({ name: "ph:image" }),
2373
- category: { label: "Sections" },
2374
- }))
2375
- .form(({ f }) => ({
2376
- fields: [f.heading, f.subheading, f.image, f.cta],
2377
- }))
2378
- .allowChildren(0)
2379
- .prefetch(async ({ values, db }) => {
2380
- // Pre-load data for frontend rendering
2381
- return { imageUrl: values.image ? await getSignedUrl(values.image) : null };
2382
- });
2383
- ```
2384
-
2385
- ```tsx
2386
- // Client: admin/blocks/hero.tsx
2387
- export default function HeroBlock({ values, data }: BlockRendererProps) {
2388
- return (
2389
- <section className="hero">
2390
- <h1>{values.heading}</h1>
2391
- <p>{values.subheading}</p>
2392
- {data?.imageUrl && <img src={data.imageUrl} />}
2393
- </section>
2394
- );
2395
- }
2396
- ```
2397
-
2398
- Block content is stored as:
2399
-
2400
- ```ts
2401
- {
2402
- _tree: [{ id: "abc", type: "hero", children: [] }],
2403
- _values: { abc: { heading: "Welcome", subheading: "..." } },
2404
- _data: { abc: { imageUrl: "https://..." } }, // server-prefetched
2405
- }
2406
- ```
2407
-
2408
- ### Components (Server-Driven)
2409
-
2410
- Components bridge serializable server config to React rendering. Two built-in:
2411
-
2412
- | Name | Description |
2413
- | ------- | --------------------------------- |
2414
- | `icon` | Renders Iconify icons (fast path) |
2415
- | `badge` | Renders a styled badge |
2416
-
2417
- Used everywhere in config via `ComponentReference`:
2418
-
2419
- ```ts
2420
- // In .admin(), sidebar, dashboard, actions:
2421
- { type: "icon", props: { name: "ph:users" } }
2422
- { type: "badge", props: { label: "New", variant: "success" } }
2423
- ```
2424
-
2425
- Or via the `c` proxy in callbacks:
2426
-
2427
- ```ts
2428
- .admin(({ c }) => ({
2429
- icon: c.icon({ name: "ph:users" }),
2430
- }))
2431
- ```
2432
-
2433
- Register custom components:
2434
-
2435
- ```ts
2436
- import { component } from "@questpie/admin/client";
2437
-
2438
- export const statusIndicator = component("statusIndicator", {
2439
- component: lazy(() => import("./status-indicator")),
2440
- });
2441
- ```
2442
-
2443
- ---
2444
-
2445
- ## 28. Codegen & the .generated/ Directory
2446
-
2447
- Codegen is the bridge between your file conventions and the runtime. It runs via `questpie generate` (one-shot) or `questpie dev` (watch mode).
2448
-
2449
- ### What Gets Generated
2450
-
2451
- | File | Contents |
2452
- | -------------------------------- | ------------------------------------------------------------------------------------ |
2453
- | `server/.generated/index.ts` | `createApp()` call, `App` type, `AppCollections`, `AppGlobals`, module augmentations |
2454
- | `server/.generated/factories.ts` | Typed `collection()`, `global()`, `block()`, config factories with plugin extensions |
2455
- | `admin/.generated/client.ts` | Admin client config object for `<AdminLayoutProvider>` |
2456
-
2457
- ### When to Regenerate
2458
-
2459
- - **File added or removed** in a convention directory → regenerate
2460
- - **File content changed** → no regeneration needed (codegen uses `typeof import(...)` which is stable)
2461
- - `questpie dev` watches for add/remove only
2462
-
2463
- ### The Process
2464
-
2465
- ```
2466
- 1. Read questpie.config.ts → runtime config
2467
- 2. Read modules.ts → extract codegen plugins from each module
2468
- 3. Build target graph: core plugin + extracted plugins → merged category declarations
2469
- 4. Discover files: scan convention directories
2470
- 5. Generate templates:
2471
- - index.ts (createApp + type augmentation)
2472
- - factories.ts (typed builders with extensions)
2473
- - client.ts (admin config)
2474
- ```
2475
-
2476
- ### For NPM Module Packages
2477
-
2478
- Module packages use `packageConfig()` instead of `runtimeConfig()`:
2479
-
2480
- ```ts
2481
- // questpie.config.ts in a module package
2482
- import { packageConfig } from "questpie/cli";
2483
-
2484
- export default packageConfig({
2485
- modulesDir: "src/server/modules",
2486
- modulePrefix: "questpie",
2487
- plugins: [adminPlugin()],
2488
- });
2489
- ```
2490
-
2491
- This generates `.generated/module.ts` for each module subdirectory (no `createApp`, no runtime config — just a typed module object).
2492
-
2493
- ---
2494
-
2495
- ## 29. Adapters & Infrastructure
2496
-
2497
- QuestPie uses pluggable adapters for all infrastructure concerns.
2498
-
2499
- ### Database
2500
-
2501
- Always **PostgreSQL** via **Drizzle ORM**. Two client options:
2502
-
2503
- - `Bun.SQL` (production — native Bun PostgreSQL driver)
2504
- - `PGlite` (testing — in-process SQLite-compatible Postgres)
2505
-
2506
- ### HTTP Adapters
2507
-
2508
- The core provides `createFetchHandler()` which works with any framework that supports the `fetch` API:
2509
-
2510
- ```ts
2511
- // Standalone (Bun.serve)
2512
- Bun.serve({ fetch: createFetchHandler(app) });
2513
-
2514
- // TanStack Start / Vinxi
2515
- export default createFetchHandler(app, { basePath: "/api" });
2516
-
2517
- // Hono
2518
- import { questpieMiddleware } from "@questpie/hono/server";
2519
- app.use("/api/*", questpieMiddleware(questpieApp));
2520
-
2521
- // Elysia
2522
- import { questpieElysia } from "@questpie/elysia/server";
2523
- app.use(questpieElysia(questpieApp));
2524
-
2525
- // Next.js
2526
- import { questpieNextRouteHandlers } from "@questpie/next";
2527
- export const { GET, POST, PUT, PATCH, DELETE } =
2528
- questpieNextRouteHandlers(questpieApp);
2529
- ```
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
-
2544
- ### Storage Adapters
2545
-
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 |
2551
-
2552
- ### Queue Adapters
2553
-
2554
- | Adapter | Description |
2555
- | --------------------------- | ------------------------------------ |
2556
- | `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
2557
- | `bullMQAdapter()` | Redis-based job queue (BullMQ) |
2558
- | `cloudflareQueuesAdapter()` | Cloudflare Workers Queues |
2559
-
2560
- ### Search Adapters
2561
-
2562
- | Adapter | Description |
2563
- | -------------------------------- | --------------------------------- |
2564
- | `createPostgresSearchAdapter()` | pg_trgm + full-text search |
2565
- | `createPgVectorSearchAdapter()` | Hybrid semantic search (pgvector + embedding provider — see `references/infrastructure-adapters.md`) |
2566
-
2567
- ### Realtime Adapters
2568
-
2569
- | Adapter | Description |
2570
- | ----------------------------- | -------------------------- |
2571
- | Default | Polling (2s interval) |
2572
- | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
2573
- | `redisStreamsAdapter()` | Redis Streams |
2574
- | `cloudflareRealtimeAdapter()` | Cloudflare Durable Objects |
2575
-
2576
- ### KV Adapters
2577
-
2578
- | Adapter | Description |
2579
- | ----------------------- | --------------------- |
2580
- | `MemoryKVAdapter` | In-process (default) |
2581
- | `RedisKVAdapter` | Redis-backed |
2582
- | `cloudflareKVAdapter()` | Cloudflare Workers KV |
2583
-
2584
- ### Email Adapters
2585
-
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 |
2593
-
2594
- ### Logger
2595
-
2596
- | Adapter | Description |
2597
- | ------- | ----------------------------------- |
2598
- | Default | Pino-based structured logging |
2599
- | Custom | Implement `LoggerAdapter` interface |
2600
-
2601
- ---
2602
-
2603
- ## 30. TypeScript Type Augmentation
2604
-
2605
- QuestPie uses `declare global { namespace Questpie { ... } }` for type extensions. Codegen fills these in automatically.
2606
-
2607
- ### Augmentation Points
2608
-
2609
- ```ts
2610
- declare global {
2611
- namespace Questpie {
2612
- interface AppContext {} // Add custom context keys
2613
- interface Registry {} // Type catalog (collections, globals, routes, etc.)
2614
- interface ViewsRegistry {} // Admin view autocomplete
2615
- interface ComponentsRegistry {} // Admin component autocomplete
2616
- interface FieldTypesMap {} // Field factory autocomplete (f.*)
2617
- }
2618
- }
2619
- ```
2620
-
2621
- ### What Codegen Produces
2622
-
2623
- The generated `index.ts` augments these interfaces so that:
2624
-
2625
- - `ctx.collections.posts` is fully typed with the actual field shapes
2626
- - `ctx.globals.siteSettings` is fully typed
2627
- - `f.text()`, `f.relation()`, etc. have full autocomplete
2628
- - `ctx.queue.jobName.publish(payload)` validates payload types
2629
- - `client.collections.posts.find()` returns typed results
2630
-
2631
- ### Plugin Config Augmentation
2632
-
2633
- Plugins extend the module config type via `declare module "questpie"`:
2634
-
2635
- ```ts
2636
- declare module "questpie" {
2637
- interface AppStateConfig {
2638
- admin?: AdminConfigInput;
2639
- openapi?: OpenApiConfig;
2640
- }
2641
- }
2642
- ```
2643
-
2644
- ---
2645
-
2646
- ## 31. CLI Reference
2647
-
2648
- ```bash
2649
- bun questpie <command> [options]
2650
- ```
2651
-
2652
- | Command | Description |
2653
- | ------------------------ | --------------------------------------------------- |
2654
- | `generate` | Run codegen (one-shot) |
2655
- | `dev` | Watch mode codegen (re-runs on file add/remove) |
2656
- | `push` | Push schema to DB (dev only, like drizzle-kit push) |
2657
- | `migrate` / `migrate:up` | Run pending migrations |
2658
- | `migrate:generate` | Generate migration from schema diff |
2659
- | `migrate:down` | Rollback migrations |
2660
- | `migrate:status` | Show migration status |
2661
- | `migrate:reset` | Rollback all |
2662
- | `migrate:fresh` | Reset + re-run all migrations |
2663
- | `seed` | Run pending seeds |
2664
- | `seed:undo` | Undo executed seeds |
2665
- | `seed:status` | Show seed status |
2666
- | `seed:reset` | Reset seed tracking |
2667
- | `add <type> <name>` | Scaffold new entity file |
2668
- | `add --list` | Show available entity types |
2669
-
2670
- Global options: `-c, --config <path>` (default: `questpie.config.ts`)
2671
-
2672
- ### Scaffolding
2673
-
2674
- ```bash
2675
- bun questpie add collection blog-posts
2676
- bun questpie add global site-settings
2677
- bun questpie add route get-stats
2678
- bun questpie add job send-newsletter
2679
- bun questpie add service analytics
2680
- bun questpie add email welcome
2681
- bun questpie add seed default-data
2682
- bun questpie add migration add-categories
2683
- bun questpie add block hero # with admin plugin
2684
- ```
2685
-
2686
- ---
2687
-
2688
- ## 32. How It All Connects — The Big Picture
2689
-
2690
- ### Data Flow: Server Startup
2691
-
2692
- ```
2693
- questpie.config.ts "Infrastructure: here's my DB, storage, email..."
2694
-
2695
- modules.ts "Packages: use admin, openapi, audit..."
2696
-
2697
- codegen Scans collections/, globals/, routes/, etc.
2698
-
2699
- .generated/index.ts Bundles everything into createApp() call
2700
-
2701
- createApp()
2702
- ├─ resolveModules() Flatten module tree depth-first
2703
- ├─ mergeModuleIntoState() Merge all contributions (later wins)
2704
- ├─ new Questpie(config) Create the app instance
2705
- └─ _initServices() Boot: db, auth, storage, queue, email, kv, logger, search, realtime
2706
-
2707
- createFetchHandler(app) Compile all routes into a trie-based dispatcher
2708
-
2709
- Ready to serve requests
2710
- ```
2711
-
2712
- ### Data Flow: HTTP Request
2713
-
2714
- ```
2715
- HTTP Request
2716
-
2717
- createFetchHandler (trie match)
2718
-
2719
- Resolve session (Better Auth)
2720
-
2721
- Resolve locale (header / cookie)
2722
-
2723
- Build AppContext { db, session, collections, globals, queue, email, ... }
2724
-
2725
- Route handler / Collection CRUD
2726
-
2727
- Access control check
2728
-
2729
- Hooks: beforeOperation → beforeValidate → beforeChange
2730
-
2731
- DB operation (Drizzle)
2732
-
2733
- Hooks: afterChange → afterRead
2734
-
2735
- Realtime event (written to outbox)
2736
-
2737
- HTTP Response (JSON / SuperJSON)
2738
- ```
2739
-
2740
- ### Data Flow: Admin Panel
2741
-
2742
- ```
2743
- Browser loads /admin
2744
-
2745
- AdminLayoutProvider receives pre-built AdminState
2746
- (fields, views, pages, widgets, blocks, components, translations)
2747
-
2748
- Sidebar rendered from adminConfig.sidebar
2749
-
2750
- User clicks "Posts"
2751
-
2752
- Fetch GET /api/posts/schema → CollectionSchema (server-driven UI config)
2753
-
2754
- Look up "collection-table" view in AdminState.views
2755
-
2756
- Render table with schema-driven columns, filters, sort, actions
2757
-
2758
- User clicks a row
2759
-
2760
- Fetch GET /api/posts/{id}
2761
-
2762
- Look up "collection-form" view in AdminState.views
2763
-
2764
- Render form with schema-driven field layout, sections, tabs, sidebar
2765
-
2766
- Each field renders via AdminState.fields[fieldType].component
2767
-
2768
- Validation via buildZodFromIntrospection() (client-side Zod)
2769
-
2770
- Submit PATCH /api/posts/{id}
2771
-
2772
- Server hooks + access control + DB write + realtime event
2773
- ```
2774
-
2775
- ### Data Flow: Module Contribution
2776
-
2777
- ```
2778
- Module author writes:
2779
- collections/invoices.ts → collection("invoices").fields(...)
2780
- routes/create-checkout.ts → route().post().handler(...)
2781
- jobs/retry-payment.ts → job({ name: "retryPayment", ... })
2782
-
2783
- questpie generate --module → .generated/module.ts
2784
-
2785
- Published to npm as @my-org/billing-module
2786
-
2787
- User adds to modules.ts:
2788
- import { billingModule } from "@my-org/billing-module";
2789
- export default [adminModule, billingModule] as const;
2790
-
2791
- questpie generate → merges into .generated/index.ts
2792
-
2793
- At runtime: billingModule's collections, routes, jobs are part of the app
2794
- ```
2795
-
2796
- ### The Primitive Hierarchy
2797
-
2798
- ```
2799
- runtimeConfig() Infrastructure (DB, storage, email, queue, ...)
2800
-
2801
- module() Packaging unit (groups related entities)
2802
- ├── collection() Data table with CRUD, hooks, access, versioning
2803
- │ ├── f.text() Field definitions (each → DB column + validation + UI)
2804
- │ ├── f.relation() Relationships between collections
2805
- │ ├── .hooks() Lifecycle callbacks
2806
- │ ├── .access() Permission rules
2807
- │ ├── .admin() Admin panel metadata ← admin plugin
2808
- │ ├── .list() List view configuration ← admin plugin
2809
- │ ├── .form() Form view configuration ← admin plugin
2810
- │ ├── .preview() Live preview ← admin plugin
2811
- │ └── .actions() Server actions ← admin plugin
2812
- ├── global() Singleton document (settings, config)
2813
- ├── route() Custom HTTP endpoint
2814
- ├── job() Background task
2815
- ├── service() Injectable dependency
2816
- ├── email() Email template
2817
- ├── block() Content builder block ← admin plugin
2818
- ├── migration() DB schema change
2819
- ├── seed() DB seed data
2820
- ├── appConfig() App-level config (locale, access, hooks, context)
2821
- ├── authConfig() Auth config (Better Auth options)
2822
- └── adminConfig() Admin config (sidebar, dashboard, branding) ← admin plugin
2823
- ```
2824
-
2825
- ### Environment Variables
2826
-
2827
- | Variable | Purpose | Default |
2828
- | ----------------------------- | --------------------- | ----------------------- |
2829
- | `DATABASE_URL` | PostgreSQL connection | (required) |
2830
- | `APP_URL` | Application URL | `http://localhost:3000` |
2831
- | `BETTER_AUTH_SECRET` | Auth signing secret | — |
2832
- | `QUESTPIE_DB` | Cloud DB override | — |
2833
- | `QUESTPIE_APP_URL` | Cloud URL override | — |
2834
- | `QUESTPIE_SECRET` | Cloud secret override | — |
2835
- | `QUESTPIE_STORAGE_ENDPOINT` | S3 endpoint | — |
2836
- | `QUESTPIE_STORAGE_BUCKET` | S3 bucket | — |
2837
- | `QUESTPIE_STORAGE_REGION` | S3 region | — |
2838
- | `QUESTPIE_STORAGE_ACCESS_KEY` | S3 access key | — |
2839
- | `QUESTPIE_STORAGE_SECRET_KEY` | S3 secret key | — |
2840
-
2841
- ---
2842
-
2843
- ## Quick Reference: All Primitives
2844
-
2845
- | Primitive | Factory | Import From | Purpose |
2846
- | -------------- | ------------------------------------ | -------------------------- | ---------------------------- |
2847
- | Collection | `collection(name)` | `#questpie/factories` | Data table + CRUD |
2848
- | Global | `global(name)` | `#questpie/factories` | Singleton document |
2849
- | Field | `f.text()`, `f.number()`, ... | `.fields()` callback | Column definition |
2850
- | Field Type | `fieldType(name, config)` | `questpie` | Custom field type |
2851
- | Route | `route()` | `questpie` | HTTP endpoint |
2852
- | Service | `service()` | `questpie` | Injectable dependency |
2853
- | Job | `job({...})` | `questpie` | Background task |
2854
- | Email | `email({...})` | `questpie` | Email template |
2855
- | Block | `block(name)` | `#questpie/factories` | Content builder block |
2856
- | Migration | `migration({...})` | `questpie` | DB schema change |
2857
- | Seed | `seed({...})` | `questpie` | DB seed data |
2858
- | Module | `module({...})` | `questpie` | Packaging unit |
2859
- | Runtime Config | `runtimeConfig({...})` | `questpie` | Infrastructure config |
2860
- | App Config | `appConfig({...})` | `questpie` | Locale, access, hooks, context |
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 |
2864
- | Admin Config | `adminConfig({...})` | `#questpie/factories` | Sidebar, dashboard, branding |
2865
- | View | `view(name, {kind, component})` | `@questpie/admin/client` | Admin view component |
2866
- | Widget | `widget(name, {component})` | `@questpie/admin/client` | Dashboard widget |
2867
- | Component | `component(name, {component})` | `@questpie/admin/client` | Server-driven component |
2868
- | Client | `createClient<App>(config)` | `questpie/client` | Frontend API client |
2869
- | Query Options | `createQuestpieQueryOptions(client)` | `@questpie/tanstack-query` | TanStack Query integration |
2870
-
2871
- > **Import rule of thumb:** Only `collection()`, `global()`, `block()`, and `adminConfig()` come from `#questpie/factories` (they need codegen-generated types). Everything else comes from `"questpie"` directly.