create-questpie 2.0.0 → 2.0.2

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 (45) hide show
  1. package/README.md +10 -6
  2. package/dist/index.mjs +140 -25
  3. package/package.json +5 -3
  4. package/skills/questpie/AGENTS.md +2664 -0
  5. package/skills/questpie/SKILL.md +181 -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 +489 -0
  11. package/skills/questpie/references/extend.md +493 -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 +549 -0
  18. package/skills/questpie/references/rules.md +327 -0
  19. package/skills/questpie/references/tanstack-query.md +520 -0
  20. package/skills/questpie-admin/AGENTS.md +1442 -0
  21. package/skills/questpie-admin/SKILL.md +410 -0
  22. package/skills/questpie-admin/references/blocks.md +307 -0
  23. package/skills/questpie-admin/references/custom-ui.md +305 -0
  24. package/skills/questpie-admin/references/views.md +433 -0
  25. package/templates/tanstack-start/AGENTS.md +71 -62
  26. package/templates/tanstack-start/CLAUDE.md +26 -23
  27. package/templates/tanstack-start/README.md +32 -20
  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/client.ts +2 -2
  31. package/templates/tanstack-start/src/lib/env.ts +1 -1
  32. package/templates/tanstack-start/src/questpie/admin/.generated/client.ts +13 -0
  33. package/templates/tanstack-start/src/questpie/admin/modules.ts +1 -0
  34. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +117 -241
  35. package/templates/tanstack-start/src/questpie/server/.generated/index.ts +129 -81
  36. package/templates/tanstack-start/src/questpie/server/app.ts +1 -1
  37. package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
  38. package/templates/tanstack-start/src/questpie/server/globals/site-settings.global.ts +1 -1
  39. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
  40. package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
  41. package/templates/tanstack-start/src/routes/__root.tsx +0 -2
  42. package/templates/tanstack-start/src/routes/admin.tsx +8 -1
  43. package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
  44. package/templates/tanstack-start/src/vite-env.d.ts +1 -0
  45. package/templates/tanstack-start/vite.config.ts +1 -3
@@ -0,0 +1,2664 @@
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(["draft", "published"]).default("draft"),
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.updateById({
380
+ id: record.id,
381
+ data: { status: "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
+ ### API Usage
1242
+
1243
+ ```ts
1244
+ // Server-side
1245
+ const versions = await collections.pages.findVersions({ id: "abc" });
1246
+ await collections.pages.revertToVersion({ id: "abc", version: 5 });
1247
+ await collections.pages.transitionStage({ id: "abc", stage: "published" });
1248
+
1249
+ // Client-side
1250
+ const versions = await client.collections.pages.findVersions({ id: "abc" });
1251
+ await client.collections.pages.transitionStage({
1252
+ id: "abc",
1253
+ stage: "published",
1254
+ });
1255
+ ```
1256
+
1257
+ ---
1258
+
1259
+ ## 18. Search
1260
+
1261
+ Full-text search across collections.
1262
+
1263
+ ### Configuring Search
1264
+
1265
+ ```ts
1266
+ collection("posts").searchable({
1267
+ content: (record) => record.title,
1268
+ // or multiple fields:
1269
+ content: (record) => `${record.title} ${record.excerpt}`,
1270
+ });
1271
+ ```
1272
+
1273
+ ### Search Adapters
1274
+
1275
+ | Adapter | Description |
1276
+ | ----------------------- | ------------------------------------ |
1277
+ | `PostgresSearchAdapter` | pg_trgm + FTS (default, zero-config) |
1278
+ | `PgVectorSearchAdapter` | Hybrid semantic search with pgvector |
1279
+
1280
+ ### API Usage
1281
+
1282
+ ```ts
1283
+ // Client-side
1284
+ const results = await client.search.search({
1285
+ query: "typescript guide",
1286
+ collections: ["posts", "pages"],
1287
+ limit: 10,
1288
+ });
1289
+
1290
+ // Server-side
1291
+ const results = await ctx.search.search({
1292
+ query: "typescript guide",
1293
+ collections: ["posts"],
1294
+ });
1295
+ ```
1296
+
1297
+ ### Reindexing
1298
+
1299
+ ```ts
1300
+ // Via API
1301
+ await client.collections.posts.reindex();
1302
+
1303
+ // Via job
1304
+ await ctx.queue.indexRecords.publish({ collection: "posts" });
1305
+ ```
1306
+
1307
+ ---
1308
+
1309
+ ## 19. Realtime
1310
+
1311
+ Server-sent events for live data updates.
1312
+
1313
+ ### How It Works
1314
+
1315
+ 1. Every create/update/delete writes a change event to `questpie_realtime_log` (outbox pattern)
1316
+ 2. An adapter (pg_notify or Redis Streams) notifies connected clients
1317
+ 3. Clients subscribe via SSE to specific resources
1318
+
1319
+ ### Client-Side Usage
1320
+
1321
+ ```ts
1322
+ // With TanStack Query (automatic)
1323
+ const { data } = useQuery(qp.collections.posts.find({}, { realtime: true }));
1324
+
1325
+ // Manual subscription
1326
+ const unsub = client.realtime.subscribe(
1327
+ { resourceType: "collection", resource: "posts" },
1328
+ (event) => {
1329
+ console.log("Change:", event.operation, event.recordId);
1330
+ },
1331
+ );
1332
+ ```
1333
+
1334
+ ### Realtime Adapters
1335
+
1336
+ ```ts
1337
+ import { pgNotifyAdapter } from "questpie";
1338
+
1339
+ runtimeConfig({
1340
+ realtime: {
1341
+ adapter: pgNotifyAdapter({ connectionString: process.env.DATABASE_URL }),
1342
+ },
1343
+ });
1344
+ ```
1345
+
1346
+ | Adapter | Description |
1347
+ | ----------------------- | ---------------------------- |
1348
+ | _None_ (default) | Polling-based (every 2s) |
1349
+ | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
1350
+ | `redisStreamsAdapter()` | Redis Streams consumer group |
1351
+
1352
+ ---
1353
+
1354
+ ## 20. i18n / Localization
1355
+
1356
+ ### Content Localization
1357
+
1358
+ Mark fields as localized — stored per-locale in a separate i18n table:
1359
+
1360
+ ```ts
1361
+ f.text(255).localized(); // stored in {collection}_i18n table
1362
+ ```
1363
+
1364
+ ### Configuring Locales
1365
+
1366
+ ```ts
1367
+ // config/app.ts
1368
+ appConfig({
1369
+ locale: {
1370
+ locales: ["en", "sk", "de"],
1371
+ defaultLocale: "en",
1372
+ fallback: { sk: "en", de: "en" },
1373
+ },
1374
+ });
1375
+ ```
1376
+
1377
+ ### Querying Localized Content
1378
+
1379
+ ```ts
1380
+ // Server
1381
+ const post = await collections.posts.findOne({
1382
+ where: { id: "abc" },
1383
+ locale: "sk",
1384
+ localeFallback: true, // fall back to "en" if "sk" not available
1385
+ });
1386
+
1387
+ // Client
1388
+ const post = await client.collections.posts.findOne({
1389
+ where: { id: "abc" },
1390
+ locale: "sk",
1391
+ });
1392
+ ```
1393
+
1394
+ ### Admin UI Translations
1395
+
1396
+ ```ts
1397
+ // config/admin.ts
1398
+ adminConfig({
1399
+ locale: {
1400
+ locales: ["en", "sk"],
1401
+ defaultLocale: "en",
1402
+ },
1403
+ });
1404
+
1405
+ // messages/en.ts — admin UI string translations
1406
+ export default {
1407
+ "admin.posts.title": "Blog Posts",
1408
+ "admin.posts.description": "Manage blog posts",
1409
+ };
1410
+ ```
1411
+
1412
+ ### Translation Function
1413
+
1414
+ Available in all hooks, routes, and services as `ctx.t(key, params?, locale?)`:
1415
+
1416
+ ```ts
1417
+ const msg = ctx.t("billing.invoice_sent", { amount: 100 }, "en");
1418
+ ```
1419
+
1420
+ ---
1421
+
1422
+ ## 21. AppContext — What's Available Everywhere
1423
+
1424
+ Every hook, route handler, job handler, and service receives `AppContext`. This is the **core runtime interface** — learn it once, use it everywhere.
1425
+
1426
+ ```ts
1427
+ interface AppContext {
1428
+ app: Questpie; // the app instance
1429
+ db: DrizzleClient; // Drizzle ORM (may be a transaction in hooks)
1430
+ session: { user; session } | null; // current auth session
1431
+ collections: { [name]: CollectionAPI }; // typed CRUD for all collections
1432
+ globals: { [name]: GlobalAPI }; // typed CRUD for all globals
1433
+ queue: QueueClient; // dispatch background jobs
1434
+ email: MailerService; // send emails
1435
+ storage: DriveManager; // file storage operations
1436
+ kv: KVService; // key-value store
1437
+ logger: LoggerService; // structured logging
1438
+ search: SearchService; // full-text search
1439
+ realtime: RealtimeService; // publish realtime events
1440
+ t: (key, params?, locale?) => string; // i18n translator
1441
+ services: Record<string, unknown>; // user-defined services
1442
+ }
1443
+ ```
1444
+
1445
+ ### Where AppContext Is Available
1446
+
1447
+ | Context | How to Access |
1448
+ | ---------------- | ---------------------------------------------------------------- |
1449
+ | Collection hooks | First argument: `async (ctx) => { ... }` |
1450
+ | Route handlers | Destructure: `async ({ db, session, collections }) => { ... }` |
1451
+ | Job handlers | Destructure: `async ({ payload, queue, email }) => { ... }` |
1452
+ | Email templates | Destructure: `async ({ input, collections }) => { ... }` |
1453
+ | Access rules | Destructure: `({ session, data }) => boolean` |
1454
+ | Seeds | `async ({ collections, log }) => { ... }` |
1455
+ | Services | `create: ({ app }) => ...` (app instance only, not full context) |
1456
+
1457
+ ### Getting Context Programmatically
1458
+
1459
+ ```ts
1460
+ import { getContext, tryGetContext } from "questpie";
1461
+
1462
+ const ctx = getContext(); // throws if outside a request scope
1463
+ const ctx = tryGetContext(); // returns null if outside scope
1464
+
1465
+ // Create a fresh context manually:
1466
+ const ctx = await app.createContext({
1467
+ session: null,
1468
+ locale: "en",
1469
+ accessMode: "system",
1470
+ });
1471
+ ```
1472
+
1473
+ ---
1474
+
1475
+ ## 22. Server-Side CRUD API
1476
+
1477
+ Access collections and globals programmatically from anywhere in server code.
1478
+
1479
+ ### Collections
1480
+
1481
+ ```ts
1482
+ // via AppContext (in hooks, routes, jobs)
1483
+ const posts = await ctx.collections.posts.find({
1484
+ where: { status: "published" },
1485
+ orderBy: { createdAt: "desc" },
1486
+ limit: 10,
1487
+ with: { author: true, tags: true },
1488
+ });
1489
+
1490
+ // via app instance (in startup scripts, CLI)
1491
+ const post = await app.collections.posts.findOne({
1492
+ where: { slug: "hello-world" },
1493
+ columns: { title: true, content: true },
1494
+ });
1495
+ ```
1496
+
1497
+ ### Collection Methods
1498
+
1499
+ ```ts
1500
+ // READ
1501
+ .find(options?) → PaginatedResult<T>
1502
+ .findOne(options?) → T | null
1503
+ .count(options?) → number
1504
+
1505
+ // WRITE
1506
+ .create(data) → T
1507
+ .updateById({ id, data }) → T
1508
+ .update({ where, data }) → T[] (batch)
1509
+ .deleteById({ id }) → { success }
1510
+ .delete({ where }) → { success, count } (batch)
1511
+ .restoreById({ id }) → T (soft-delete)
1512
+
1513
+ // VERSIONING
1514
+ .findVersions({ id }) → VersionRecord[]
1515
+ .revertToVersion({ id, version }) → T
1516
+ .transitionStage({ id, stage }) → T
1517
+
1518
+ // UPLOAD (when .upload() is configured)
1519
+ .upload(file, ctx?, additionalData?) → T
1520
+ .uploadMany(files, ctx?, additionalData?) → T[]
1521
+ ```
1522
+
1523
+ ### FindOptions
1524
+
1525
+ ```ts
1526
+ {
1527
+ where: {
1528
+ status: "published", // equality
1529
+ title: { like: "%guide%" }, // LIKE
1530
+ createdAt: { gte: new Date() }, // comparison operators
1531
+ OR: [{ a: 1 }, { b: 2 }], // logical
1532
+ AND: [...],
1533
+ NOT: { ... },
1534
+ },
1535
+ columns: { title: true, content: true }, // select specific fields
1536
+ with: { // include relations
1537
+ author: true,
1538
+ tags: { columns: { name: true } },
1539
+ },
1540
+ orderBy: { createdAt: "desc" },
1541
+ limit: 10,
1542
+ offset: 0,
1543
+ search: "keyword", // full-text ILIKE on title
1544
+ locale: "en",
1545
+ localeFallback: true,
1546
+ includeDeleted: false, // include soft-deleted
1547
+ stage: "published", // versioning stage filter
1548
+ extras: { wordCount: sql`...` }, // computed columns
1549
+ }
1550
+ ```
1551
+
1552
+ ### PaginatedResult
1553
+
1554
+ ```ts
1555
+ {
1556
+ docs: T[],
1557
+ totalDocs: number,
1558
+ limit: number,
1559
+ totalPages: number,
1560
+ page: number,
1561
+ pagingCounter: number,
1562
+ hasPrevPage: boolean,
1563
+ hasNextPage: boolean,
1564
+ prevPage: number | null,
1565
+ nextPage: number | null,
1566
+ }
1567
+ ```
1568
+
1569
+ ### Globals
1570
+
1571
+ ```ts
1572
+ const settings = await ctx.globals.siteSettings.get({
1573
+ with: { logo: true },
1574
+ locale: "en",
1575
+ });
1576
+
1577
+ await ctx.globals.siteSettings.update({
1578
+ siteName: "New Name",
1579
+ description: "Updated description",
1580
+ });
1581
+ ```
1582
+
1583
+ ### Global Methods
1584
+
1585
+ ```ts
1586
+ .get(options?) → T | null
1587
+ .update(data, ctx?, options?) → T
1588
+ .findVersions(options?) → VersionRecord[]
1589
+ .revertToVersion({ version }) → T
1590
+ .transitionStage({ stage }) → T
1591
+ ```
1592
+
1593
+ ### Request Context
1594
+
1595
+ All CRUD methods accept an optional second argument for context:
1596
+
1597
+ ```ts
1598
+ await collections.posts.find(
1599
+ { where: { status: "published" } },
1600
+ {
1601
+ session: mySession, // override session
1602
+ locale: "sk", // override locale
1603
+ accessMode: "user", // force access checks (default: "system")
1604
+ stage: "published", // version stage filter
1605
+ db: transactionClient, // run inside a transaction
1606
+ },
1607
+ );
1608
+ ```
1609
+
1610
+ ---
1611
+
1612
+ ## 23. Client SDK
1613
+
1614
+ The typed client for calling the QuestPie API from frontend or external code.
1615
+
1616
+ ### Setup
1617
+
1618
+ ```ts
1619
+ import { createClient } from "questpie/client";
1620
+ import type { App } from "@/questpie/server/app";
1621
+
1622
+ export const client = createClient<App>({
1623
+ baseURL: "http://localhost:3000",
1624
+ basePath: "/api", // matches your catch-all route
1625
+ });
1626
+ ```
1627
+
1628
+ ### Collections
1629
+
1630
+ ```ts
1631
+ // Read
1632
+ const posts = await client.collections.posts.find({
1633
+ where: { status: "published" },
1634
+ limit: 10,
1635
+ with: { author: true },
1636
+ });
1637
+
1638
+ const post = await client.collections.posts.findOne({
1639
+ where: { id: "abc" },
1640
+ });
1641
+
1642
+ const count = await client.collections.posts.count({
1643
+ where: { status: "draft" },
1644
+ });
1645
+
1646
+ // Write
1647
+ const newPost = await client.collections.posts.create({
1648
+ title: "Hello",
1649
+ content: "...",
1650
+ });
1651
+
1652
+ const updated = await client.collections.posts.update({
1653
+ id: "abc",
1654
+ data: { title: "Updated" },
1655
+ });
1656
+
1657
+ await client.collections.posts.delete({ id: "abc" });
1658
+
1659
+ // Upload
1660
+ const asset = await client.collections.assets.upload(fileObject);
1661
+
1662
+ // Versioning
1663
+ const versions = await client.collections.posts.findVersions({ id: "abc" });
1664
+ await client.collections.posts.revertToVersion({ id: "abc", version: 3 });
1665
+ await client.collections.posts.transitionStage({
1666
+ id: "abc",
1667
+ stage: "published",
1668
+ });
1669
+
1670
+ // Schema introspection
1671
+ const schema = await client.collections.posts.schema();
1672
+ ```
1673
+
1674
+ ### Globals
1675
+
1676
+ ```ts
1677
+ const settings = await client.globals.siteSettings.get();
1678
+ await client.globals.siteSettings.update({ siteName: "New Name" });
1679
+ ```
1680
+
1681
+ ### Custom Routes
1682
+
1683
+ ```ts
1684
+ // Route: routes/admin/stats.ts → key "admin/stats"
1685
+ const stats = await client.routes.admin.stats({ period: "week" });
1686
+ ```
1687
+
1688
+ ### Search
1689
+
1690
+ ```ts
1691
+ const results = await client.search.search({
1692
+ query: "typescript",
1693
+ collections: ["posts"],
1694
+ limit: 10,
1695
+ });
1696
+ ```
1697
+
1698
+ ### Locale
1699
+
1700
+ ```ts
1701
+ client.setLocale("sk"); // set for all subsequent requests
1702
+ const locale = client.getLocale();
1703
+ ```
1704
+
1705
+ ### Error Handling
1706
+
1707
+ ```ts
1708
+ import { QuestpieClientError } from "questpie/client";
1709
+
1710
+ try {
1711
+ await client.collections.posts.create({ title: "" });
1712
+ } catch (err) {
1713
+ if (err instanceof QuestpieClientError) {
1714
+ err.status; // HTTP status
1715
+ err.code; // error code
1716
+ err.fieldErrors; // per-field validation errors
1717
+ err.getFieldError("title"); // specific field error
1718
+ err.isCode("VALIDATION_ERROR"); // check error type
1719
+ }
1720
+ }
1721
+ ```
1722
+
1723
+ ---
1724
+
1725
+ ## 24. TanStack Query Integration
1726
+
1727
+ Pre-built query/mutation options for `@tanstack/react-query`.
1728
+
1729
+ ### Setup
1730
+
1731
+ ```ts
1732
+ import { createQuestpieQueryOptions } from "@questpie/tanstack-query";
1733
+ import { client } from "@/lib/client";
1734
+ import type { App } from "@/questpie/server/app";
1735
+
1736
+ export const qp = createQuestpieQueryOptions<App>(client, {
1737
+ keyPrefix: ["questpie"],
1738
+ });
1739
+ ```
1740
+
1741
+ ### Queries
1742
+
1743
+ ```ts
1744
+ import { useQuery, useMutation } from "@tanstack/react-query";
1745
+
1746
+ // List
1747
+ const { data: posts } = useQuery(
1748
+ qp.collections.posts.find({
1749
+ where: { status: "published" },
1750
+ limit: 10,
1751
+ }),
1752
+ );
1753
+
1754
+ // Single
1755
+ const { data: post } = useQuery(
1756
+ qp.collections.posts.findOne({ where: { id: postId } }),
1757
+ );
1758
+
1759
+ // Count
1760
+ const { data: count } = useQuery(
1761
+ qp.collections.posts.count({ where: { status: "draft" } }),
1762
+ );
1763
+
1764
+ // Global
1765
+ const { data: settings } = useQuery(qp.globals.siteSettings.get());
1766
+
1767
+ // With realtime (auto-refetches on server changes)
1768
+ const { data: posts } = useQuery(
1769
+ qp.collections.posts.find({}, { realtime: true }),
1770
+ );
1771
+ ```
1772
+
1773
+ ### Mutations
1774
+
1775
+ ```ts
1776
+ const createPost = useMutation(qp.collections.posts.create());
1777
+ const updatePost = useMutation(qp.collections.posts.update());
1778
+ const deletePost = useMutation(qp.collections.posts.delete());
1779
+
1780
+ // Usage
1781
+ createPost.mutate({ title: "New Post", content: "..." });
1782
+ updatePost.mutate({ id: "abc", data: { title: "Updated" } });
1783
+ deletePost.mutate({ id: "abc" });
1784
+ ```
1785
+
1786
+ ### Custom Routes
1787
+
1788
+ ```ts
1789
+ // Query (GET routes)
1790
+ const { data } = useQuery(qp.routes.admin.stats.query({ period: "week" }));
1791
+
1792
+ // Mutation (POST/PATCH/DELETE routes)
1793
+ const action = useMutation(qp.routes.webhooks.stripe.mutation());
1794
+ ```
1795
+
1796
+ ### Query Keys
1797
+
1798
+ Consistent key structure for cache invalidation:
1799
+
1800
+ ```ts
1801
+ ["questpie", "collections", "posts", "find", locale, stage, options][
1802
+ ("questpie", "globals", "siteSettings", "get", locale, stage, options)
1803
+ ][("questpie", "routes", "admin", "stats", "query", locale, options)];
1804
+ ```
1805
+
1806
+ ---
1807
+
1808
+ ## 25. Admin Panel
1809
+
1810
+ 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.
1811
+
1812
+ ### Enabling the Admin
1813
+
1814
+ ```ts
1815
+ // modules.ts
1816
+ import { adminModule } from "@questpie/admin/server";
1817
+ export default [adminModule] as const;
1818
+
1819
+ // config/admin.ts
1820
+ import { adminConfig } from "#questpie/factories";
1821
+
1822
+ export default adminConfig({
1823
+ branding: {
1824
+ name: "My CMS",
1825
+ },
1826
+ locale: {
1827
+ locales: ["en", "sk"],
1828
+ defaultLocale: "en",
1829
+ },
1830
+ sidebar: ({ s, c }) => ({
1831
+ sections: [
1832
+ s.section({
1833
+ id: "content",
1834
+ title: "Content",
1835
+ icon: c.icon({ name: "ph:article" }),
1836
+ }),
1837
+ s.section({
1838
+ id: "settings",
1839
+ title: "Settings",
1840
+ icon: c.icon({ name: "ph:gear" }),
1841
+ }),
1842
+ ],
1843
+ items: [
1844
+ s.item({ sectionId: "content", type: "collection", collection: "posts" }),
1845
+ s.item({ sectionId: "content", type: "collection", collection: "pages" }),
1846
+ s.item({
1847
+ sectionId: "settings",
1848
+ type: "global",
1849
+ global: "site_settings",
1850
+ }),
1851
+ s.item({
1852
+ sectionId: "settings",
1853
+ type: "link",
1854
+ label: "API Docs",
1855
+ href: "/api/docs",
1856
+ external: true,
1857
+ }),
1858
+ s.item({ sectionId: "settings", type: "divider" }),
1859
+ ],
1860
+ }),
1861
+ dashboard: ({ d, c, a }) => ({
1862
+ title: "Dashboard",
1863
+ sections: [
1864
+ d.section({ id: "overview", label: "Overview", columns: 4 }),
1865
+ d.section({ id: "recent", label: "Recent Activity", columns: 2 }),
1866
+ ],
1867
+ items: [
1868
+ d.stats({
1869
+ sectionId: "overview",
1870
+ id: "total-posts",
1871
+ label: "Total Posts",
1872
+ collection: "posts",
1873
+ span: 1,
1874
+ }),
1875
+ d.recentItems({
1876
+ sectionId: "recent",
1877
+ id: "recent-posts",
1878
+ label: "Recent Posts",
1879
+ collection: "posts",
1880
+ dateField: "createdAt",
1881
+ limit: 5,
1882
+ span: 1,
1883
+ }),
1884
+ ],
1885
+ actions: [a.create({ collection: "posts", label: "New Post" })],
1886
+ }),
1887
+ });
1888
+ ```
1889
+
1890
+ ### AdminState
1891
+
1892
+ 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.
1893
+
1894
+ ### How the Admin Renders a Collection
1895
+
1896
+ 1. Client navigates to `/admin/collections/posts`
1897
+ 2. Fetches `GET /api/posts/schema` → `CollectionSchema` (fields, access, options, admin config)
1898
+ 3. Looks up the configured **list view** (`collection-table`) from AdminState
1899
+ 4. Renders the view component with schema-driven columns, filters, actions
1900
+ 5. On row click → navigates to `/admin/collections/posts/{id}`
1901
+ 6. Fetches `GET /api/posts/{id}` + schema
1902
+ 7. Looks up the configured **form view** (`collection-form`)
1903
+ 8. Renders form fields using the field registry (each field type maps to a React component)
1904
+
1905
+ ---
1906
+
1907
+ ## 26. Admin Builder Extensions
1908
+
1909
+ These methods are added to `CollectionBuilder`, `GlobalBuilder`, and `Field` by the admin plugin's codegen.
1910
+
1911
+ ### `.admin()` — Collection/Global Metadata
1912
+
1913
+ ```ts
1914
+ collection("posts").admin(({ c }) => ({
1915
+ label: "Blog Posts", // display name
1916
+ description: "Manage blog posts", // subtitle
1917
+ icon: c.icon({ name: "ph:article" }),
1918
+ hidden: false, // hide from sidebar
1919
+ group: "content", // sidebar group key
1920
+ order: 1, // order within group
1921
+ audit: true, // enable audit logging
1922
+ }));
1923
+ ```
1924
+
1925
+ ### `.list()` — List View Config
1926
+
1927
+ ```ts
1928
+ collection("posts").list(({ v, f, a }) =>
1929
+ v.collectionTable({
1930
+ columns: [f.title, f.status, f.author, f.createdAt],
1931
+ defaultSort: { field: f.createdAt, direction: "desc" },
1932
+ searchable: [f.title, f.content],
1933
+ filterable: [f.status, f.author],
1934
+ actions: {
1935
+ header: { primary: [a.create()], secondary: [a.custom("export")] },
1936
+ row: [a.delete(), a.duplicate()],
1937
+ bulk: [a.deleteMany(), a.custom("bulkPublish")],
1938
+ },
1939
+ }),
1940
+ );
1941
+ ```
1942
+
1943
+ ### `.form()` — Form View Config
1944
+
1945
+ ```ts
1946
+ collection("posts").form(({ v, f }) =>
1947
+ v.collectionForm({
1948
+ fields: [
1949
+ f.title,
1950
+ f.slug,
1951
+ {
1952
+ type: "section",
1953
+ label: "Content",
1954
+ layout: "stack",
1955
+ fields: [f.content, f.excerpt],
1956
+ },
1957
+ {
1958
+ type: "tabs",
1959
+ tabs: [
1960
+ {
1961
+ id: "media",
1962
+ label: "Media",
1963
+ icon: { type: "icon", props: { name: "ph:image" } },
1964
+ fields: [f.cover, f.gallery],
1965
+ },
1966
+ { id: "seo", label: "SEO", fields: [f.metaTitle, f.metaDescription] },
1967
+ ],
1968
+ },
1969
+ ],
1970
+ sidebar: {
1971
+ position: "right",
1972
+ fields: [f.status, f.author, f.tags, f.publishedAt],
1973
+ },
1974
+ }),
1975
+ );
1976
+ ```
1977
+
1978
+ #### Form Layout Primitives
1979
+
1980
+ | Type | Description |
1981
+ | ---------------------------------------------------------------- | ---------------------------------------- |
1982
+ | `string` (e.g. `f.title`) | Bare field — renders with default config |
1983
+ | `{ field, hidden?, readOnly?, disabled?, compute?, className? }` | Field with overrides |
1984
+ | `{ type: "section", label?, layout?, columns?, fields: [...] }` | Visual grouping |
1985
+ | `{ type: "tabs", tabs: [{ id, label, icon?, fields }] }` | Tabbed layout |
1986
+
1987
+ #### Reactive Field Config
1988
+
1989
+ Fields can be dynamically hidden, read-only, disabled, or computed based on other field values:
1990
+
1991
+ ```ts
1992
+ {
1993
+ field: f.publishedAt,
1994
+ hidden: ({ data }) => data.status !== "published",
1995
+ readOnly: { deps: [f.status], handler: ({ data }) => data.status === "archived" },
1996
+ compute: {
1997
+ deps: [f.firstName, f.lastName],
1998
+ handler: ({ data }) => `${data.firstName} ${data.lastName}`,
1999
+ debounce: 300,
2000
+ },
2001
+ }
2002
+ ```
2003
+
2004
+ ### `.preview()` — Live Preview
2005
+
2006
+ ```ts
2007
+ collection("posts").preview({
2008
+ enabled: true,
2009
+ url: ({ record, locale }) => `/blog/${record.slug}?locale=${locale}`,
2010
+ position: "right", // "left" | "right" | "bottom"
2011
+ defaultWidth: 50, // percentage
2012
+ });
2013
+ ```
2014
+
2015
+ ### `.actions()` — Server Actions
2016
+
2017
+ ```ts
2018
+ collection("posts").actions(({ a, c, f }) => ({
2019
+ builtin: [a.create(), a.save(), a.delete()],
2020
+ custom: [
2021
+ a.action({
2022
+ id: "publish",
2023
+ label: "Publish",
2024
+ icon: c.icon({ name: "ph:rocket" }),
2025
+ scope: "single", // "single" | "bulk" | "header" | "row"
2026
+ confirmation: {
2027
+ title: "Publish this post?",
2028
+ destructive: false,
2029
+ },
2030
+ handler: async ({ record, collections }) => {
2031
+ await collections.posts.updateById({
2032
+ id: record.id,
2033
+ data: { status: "published" },
2034
+ });
2035
+ return { type: "success", toast: { message: "Published!" } };
2036
+ },
2037
+ }),
2038
+ a.bulkAction({
2039
+ id: "bulkArchive",
2040
+ label: "Archive Selected",
2041
+ form: {
2042
+ title: "Archive Posts",
2043
+ fields: {
2044
+ reason: f.textarea().label("Reason").required(),
2045
+ },
2046
+ },
2047
+ handler: async ({ records, formData, collections }) => {
2048
+ for (const record of records) {
2049
+ await collections.posts.updateById({
2050
+ id: record.id,
2051
+ data: { status: "archived", archiveReason: formData.reason },
2052
+ });
2053
+ }
2054
+ return {
2055
+ type: "success",
2056
+ toast: { message: `Archived ${records.length} posts` },
2057
+ };
2058
+ },
2059
+ }),
2060
+ ],
2061
+ }));
2062
+ ```
2063
+
2064
+ #### Action Result Types
2065
+
2066
+ ```ts
2067
+ { type: "success", toast?: { message, title? }, effects?: { closeModal?, invalidate?, redirect? } }
2068
+ { type: "error", toast?: { message, title? }, errors?: Record<string, string> }
2069
+ { type: "redirect", url: string, external?: boolean }
2070
+ { type: "download", file: { name, content, mimeType } }
2071
+ ```
2072
+
2073
+ ### Field-Level `.admin()` and `.form()`
2074
+
2075
+ ```ts
2076
+ f.text().admin({
2077
+ type: "password", // override the input type
2078
+ autoComplete: "new-password",
2079
+ placeholder: "Enter password",
2080
+ })
2081
+
2082
+ f.object({ ... }).form(({ f }) => ({
2083
+ fields: [
2084
+ { type: "section", label: "Address", layout: "grid", columns: 2,
2085
+ fields: [f.street, f.city, f.zip, f.country] },
2086
+ ],
2087
+ }))
2088
+ ```
2089
+
2090
+ ---
2091
+
2092
+ ## 27. Admin Fields, Views, Widgets, Blocks, Components
2093
+
2094
+ ### Fields (Client-Side)
2095
+
2096
+ Each server field type maps to a React component in the admin:
2097
+
2098
+ | Field Type | Component | Cell (in tables) |
2099
+ | -------------- | ------------------- | ---------------- |
2100
+ | `text` | `TextField` | `TextCell` |
2101
+ | `textarea` | `TextareaField` | `TextCell` |
2102
+ | `number` | `NumberField` | primitive |
2103
+ | `email` | `EmailField` | primitive |
2104
+ | `url` | `UrlField` | primitive |
2105
+ | `boolean` | `BooleanField` | primitive |
2106
+ | `date` | `DateField` | primitive |
2107
+ | `datetime` | `DatetimeField` | primitive |
2108
+ | `time` | `TimeField` | primitive |
2109
+ | `select` | `SelectField` | primitive |
2110
+ | `relation` | `RelationField` | `RelationCell` |
2111
+ | `upload` | `UploadField` | `UploadCell` |
2112
+ | `richText` | `RichTextField` | — |
2113
+ | `json` | `JsonField` | — |
2114
+ | `object` | `ObjectField` | `ObjectCell` |
2115
+ | `array` | `ArrayField` | `ArrayCell` |
2116
+ | `blocks` | `BlocksField` | `BlocksCell` |
2117
+ | `assetPreview` | `AssetPreviewField` | — |
2118
+
2119
+ Register custom field types:
2120
+
2121
+ ```ts
2122
+ import { field } from "@questpie/admin/client";
2123
+
2124
+ export const colorPicker = field("color", {
2125
+ component: lazy(() => import("./color-picker-field")),
2126
+ cell: lazy(() => import("./color-picker-cell")),
2127
+ });
2128
+ ```
2129
+
2130
+ ### Views
2131
+
2132
+ | View | Kind | Description |
2133
+ | ------------------ | ------ | ---------------------------------------- |
2134
+ | `collection-table` | `list` | Default table view for collection lists |
2135
+ | `collection-form` | `form` | Default form view for collection editing |
2136
+ | `global-form` | `form` | Default form view for globals |
2137
+
2138
+ Register custom views:
2139
+
2140
+ ```ts
2141
+ import { view } from "@questpie/admin/client";
2142
+
2143
+ export const kanbanView = view("kanban-board", {
2144
+ kind: "list",
2145
+ component: lazy(() => import("./kanban-view")),
2146
+ });
2147
+ ```
2148
+
2149
+ ### Widgets (Dashboard)
2150
+
2151
+ 8 built-in widget types (use via `d.stats()`, `d.chart()`, etc. in dashboard config):
2152
+
2153
+ | Widget Type | Dashboard Builder | Description |
2154
+ | -------------- | ----------------------- | ----------------------------------------------- |
2155
+ | `stats` | `d.stats({...})` | Single number with optional trend indicator |
2156
+ | `value` | `d.value({...})` | Custom formatted value with subtitle/footer |
2157
+ | `chart` | `d.chart({...})` | Line, bar, area, or pie chart |
2158
+ | `recentItems` | `d.recentItems({...})` | List of recent records from a collection |
2159
+ | `quickActions` | `d.quickActions({...})` | Grid of action buttons |
2160
+ | `table` | `d.table({...})` | Mini data table |
2161
+ | `timeline` | `d.timeline({...})` | Chronological event list |
2162
+ | `progress` | `d.progress({...})` | Progress bar toward a target |
2163
+ | `custom` | `d.custom({...})` | Fully custom widget component (user-registered) |
2164
+
2165
+ ### Blocks (Content Builder)
2166
+
2167
+ Blocks are user-defined content components for the page builder (`f.blocks()` field). Defined on the server, rendered on both admin and frontend.
2168
+
2169
+ ```ts
2170
+ // Server: blocks/hero.ts
2171
+ import { block } from "#questpie/factories";
2172
+
2173
+ export const hero = block("hero")
2174
+ .fields(({ f }) => ({
2175
+ heading: f.text(255).required(),
2176
+ subheading: f.textarea(),
2177
+ image: f.upload(),
2178
+ cta: f.object({
2179
+ label: f.text(100),
2180
+ url: f.url(),
2181
+ }),
2182
+ }))
2183
+ .admin(({ c }) => ({
2184
+ label: "Hero Banner",
2185
+ icon: c.icon({ name: "ph:image" }),
2186
+ category: { label: "Sections" },
2187
+ }))
2188
+ .form(({ f }) => ({
2189
+ fields: [f.heading, f.subheading, f.image, f.cta],
2190
+ }))
2191
+ .allowChildren(0)
2192
+ .prefetch(async ({ values, db }) => {
2193
+ // Pre-load data for frontend rendering
2194
+ return { imageUrl: values.image ? await getSignedUrl(values.image) : null };
2195
+ });
2196
+ ```
2197
+
2198
+ ```tsx
2199
+ // Client: admin/blocks/hero.tsx
2200
+ export default function HeroBlock({ values, data }: BlockRendererProps) {
2201
+ return (
2202
+ <section className="hero">
2203
+ <h1>{values.heading}</h1>
2204
+ <p>{values.subheading}</p>
2205
+ {data?.imageUrl && <img src={data.imageUrl} />}
2206
+ </section>
2207
+ );
2208
+ }
2209
+ ```
2210
+
2211
+ Block content is stored as:
2212
+
2213
+ ```ts
2214
+ {
2215
+ _tree: [{ id: "abc", type: "hero", children: [] }],
2216
+ _values: { abc: { heading: "Welcome", subheading: "..." } },
2217
+ _data: { abc: { imageUrl: "https://..." } }, // server-prefetched
2218
+ }
2219
+ ```
2220
+
2221
+ ### Components (Server-Driven)
2222
+
2223
+ Components bridge serializable server config to React rendering. Two built-in:
2224
+
2225
+ | Name | Description |
2226
+ | ------- | --------------------------------- |
2227
+ | `icon` | Renders Iconify icons (fast path) |
2228
+ | `badge` | Renders a styled badge |
2229
+
2230
+ Used everywhere in config via `ComponentReference`:
2231
+
2232
+ ```ts
2233
+ // In .admin(), sidebar, dashboard, actions:
2234
+ { type: "icon", props: { name: "ph:users" } }
2235
+ { type: "badge", props: { label: "New", variant: "success" } }
2236
+ ```
2237
+
2238
+ Or via the `c` proxy in callbacks:
2239
+
2240
+ ```ts
2241
+ .admin(({ c }) => ({
2242
+ icon: c.icon({ name: "ph:users" }),
2243
+ }))
2244
+ ```
2245
+
2246
+ Register custom components:
2247
+
2248
+ ```ts
2249
+ import { component } from "@questpie/admin/client";
2250
+
2251
+ export const statusIndicator = component("statusIndicator", {
2252
+ component: lazy(() => import("./status-indicator")),
2253
+ });
2254
+ ```
2255
+
2256
+ ---
2257
+
2258
+ ## 28. Codegen & the .generated/ Directory
2259
+
2260
+ Codegen is the bridge between your file conventions and the runtime. It runs via `questpie generate` (one-shot) or `questpie dev` (watch mode).
2261
+
2262
+ ### What Gets Generated
2263
+
2264
+ | File | Contents |
2265
+ | -------------------------------- | ------------------------------------------------------------------------------------ |
2266
+ | `server/.generated/index.ts` | `createApp()` call, `App` type, `AppCollections`, `AppGlobals`, module augmentations |
2267
+ | `server/.generated/factories.ts` | Typed `collection()`, `global()`, `block()`, config factories with plugin extensions |
2268
+ | `admin/.generated/client.ts` | Admin client config object for `<AdminLayoutProvider>` |
2269
+
2270
+ ### When to Regenerate
2271
+
2272
+ - **File added or removed** in a convention directory → regenerate
2273
+ - **File content changed** → no regeneration needed (codegen uses `typeof import(...)` which is stable)
2274
+ - `questpie dev` watches for add/remove only
2275
+
2276
+ ### The Process
2277
+
2278
+ ```
2279
+ 1. Read questpie.config.ts → runtime config
2280
+ 2. Read modules.ts → extract codegen plugins from each module
2281
+ 3. Build target graph: core plugin + extracted plugins → merged category declarations
2282
+ 4. Discover files: scan convention directories
2283
+ 5. Generate templates:
2284
+ - index.ts (createApp + type augmentation)
2285
+ - factories.ts (typed builders with extensions)
2286
+ - client.ts (admin config)
2287
+ ```
2288
+
2289
+ ### For NPM Module Packages
2290
+
2291
+ Module packages use `packageConfig()` instead of `runtimeConfig()`:
2292
+
2293
+ ```ts
2294
+ // questpie.config.ts in a module package
2295
+ import { packageConfig } from "questpie/cli";
2296
+
2297
+ export default packageConfig({
2298
+ modulesDir: "src/server/modules",
2299
+ modulePrefix: "questpie",
2300
+ plugins: [adminPlugin()],
2301
+ });
2302
+ ```
2303
+
2304
+ This generates `.generated/module.ts` for each module subdirectory (no `createApp`, no runtime config — just a typed module object).
2305
+
2306
+ ---
2307
+
2308
+ ## 29. Adapters & Infrastructure
2309
+
2310
+ QuestPie uses pluggable adapters for all infrastructure concerns.
2311
+
2312
+ ### Database
2313
+
2314
+ Always **PostgreSQL** via **Drizzle ORM**. Two client options:
2315
+
2316
+ - `Bun.SQL` (production — native Bun PostgreSQL driver)
2317
+ - `PGlite` (testing — in-process SQLite-compatible Postgres)
2318
+
2319
+ ### HTTP Adapters
2320
+
2321
+ The core provides `createFetchHandler()` which works with any framework that supports the `fetch` API:
2322
+
2323
+ ```ts
2324
+ // Standalone (Bun.serve)
2325
+ Bun.serve({ fetch: createFetchHandler(app) });
2326
+
2327
+ // TanStack Start / Vinxi
2328
+ export default createFetchHandler(app, { basePath: "/api" });
2329
+
2330
+ // Hono
2331
+ import { questpieMiddleware } from "@questpie/hono/server";
2332
+ app.use("/api/*", questpieMiddleware(questpieApp));
2333
+
2334
+ // Elysia
2335
+ import { questpieElysia } from "@questpie/elysia/server";
2336
+ app.use(questpieElysia(questpieApp));
2337
+
2338
+ // Next.js
2339
+ import { questpieNextRouteHandlers } from "@questpie/next";
2340
+ export const { GET, POST, PUT, PATCH, DELETE } =
2341
+ questpieNextRouteHandlers(questpieApp);
2342
+ ```
2343
+
2344
+ ### Storage Adapters
2345
+
2346
+ | Config | Driver |
2347
+ | ----------------------------- | ------------------------------ |
2348
+ | Default | `FSDriver` (local `./uploads`) |
2349
+ | `QUESTPIE_STORAGE_*` env vars | S3-compatible (auto-detected) |
2350
+ | `{ driver: customDriver }` | Any FlyDrive `DriverContract` |
2351
+
2352
+ ### Queue Adapters
2353
+
2354
+ | Adapter | Description |
2355
+ | ------------------------- | ------------------------------------ |
2356
+ | `pgBossAdapter()` | PostgreSQL-based job queue (pg-boss) |
2357
+ | `CloudflareQueuesAdapter` | Cloudflare Workers Queues |
2358
+
2359
+ ### Search Adapters
2360
+
2361
+ | Adapter | Description |
2362
+ | ----------------------- | --------------------------------- |
2363
+ | `PostgresSearchAdapter` | pg_trgm + full-text search |
2364
+ | `PgVectorSearchAdapter` | Hybrid semantic search (pgvector) |
2365
+
2366
+ ### Realtime Adapters
2367
+
2368
+ | Adapter | Description |
2369
+ | ----------------------- | ------------------------ |
2370
+ | Default | Polling (2s interval) |
2371
+ | `pgNotifyAdapter()` | PostgreSQL LISTEN/NOTIFY |
2372
+ | `redisStreamsAdapter()` | Redis Streams |
2373
+
2374
+ ### KV Adapters
2375
+
2376
+ | Adapter | Description |
2377
+ | ------------------ | -------------------- |
2378
+ | `MemoryKVAdapter` | In-process (default) |
2379
+ | `IORedisKVAdapter` | Redis-backed |
2380
+
2381
+ ### Email Adapters
2382
+
2383
+ | Adapter | Description |
2384
+ | ----------------------------- | --------------------------- |
2385
+ | `ConsoleAdapter` | Logs to console |
2386
+ | `SmtpAdapter` | Nodemailer SMTP |
2387
+ | `createEtherealSmtpAdapter()` | Auto-generated test account |
2388
+
2389
+ ### Logger
2390
+
2391
+ | Adapter | Description |
2392
+ | ------- | ----------------------------------- |
2393
+ | Default | Pino-based structured logging |
2394
+ | Custom | Implement `LoggerAdapter` interface |
2395
+
2396
+ ---
2397
+
2398
+ ## 30. TypeScript Type Augmentation
2399
+
2400
+ QuestPie uses `declare global { namespace Questpie { ... } }` for type extensions. Codegen fills these in automatically.
2401
+
2402
+ ### Augmentation Points
2403
+
2404
+ ```ts
2405
+ declare global {
2406
+ namespace Questpie {
2407
+ interface AppContext {} // Add custom context keys
2408
+ interface Registry {} // Type catalog (collections, globals, routes, etc.)
2409
+ interface ViewsRegistry {} // Admin view autocomplete
2410
+ interface ComponentsRegistry {} // Admin component autocomplete
2411
+ interface FieldTypesMap {} // Field factory autocomplete (f.*)
2412
+ }
2413
+ }
2414
+ ```
2415
+
2416
+ ### What Codegen Produces
2417
+
2418
+ The generated `index.ts` augments these interfaces so that:
2419
+
2420
+ - `ctx.collections.posts` is fully typed with the actual field shapes
2421
+ - `ctx.globals.siteSettings` is fully typed
2422
+ - `f.text()`, `f.relation()`, etc. have full autocomplete
2423
+ - `ctx.queue.jobName.publish(payload)` validates payload types
2424
+ - `client.collections.posts.find()` returns typed results
2425
+
2426
+ ### Plugin Config Augmentation
2427
+
2428
+ Plugins extend the module config type via `declare module "questpie"`:
2429
+
2430
+ ```ts
2431
+ declare module "questpie" {
2432
+ interface AppStateConfig {
2433
+ admin?: AdminConfigInput;
2434
+ openapi?: OpenApiConfig;
2435
+ }
2436
+ }
2437
+ ```
2438
+
2439
+ ---
2440
+
2441
+ ## 31. CLI Reference
2442
+
2443
+ ```bash
2444
+ bun questpie <command> [options]
2445
+ ```
2446
+
2447
+ | Command | Description |
2448
+ | ------------------------ | --------------------------------------------------- |
2449
+ | `generate` | Run codegen (one-shot) |
2450
+ | `dev` | Watch mode codegen (re-runs on file add/remove) |
2451
+ | `push` | Push schema to DB (dev only, like drizzle-kit push) |
2452
+ | `migrate` / `migrate:up` | Run pending migrations |
2453
+ | `migrate:generate` | Generate migration from schema diff |
2454
+ | `migrate:down` | Rollback migrations |
2455
+ | `migrate:status` | Show migration status |
2456
+ | `migrate:reset` | Rollback all |
2457
+ | `migrate:fresh` | Reset + re-run all migrations |
2458
+ | `seed` | Run pending seeds |
2459
+ | `seed:undo` | Undo executed seeds |
2460
+ | `seed:status` | Show seed status |
2461
+ | `seed:reset` | Reset seed tracking |
2462
+ | `add <type> <name>` | Scaffold new entity file |
2463
+ | `add --list` | Show available entity types |
2464
+
2465
+ Global options: `-c, --config <path>` (default: `questpie.config.ts`)
2466
+
2467
+ ### Scaffolding
2468
+
2469
+ ```bash
2470
+ bun questpie add collection blog-posts
2471
+ bun questpie add global site-settings
2472
+ bun questpie add route get-stats
2473
+ bun questpie add job send-newsletter
2474
+ bun questpie add service analytics
2475
+ bun questpie add email welcome
2476
+ bun questpie add seed default-data
2477
+ bun questpie add migration add-categories
2478
+ bun questpie add block hero # with admin plugin
2479
+ ```
2480
+
2481
+ ---
2482
+
2483
+ ## 32. How It All Connects — The Big Picture
2484
+
2485
+ ### Data Flow: Server Startup
2486
+
2487
+ ```
2488
+ questpie.config.ts "Infrastructure: here's my DB, storage, email..."
2489
+
2490
+ modules.ts "Packages: use admin, openapi, audit..."
2491
+
2492
+ codegen Scans collections/, globals/, routes/, etc.
2493
+
2494
+ .generated/index.ts Bundles everything into createApp() call
2495
+
2496
+ createApp()
2497
+ ├─ resolveModules() Flatten module tree depth-first
2498
+ ├─ mergeModuleIntoState() Merge all contributions (later wins)
2499
+ ├─ new Questpie(config) Create the app instance
2500
+ └─ _initServices() Boot: db, auth, storage, queue, email, kv, logger, search, realtime
2501
+
2502
+ createFetchHandler(app) Compile all routes into a trie-based dispatcher
2503
+
2504
+ Ready to serve requests
2505
+ ```
2506
+
2507
+ ### Data Flow: HTTP Request
2508
+
2509
+ ```
2510
+ HTTP Request
2511
+
2512
+ createFetchHandler (trie match)
2513
+
2514
+ Resolve session (Better Auth)
2515
+
2516
+ Resolve locale (header / cookie)
2517
+
2518
+ Build AppContext { db, session, collections, globals, queue, email, ... }
2519
+
2520
+ Route handler / Collection CRUD
2521
+
2522
+ Access control check
2523
+
2524
+ Hooks: beforeOperation → beforeValidate → beforeChange
2525
+
2526
+ DB operation (Drizzle)
2527
+
2528
+ Hooks: afterChange → afterRead
2529
+
2530
+ Realtime event (written to outbox)
2531
+
2532
+ HTTP Response (JSON / SuperJSON)
2533
+ ```
2534
+
2535
+ ### Data Flow: Admin Panel
2536
+
2537
+ ```
2538
+ Browser loads /admin
2539
+
2540
+ AdminLayoutProvider receives pre-built AdminState
2541
+ (fields, views, pages, widgets, blocks, components, translations)
2542
+
2543
+ Sidebar rendered from adminConfig.sidebar
2544
+
2545
+ User clicks "Posts"
2546
+
2547
+ Fetch GET /api/posts/schema → CollectionSchema (server-driven UI config)
2548
+
2549
+ Look up "collection-table" view in AdminState.views
2550
+
2551
+ Render table with schema-driven columns, filters, sort, actions
2552
+
2553
+ User clicks a row
2554
+
2555
+ Fetch GET /api/posts/{id}
2556
+
2557
+ Look up "collection-form" view in AdminState.views
2558
+
2559
+ Render form with schema-driven field layout, sections, tabs, sidebar
2560
+
2561
+ Each field renders via AdminState.fields[fieldType].component
2562
+
2563
+ Validation via buildZodFromIntrospection() (client-side Zod)
2564
+
2565
+ Submit PATCH /api/posts/{id}
2566
+
2567
+ Server hooks + access control + DB write + realtime event
2568
+ ```
2569
+
2570
+ ### Data Flow: Module Contribution
2571
+
2572
+ ```
2573
+ Module author writes:
2574
+ collections/invoices.ts → collection("invoices").fields(...)
2575
+ routes/create-checkout.ts → route().post().handler(...)
2576
+ jobs/retry-payment.ts → job({ name: "retryPayment", ... })
2577
+
2578
+ questpie generate --module → .generated/module.ts
2579
+
2580
+ Published to npm as @my-org/billing-module
2581
+
2582
+ User adds to modules.ts:
2583
+ import { billingModule } from "@my-org/billing-module";
2584
+ export default [adminModule, billingModule] as const;
2585
+
2586
+ questpie generate → merges into .generated/index.ts
2587
+
2588
+ At runtime: billingModule's collections, routes, jobs are part of the app
2589
+ ```
2590
+
2591
+ ### The Primitive Hierarchy
2592
+
2593
+ ```
2594
+ runtimeConfig() Infrastructure (DB, storage, email, queue, ...)
2595
+
2596
+ module() Packaging unit (groups related entities)
2597
+ ├── collection() Data table with CRUD, hooks, access, versioning
2598
+ │ ├── f.text() Field definitions (each → DB column + validation + UI)
2599
+ │ ├── f.relation() Relationships between collections
2600
+ │ ├── .hooks() Lifecycle callbacks
2601
+ │ ├── .access() Permission rules
2602
+ │ ├── .admin() Admin panel metadata ← admin plugin
2603
+ │ ├── .list() List view configuration ← admin plugin
2604
+ │ ├── .form() Form view configuration ← admin plugin
2605
+ │ ├── .preview() Live preview ← admin plugin
2606
+ │ └── .actions() Server actions ← admin plugin
2607
+ ├── global() Singleton document (settings, config)
2608
+ ├── route() Custom HTTP endpoint
2609
+ ├── job() Background task
2610
+ ├── service() Injectable dependency
2611
+ ├── email() Email template
2612
+ ├── block() Content builder block ← admin plugin
2613
+ ├── migration() DB schema change
2614
+ ├── seed() DB seed data
2615
+ ├── appConfig() App-level config (locale, access, hooks)
2616
+ ├── authConfig() Auth config (Better Auth options)
2617
+ └── adminConfig() Admin config (sidebar, dashboard, branding) ← admin plugin
2618
+ ```
2619
+
2620
+ ### Environment Variables
2621
+
2622
+ | Variable | Purpose | Default |
2623
+ | ----------------------------- | --------------------- | ----------------------- |
2624
+ | `DATABASE_URL` | PostgreSQL connection | (required) |
2625
+ | `APP_URL` | Application URL | `http://localhost:3000` |
2626
+ | `BETTER_AUTH_SECRET` | Auth signing secret | — |
2627
+ | `QUESTPIE_DB` | Cloud DB override | — |
2628
+ | `QUESTPIE_APP_URL` | Cloud URL override | — |
2629
+ | `QUESTPIE_SECRET` | Cloud secret override | — |
2630
+ | `QUESTPIE_STORAGE_ENDPOINT` | S3 endpoint | — |
2631
+ | `QUESTPIE_STORAGE_BUCKET` | S3 bucket | — |
2632
+ | `QUESTPIE_STORAGE_REGION` | S3 region | — |
2633
+ | `QUESTPIE_STORAGE_ACCESS_KEY` | S3 access key | — |
2634
+ | `QUESTPIE_STORAGE_SECRET_KEY` | S3 secret key | — |
2635
+
2636
+ ---
2637
+
2638
+ ## Quick Reference: All Primitives
2639
+
2640
+ | Primitive | Factory | Import From | Purpose |
2641
+ | -------------- | ------------------------------------ | -------------------------- | ---------------------------- |
2642
+ | Collection | `collection(name)` | `#questpie/factories` | Data table + CRUD |
2643
+ | Global | `global(name)` | `#questpie/factories` | Singleton document |
2644
+ | Field | `f.text()`, `f.number()`, ... | `.fields()` callback | Column definition |
2645
+ | Field Type | `fieldType(name, config)` | `questpie` | Custom field type |
2646
+ | Route | `route()` | `questpie` | HTTP endpoint |
2647
+ | Service | `service()` | `questpie` | Injectable dependency |
2648
+ | Job | `job({...})` | `questpie` | Background task |
2649
+ | Email | `email({...})` | `questpie` | Email template |
2650
+ | Block | `block(name)` | `#questpie/factories` | Content builder block |
2651
+ | Migration | `migration({...})` | `questpie` | DB schema change |
2652
+ | Seed | `seed({...})` | `questpie` | DB seed data |
2653
+ | Module | `module({...})` | `questpie` | Packaging unit |
2654
+ | Runtime Config | `runtimeConfig({...})` | `questpie` | Infrastructure config |
2655
+ | App Config | `appConfig({...})` | `questpie` | Locale, access, hooks |
2656
+ | Auth Config | `authConfig({...})` | `questpie` | Better Auth options |
2657
+ | Admin Config | `adminConfig({...})` | `#questpie/factories` | Sidebar, dashboard, branding |
2658
+ | View | `view(name, {kind, component})` | `@questpie/admin/client` | Admin view component |
2659
+ | Widget | `widget(name, {component})` | `@questpie/admin/client` | Dashboard widget |
2660
+ | Component | `component(name, {component})` | `@questpie/admin/client` | Server-driven component |
2661
+ | Client | `createClient<App>(config)` | `questpie/client` | Frontend API client |
2662
+ | Query Options | `createQuestpieQueryOptions(client)` | `@questpie/tanstack-query` | TanStack Query integration |
2663
+
2664
+ > **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.