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