create-questpie 2.0.1 → 2.0.3

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