create-questpie 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.mjs +244 -30
  2. package/package.json +1 -1
  3. package/skills/questpie/AGENTS.md +310 -103
  4. package/skills/questpie/SKILL.md +196 -84
  5. package/skills/questpie/coverage.json +213 -0
  6. package/skills/questpie/references/auth.md +119 -4
  7. package/skills/questpie/references/business-logic.md +126 -56
  8. package/skills/questpie/references/crud-api.md +231 -29
  9. package/skills/questpie/references/data-modeling.md +26 -6
  10. package/skills/questpie/references/extend.md +98 -7
  11. package/skills/questpie/references/field-types.md +14 -2
  12. package/skills/questpie/references/infrastructure-adapters.md +207 -32
  13. package/skills/questpie/references/mcp.md +147 -0
  14. package/skills/questpie/references/multi-tenancy.md +1 -2
  15. package/skills/questpie/references/production.md +218 -53
  16. package/skills/questpie/references/quickstart.md +31 -18
  17. package/skills/questpie/references/rules.md +140 -13
  18. package/skills/questpie/references/sandbox.md +110 -0
  19. package/skills/questpie/references/tanstack-query.md +34 -11
  20. package/skills/questpie/references/type-inference.md +167 -0
  21. package/skills/questpie/references/workflows.md +155 -0
  22. package/skills/questpie-admin/AGENTS.md +141 -68
  23. package/skills/questpie-admin/SKILL.md +96 -63
  24. package/skills/questpie-admin/references/blocks.md +28 -4
  25. package/skills/questpie-admin/references/custom-ui.md +1 -1
  26. package/skills/questpie-admin/references/views.md +21 -5
  27. package/templates/tanstack-start/AGENTS.md +15 -8
  28. package/templates/tanstack-start/CLAUDE.md +12 -5
  29. package/templates/tanstack-start/README.md +7 -6
  30. package/templates/tanstack-start/package.json +1 -0
  31. package/templates/tanstack-start/src/lib/query-client.ts +10 -1
  32. package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
  33. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
  34. package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
  35. package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
  36. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
  37. package/templates/tanstack-start/src/routes/admin/$.tsx +12 -1
  38. package/templates/tanstack-start/src/routes/admin/index.tsx +12 -5
  39. package/templates/tanstack-start/src/routes/api/$.ts +1 -2
  40. package/templates/tanstack-start/vite.config.ts +2 -2
@@ -16,6 +16,14 @@ Access rules are defined per-collection via `.access()`. Each operation accepts
16
16
 
17
17
  When no `.access()` is defined, all operations default to `({ session }) => !!session` — **authenticated users only**. You must explicitly set `read: true` for public collections.
18
18
 
19
+ Every operation resolves through the same chain, with no hidden framework grants above your config:
20
+
21
+ 1. Collection/global `.access()` rule for that operation
22
+ 2. App-level `defaultAccess` (`appConfig({ access })` in `config/app.ts`)
23
+ 3. Framework fallback: require session
24
+
25
+ A deny-all `defaultAccess` (`{ read: false, create: false, update: false, delete: false }`) closes the entire REST surface — including upload-row listing and schema/meta introspection — until collections opt in.
26
+
19
27
  ### Collection Access
20
28
 
21
29
  ```ts
@@ -26,7 +34,7 @@ export default collection("posts")
26
34
  .fields(({ f }) => ({
27
35
  title: f.text().label("Title").required(),
28
36
  content: f.richText().label("Content"),
29
- author: f.relation("users"),
37
+ author: f.relation("user"),
30
38
  }))
31
39
  .access({
32
40
  read: true, // Public read
@@ -38,12 +46,34 @@ export default collection("posts")
38
46
 
39
47
  ### Operations
40
48
 
41
- | Operation | When checked |
42
- | --------- | ---------------------------- |
43
- | `read` | Listing and fetching records |
44
- | `create` | Creating new records |
45
- | `update` | Updating existing records |
46
- | `delete` | Deleting records |
49
+ | Operation | When checked |
50
+ | ------------ | ---------------------------------------------------------------- |
51
+ | `read` | Listing and fetching records |
52
+ | `create` | Creating new records |
53
+ | `update` | Updating existing records |
54
+ | `delete` | Deleting records |
55
+ | `transition` | Workflow stage transitions (falls back to `update`) |
56
+ | `serve` | Upload file bytes by key (`GET /:collection/files/:key`) |
57
+ | `introspect` | Schema/meta routes (`GET /:collection/{schema,meta}`) |
58
+
59
+ Two operations have specialized chains:
60
+
61
+ - **`serve`** (upload collections): `serve` → explicit collection `read`
62
+ (row-aware, `ctx.data` is the upload row) → `defaultAccess.serve` → allow.
63
+ `defaultAccess.read` is deliberately NOT consulted — listing rows and
64
+ fetching bytes by key are distinct permissions. `visibility: "public"`
65
+ means bytes are servable by key; `"private"` files always require the
66
+ signed token in addition to any serve rule.
67
+ - **`introspect`**: `introspect` → `defaultAccess.introspect` → visible iff
68
+ at least one CRUD operation is allowed for the current user. Create-only
69
+ public collections keep their validation schema readable; deny-all apps
70
+ expose no schemas. Denied requests get 401 (anonymous) or 403
71
+ (authenticated).
72
+
73
+ `f.upload()` fields populate through the PARENT row's read decision — a
74
+ publicly readable gallery shows its assets (with `url`) to anonymous readers
75
+ even when the assets collection itself is unlistable. Field-level read rules
76
+ on the upload collection still apply inside population.
47
77
 
48
78
  ### Global Access
49
79
 
@@ -83,11 +113,78 @@ Return a where clause object instead of a boolean to restrict operations to matc
83
113
 
84
114
  Access functions receive `AppContext` with these properties:
85
115
 
86
- | Property | Description |
87
- | ------------- | --------------------------------------- |
88
- | `session` | Current auth session (null if unauthed) |
89
- | `db` | Database instance |
90
- | `collections` | Typed collection API |
116
+ | Property | Description |
117
+ | ------------- | ------------------------------------------------------------ |
118
+ | `session` | Current auth session (null if unauthed) |
119
+ | `db` | Database instance |
120
+ | `collections` | Typed collection API |
121
+ | `request` | Current HTTP `Request` (headers, URL) |
122
+ | `data` | The existing row — typed, non-optional in `update`/`delete` rules |
123
+ | `input` | Typed insert shape in `create` rules; typed patch in `update` rules |
124
+ | _extensions_ | Keys returned by `appConfig({ context })`, flat (see below) |
125
+
126
+ `data`/`input` are typed **per operation** by the builder — no casts, no annotations inside the defining collection. For shared rule helpers and every other "I need type X" case, see `references/type-inference.md`.
127
+
128
+ ### Derived Request Context in Rules
129
+
130
+ `appConfig({ context })` runs once per HTTP request; its result arrives **flat** on every access rule ctx (collections, globals, routes, field access, transitions), typed by inference:
131
+
132
+ ```ts
133
+ // config/app.ts
134
+ export default appConfig({
135
+ context: async ({ request }) => ({
136
+ workspaceId: request.headers.get("x-workspace") || null,
137
+ }),
138
+ });
139
+
140
+ // collections/projects.ts — destructure flat, narrow before use
141
+ .access({
142
+ read: ({ workspaceId }) =>
143
+ workspaceId ? { workspace: workspaceId } : false,
144
+ })
145
+ ```
146
+
147
+ Extensions are typed `Partial<…>` — absent for non-HTTP contexts (jobs, seeds, system scripts), so rules must handle `undefined`. See `references/multi-tenancy.md` for the full pattern (membership validation, closure memoization, scope UI).
148
+
149
+ Access functions may be async. Use `request` for request-scoped checks such as headers, tenant scope, CAPTCHA tokens, or signed public form tokens:
150
+
151
+ ```ts
152
+ import type { AccessContext } from "questpie";
153
+ import { ApiError } from "questpie/errors";
154
+ import { isAdminRequest } from "@questpie/admin/shared";
155
+
156
+ // AccessContext is the sanctioned shared-helper param — never hand-roll a
157
+ // structural ctx type (see references/type-inference.md)
158
+ async function canCreatePublicSubmission({ request, session }: AccessContext) {
159
+ if (session?.user) return true;
160
+ if (request && isAdminRequest(request)) {
161
+ throw ApiError.unauthorized();
162
+ }
163
+
164
+ const token = request?.headers.get("x-captcha-token");
165
+ const valid = token ? await verifyCaptchaToken(token) : false;
166
+ if (valid) return true;
167
+
168
+ throw ApiError.forbidden({
169
+ operation: "create",
170
+ resource: "public_submissions",
171
+ reason: "CAPTCHA verification failed",
172
+ });
173
+ }
174
+
175
+ export default collection("public_submissions")
176
+ .fields(({ f }) => ({
177
+ message: f.textarea().required(),
178
+ }))
179
+ .access({
180
+ read: false,
181
+ create: canCreatePublicSubmission,
182
+ });
183
+ ```
184
+
185
+ For public anti-abuse checks, bypass already authenticated users before requiring a CAPTCHA token. Admin-origin requests should not be asked for CAPTCHA either, but remember that `isAdminRequest()` is a caller-intent signal, not authentication; if an admin-origin request reaches this rule without a session, fail it as unauthorized instead of accepting it.
186
+
187
+ Prefer throwing `ApiError.*` from access rules when callers need a specific structured error response. Returning `false` is fine for generic denial, but it produces the default forbidden message.
91
188
 
92
189
  ### System Access Mode
93
190
 
@@ -136,7 +233,7 @@ import { collection } from "#questpie/factories";
136
233
 
137
234
  export default collection("appointments")
138
235
  .fields(({ f }) => ({
139
- customer: f.relation("users"),
236
+ customer: f.relation("user"),
140
237
  barber: f.relation("barbers"),
141
238
  service: f.relation("services"),
142
239
  scheduledAt: f.datetime().required(),
@@ -204,6 +301,17 @@ export default collection("appointments")
204
301
  | `db` | All hooks | Database instance |
205
302
  | `session` | All hooks | Current auth session |
206
303
  | `services` | All hooks | Custom services from `services/` |
304
+ | _extensions_ | All hooks | `appConfig({ context })` result, flat (HTTP requests only) |
305
+
306
+ Derived request context also reaches hooks and any nested code via `getContext<App>()` — including CRUD calls a hook triggers (AsyncLocalStorage carries it):
307
+
308
+ ```ts
309
+ .hooks({
310
+ beforeChange: async ({ data, operation, workspaceId }) => {
311
+ if (operation === "create" && workspaceId) data.workspace = workspaceId;
312
+ },
313
+ })
314
+ ```
207
315
 
208
316
  ### Context-First Pattern
209
317
 
@@ -322,6 +430,25 @@ Live preview sessions use token-based authentication. When a preview iframe load
322
430
  - Access rules (`.access()`) still apply to all data fetched during preview, including prefetched relations and block data.
323
431
  - Row-level access (AccessWhere) filters are enforced even in preview context — a user cannot preview records they cannot read.
324
432
 
433
+ ### Workflow Published Reads
434
+
435
+ For publishable collections that use workflow stages, do not use `read: true` when public client or HTTP access is available. Gate anonymous reads to the published stage:
436
+
437
+ ```ts
438
+ .access({
439
+ read: ({ session, input }) => {
440
+ if (session?.user) return true;
441
+ return input?.stage === "published";
442
+ },
443
+ create: ({ session }) => !!session?.user,
444
+ update: ({ session }) => !!session?.user,
445
+ delete: ({ session }) => !!session?.user,
446
+ transition: ({ session }) => !!session?.user,
447
+ })
448
+ ```
449
+
450
+ Public frontend code must pass `stage: "published"`. Preview/draft-mode reads may omit `stage` only when the request has an authorized editor session.
451
+
325
452
  ### System Access and Preview
326
453
 
327
454
  Do not use `accessMode: "system"` to serve preview data. Preview requests should go through normal session-based access, with the preview token resolving to the editor's session. This ensures previewed content respects the same visibility rules as the final published page.
@@ -0,0 +1,110 @@
1
+ # Sandboxed Code Execution (ctx.executor)
2
+
3
+ Use the executor when an app must run **untrusted or dynamically-authored code** — agent-written scripts, user plugins, knowledge mini-apps — under a default-deny capability model. `ctx.executor.run()` is the primitive (top-level on AppContext; there is no `ctx.sandbox`).
4
+
5
+ Unconfigured = disabled: without an `executor` key in `questpie.config.ts`, `ctx.executor.run` throws a clear "not configured" error. An app that never runs dynamic code simply does not configure it.
6
+
7
+ ## Two Isolation Modes
8
+
9
+ | Mode | Runs in | For |
10
+ | --- | --- | --- |
11
+ | `"sandboxed"` (default) | fresh, hardened **Deno** subprocess per request (scoped net/import, fs/env/run/ffi denied, memory bound) | untrusted code (user/AI mini-apps) |
12
+ | `"trusted"` | in-process (Bun) with a soft timeout | code you already own (code-mode agents, scheduled scripts) |
13
+
14
+ Untrusted-by-default: omitting `isolation` means `"sandboxed"`; trusted callers opt in explicitly.
15
+
16
+ ## Install And Configure
17
+
18
+ The sandboxed adapter comes from the opt-in `@questpie/sandbox` package; the engine is a standalone Deno service your app reaches over HTTP (the app ships no Deno):
19
+
20
+ ```bash
21
+ bun add @questpie/sandbox
22
+ ```
23
+
24
+ ```ts
25
+ // questpie.config.ts
26
+ import { httpSandboxAdapter } from "@questpie/sandbox/adapter";
27
+ import { runtimeConfig } from "questpie/app";
28
+
29
+ export default runtimeConfig({
30
+ executor: {
31
+ sandboxed: httpSandboxAdapter({
32
+ url: process.env.SANDBOX_URL ?? "http://127.0.0.1:8787",
33
+ }),
34
+ // TRUSTED internal URL of this app's own broker endpoint — required only
35
+ // for the untrusted app-bindings path. NEVER derive from request Host.
36
+ brokerUrl: process.env.SANDBOX_BROKER_URL,
37
+ // defaultTimeoutMs: 10_000,
38
+ },
39
+ });
40
+ ```
41
+
42
+ `executor.trusted` defaults to the built-in in-process adapter — override only to customize.
43
+
44
+ ## Running Code
45
+
46
+ The guest source must `export default` a function of `input`:
47
+
48
+ ```ts
49
+ const result = await ctx.executor.run({
50
+ source: `export default async function (input) {
51
+ const res = await fetch("https://api.example.com/data?since=" + input.since);
52
+ const data = await res.json();
53
+ return { count: data.length };
54
+ }`,
55
+ capabilities: {
56
+ net: ["api.example.com"], // fetch() egress allowlist
57
+ timeoutMs: 5_000,
58
+ memoryMb: 128,
59
+ },
60
+ input: { since: "2026-01-01" },
61
+ });
62
+ // → { ok: true, output: { count: 42 }, logs: [...], ms: 312 }
63
+ ```
64
+
65
+ Result shape: `{ ok, output?, logs, error?, timedOut?, ms? }`.
66
+
67
+ ## The Capability Model
68
+
69
+ Every run declares a manifest; anything not granted is denied (default-deny):
70
+
71
+ | Axis | Grants | Enforced by |
72
+ | --- | --- | --- |
73
+ | `net` | `fetch()` host allowlist (`host[:port]`) | sandbox engine |
74
+ | `import` | remote module-import host allowlist (independent of `net`) | sandbox engine |
75
+ | `timeoutMs` / `memoryMb` | hard wall-clock / per-guest memory bounds | sandbox engine |
76
+ | `files` | read/write path globs into the file store | bindings broker |
77
+ | `data.collections` | per-collection verbs (`read`/`create`/`update`/`delete`) | bindings broker |
78
+ | `data.globals` / `data.stores` | per-global and per-`document_store`-namespace verbs | bindings broker |
79
+ | `services` / `jobs` / `workflows` | allowed service names / enqueueable jobs / triggerable workflows | bindings broker |
80
+
81
+ `secrets: Record<string, string>` injects secrets into the guest without embedding them in source.
82
+
83
+ ## App Bindings (the `questpie` Proxy)
84
+
85
+ A plain `ctx.executor.run` is **compute-only** (plus granted `net`). To let the guest reach app data, the caller passes an `appBindings` target plus `brokerUrl` — the service mints a per-run scoped token, and the guest's `globalThis.questpie` proxy RPCs through a host **broker** that enforces the capability manifest per call and dispatches under a non-privileged principal (never `system`):
86
+
87
+ ```ts
88
+ // inside the guest source — only the granted surface resolves
89
+ const posts = await questpie.collections.posts.find({ limit: 10 });
90
+ const file = await questpie.files.read({ path: "company/data/report.json" });
91
+ ```
92
+
93
+ The broker endpoint is a route the host app mounts (product layers like Autopilot's mini-app runner do this); the guest never imports your app. For trusted in-process runs, `bindings` injects host globals directly instead.
94
+
95
+ ## Deployment
96
+
97
+ The sandbox engine runs as its own service/container reachable at `SANDBOX_URL`; `brokerUrl` must point at the app's own loopback/internal address (the supervisor is trusted) — never at anything request-derived:
98
+
99
+ ```bash
100
+ deno run --allow-net --allow-read packages/sandbox/src/sandbox-server.ts
101
+ ```
102
+
103
+ ## Rules
104
+
105
+ - Do not use the executor for trusted first-party logic — routes, jobs, and services are the right tools.
106
+ - Never grant `net`/`import` hosts or data verbs a run does not need; capabilities are per-run, not global.
107
+ - Never pass `isolation: "trusted"` for code you did not author — there is no sandbox in that mode.
108
+ - Source `brokerUrl` from config/env only; a request-derived broker URL lets a spoofed Host exfiltrate the per-run token.
109
+
110
+ Full reference: docs page `backend/business-logic/code-execution`.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: questpie-tanstack-query
3
- description: QUESTPIE TanStack Query integration - createQuestpieQueryOptions option builders, useQuery useMutation queryOptions mutationOptions, collections globals routes, streamedQuery SSE realtime subscriptions, batch helpers, type inference AppConfig createClient, React data fetching caching, framework adapters TanStack Start Next.js Hono Elysia, frontend client SDK querying where orderBy pagination with select
3
+ description:
4
+ QUESTPIE TanStack Query integration - createQuestpieQueryOptions option builders, useQuery useMutation queryOptions mutationOptions, collections globals routes, streamedQuery SSE realtime subscriptions, batch helpers, type inference AppConfig createClient, React data fetching caching, framework adapters TanStack Start Next.js Hono Elysia, frontend client SDK querying where orderBy pagination with select
4
5
  - questpie-core
5
6
  ---
6
7
 
@@ -401,11 +402,11 @@ const post = await client.collections.posts.findOne({
401
402
  with: { author: true },
402
403
  });
403
404
  await client.collections.posts.create({ title: "Hello", status: "draft" });
404
- await client.collections.posts.update({
405
+ await client.collections.posts.updateById({
405
406
  id: "abc",
406
407
  data: { status: "published" },
407
408
  });
408
- await client.collections.posts.delete({ id: "abc" });
409
+ await client.collections.posts.deleteById({ id: "abc" });
409
410
  const settings = await client.globals.siteSettings.get();
410
411
  const result = await client.routes.createBooking({
411
412
  barberId: "abc",
@@ -416,11 +417,22 @@ client.setLocale("sk"); // Set locale for localized content
416
417
 
417
418
  ## Realtime
418
419
 
419
- Pass `{ realtime: true }` as the second argument to `find()`, `count()`, or `get()` to enable SSE-based live updates. Requires a realtime adapter in `questpie.config.ts`:
420
+ Pass `{ realtime: true }` as the **typed** second argument (`RealtimeQueryConfig`) to `find()`, `count()`, or `get()` initial data via a normal fetch, then the server pushes full access-controlled snapshots on every matching change. `findOne()` and `findVersions()` have no realtime form (a second argument there is a compile error).
421
+
422
+ ```tsx
423
+ const { data } = useQuery(
424
+ q.collections.posts.find(
425
+ { where: { status: "published" }, limit: 20 },
426
+ { realtime: true },
427
+ ),
428
+ );
429
+ ```
430
+
431
+ Works zero-config (2s polling); add a realtime adapter for instant push:
420
432
 
421
433
  ```ts
422
- import { runtimeConfig } from "questpie";
423
- import { pgNotifyAdapter } from "questpie";
434
+ import { runtimeConfig } from "questpie/app";
435
+ import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
424
436
 
425
437
  export default runtimeConfig({
426
438
  realtime: {
@@ -429,7 +441,16 @@ export default runtimeConfig({
429
441
  });
430
442
  ```
431
443
 
432
- Channel patterns: `collections:<name>:*` (all changes), `collections:<name>:<id>` (specific record), `globals:<name>`.
444
+ Subscriptions are query-shaped topic objects (`{ resourceType, resource, where?, with? }`) — there are no channel strings. Outside React, use the typed live form of the same query: `client.collections.posts.live(options, onSnapshot)` / `liveIter(options)` (see AGENTS.md §19 Realtime).
445
+
446
+ To build those topic objects yourself — e.g. manual cache invalidation or a raw `client.realtime.subscribe` call that must match the topic a query subscribed with — use the exported builders instead of hand-writing the shape:
447
+
448
+ ```ts
449
+ import { buildCollectionTopic, buildGlobalTopic } from "@questpie/tanstack-query"; // re-exported from questpie/client
450
+
451
+ const topic = buildCollectionTopic("posts", { where: { status: "published" }, limit: 20 });
452
+ const settingsTopic = buildGlobalTopic("siteSettings");
453
+ ```
433
454
 
434
455
  For multi-instance deployments, create a Redis client and use `redisStreamsAdapter({ client })`.
435
456
 
@@ -439,7 +460,7 @@ For multi-instance deployments, create a Redis client and use `redisStreamsAdapt
439
460
 
440
461
  ```ts title="src/routes/api/$.ts"
441
462
  import { createAPIFileRoute } from "@tanstack/react-start/api";
442
- import { createFetchHandler } from "questpie";
463
+ import { createFetchHandler } from "questpie/http";
443
464
  import { app } from "#questpie";
444
465
  const handler = createFetchHandler(app, { basePath: "/api" });
445
466
  export const Route = createAPIFileRoute("/api/$")({
@@ -448,12 +469,14 @@ export const Route = createAPIFileRoute("/api/$")({
448
469
  });
449
470
  ```
450
471
 
451
- **Next.js**: `import { questpieNextRouteHandlers } from "@questpie/next"` -- export `GET`, `POST`, `PATCH`, `DELETE` from `app/api/[...slug]/route.ts`.
472
+ **Next.js**: `import { questpieNextRouteHandlers } from "@questpie/next"` -- export `GET`, `POST`, `PATCH`, `DELETE` from `app/api/[...slug]/route.ts`. The lower-level `questpieNext(app, config)` returns a single fetch-style handler.
452
473
 
453
474
  **Hono**: `import { questpieHono } from "@questpie/hono/server"` -- `server.route("/api", questpieHono(app))`.
454
475
 
455
476
  **Elysia**: `import { questpieElysia } from "@questpie/elysia/server"` -- `.use(questpieElysia(app, { basePath: "/api" }))`.
456
477
 
478
+ For server-side calls in the same process (SSR loaders, tests), `createClientFromHono` (`@questpie/hono/client`) and `createClientFromEden` (`@questpie/elysia/client`) build the typed client over the live server instance instead of HTTP.
479
+
457
480
  ## Common Mistakes
458
481
 
459
482
  ### HIGH: Creating the QUESTPIE client without proper base URL
@@ -495,8 +518,8 @@ const { docs } = await client.collections.posts.find({ limit: 10 });
495
518
  Collection changes do not auto-refresh when realtime is enabled but no adapter is configured. Add a realtime adapter in `questpie.config.ts`:
496
519
 
497
520
  ```ts
498
- import { runtimeConfig } from "questpie";
499
- import { pgNotifyAdapter } from "questpie";
521
+ import { runtimeConfig } from "questpie/app";
522
+ import { pgNotifyAdapter } from "questpie/adapters/pg-notify";
500
523
 
501
524
  export default runtimeConfig({
502
525
  realtime: {
@@ -0,0 +1,167 @@
1
+ # Type Inference Reference
2
+
3
+ The schema is the single source of types. If you are hand-writing a type that restates a schema (a row shape, a session shape, a payload), stop — there is a sanctioned inference one-liner for it. Hand-rolled structural types drift silently (real schema `string | null` vs hand-rolled `string | undefined`) and structural mirrors of the CRUD generics produce deep error walls at every call site.
4
+
5
+ ## The Map — "I Need Type X"
6
+
7
+ | # | You need | Write exactly this | Notes |
8
+ | --- | --- | --- | --- |
9
+ | 1 | Row of **another** collection | `import type { CollectionDoc } from "#questpie"` → `CollectionDoc<"toys">` | Type-only import. See cycle rules below |
10
+ | 2 | Own row inside `.access()` / `.hooks()` | Nothing — `ctx.data` / `ctx.input` are already typed by the builder | Never name your own doc type inside the defining collection |
11
+ | 3 | Shared access-helper parameter | Collection-imported helper: `AccessContext` from `"questpie"`. Anywhere else: `AccessRuleContext<"posts">` from `#questpie` (narrows `ctx.data`) | See cycle rules below |
12
+ | 4 | Shared hook-helper parameter | `HookContext` from `"questpie"` (collection-imported) or `HookRuleContext<"posts">` from `#questpie` | Same rules as #3 |
13
+ | 5 | App/services in a function without a ctx param | `getContext<App>()` with `import type { App } from "#questpie"` | Type-only `App` import — no runtime cycle |
14
+ | 6 | Global doc | `import type { GlobalDoc } from "#questpie"` → `GlobalDoc<"siteSettings">` | Same cycle rules as `CollectionDoc` |
15
+ | 7 | Session / user shape | In handlers: `ctx.session?.user` is typed. Standalone: `import type { AppSession, AppSessionUser } from "#questpie"` | Generated from the app auth config |
16
+ | 8 | Route input/output in the handler | Nothing — inferred from `.schema()` / return type | |
17
+ | 9 | Route input/output standalone | `InferRouteInput<typeof def>` / `InferRouteOutput<typeof def>` / `InferRouteParams<typeof def>` from `questpie/types` | tRPC-style; `def` is the route file's default export |
18
+ | 10 | Client-side types | `createClient<AppConfig>()` — everything flows from the generic | See `references/tanstack-query.md` |
19
+ | 11 | Job payload in the handler | Nothing — `payload` is typed from `schema` | |
20
+ | 12 | Job payload standalone | `InferJobPayload<typeof jobDef>` from `questpie/queue` (or `z.infer<typeof jobDef.schema>`) | |
21
+ | 13 | `db` / `session` inside job/workflow handlers | Honest gap: generated job context types them `unknown` today | Use `collections` (typed) or narrow explicitly; do not restate schemas |
22
+ | 14 | Publishing jobs outside job files | `ctx.queue.<name>.publish(payload)` — payload typed | |
23
+ | 15 | Relation target autocomplete | Nothing — codegen populates `Questpie.CollectionKeys` from discovered files; `f.relation("…")` autocompletes after `questpie generate` | Plain strings always compile |
24
+ | 16 | Realtime payloads | `live()` / `liveIter()` snapshots are typed; raw `client.realtime.subscribe` data is untyped — annotate with `CollectionDoc<"posts">` | Typed realtime contract is planned |
25
+ | 17 | Env vars | `env.ts` / `env.client.ts` with `env()` — see `references/env.md` | Never `process.env.X!` |
26
+ | 18 | Field-level rule ctx (`.access({ fields })`) | `doc` is typed as the row, `user` is typed from the generated session — destructure, don't annotate | |
27
+ | 19 | Derived request context (tenant, role) | `appConfig({ context })` result is inferred and arrives flat on rules — see `references/rules.md` | |
28
+ | 20 | Select-option unions | `CollectionDoc<"events">["type"]` (server-side) | No client-safe union export yet; clients infer from SDK responses |
29
+
30
+ ## The Two Cycle Rules
31
+
32
+ Type inference flows through the generated index (`#questpie`), and collections are part of that graph. Two rules keep every inference path compiling:
33
+
34
+ **Rule 1 — inside the defining collection, trust builder inference.** `ctx.data` and `ctx.input` are already typed per operation (table below). Naming your own doc type (`CollectionDoc<"production_orders">` inside `collections/production-orders.ts`, or `ctx.data as OrderDoc`) forces TypeScript to resolve `typeof <own default export>` while that export's type is still being inferred — TS2456/TS7022, or worse, a silently degraded type.
35
+
36
+ **Rule 2 — helpers imported by collections must not import generated aliases, and must cut the inference loop with an explicit return annotation.** The verified pattern (from `examples/toy-factory-backend/src/questpie/server/lib/access.ts`):
37
+
38
+ ```ts
39
+ // lib/access.ts — imported by a collection, so:
40
+ // - the helper param is the package-level AccessContext (cycle-safe)
41
+ // - the return type is annotated explicitly with a CROSS-collection
42
+ // CollectionDoc (type-only) — this cut breaks the inference loop that
43
+ // otherwise forms when the helper dereferences ctx.collections back
44
+ // into the module graph (TS7022/TS2502 without it)
45
+ import type { AccessContext } from "questpie";
46
+ import type { CollectionDoc } from "#questpie";
47
+
48
+ export async function resolveOrderToy(
49
+ ctx: AccessContext,
50
+ toyId: string,
51
+ ): Promise<{ toy: CollectionDoc<"toys"> | null; userId: string | null }> {
52
+ const toy = await ctx.collections.toys.findOne(
53
+ { where: { id: toyId } },
54
+ { accessMode: "system" },
55
+ );
56
+ return { toy, userId: ctx.session?.user.id ?? null };
57
+ }
58
+
59
+ /** Narrow `data` structurally when the helper only reads a few fields. */
60
+ export function canCancelOrder(ctx: AccessContext<{ priority?: string | null }>) {
61
+ if (ctx.data?.priority === "rush") return !!ctx.session?.user;
62
+ return true;
63
+ }
64
+ ```
65
+
66
+ `ctx.app`, `ctx.collections`, and `ctx.session` are fully typed on `AccessContext` through the (lazily merged) AppContext augmentation — the explicit return annotation stays mandatory in collection-imported helpers (it cuts the inference loop).
67
+
68
+ Helpers **not** imported by any collection (scripts, routes, services, jobs) may freely use `CollectionDoc<K>` in parameters and locals — Rule 2 only binds files that collections import.
69
+
70
+ ## Per-Operation Access Rule Typing
71
+
72
+ `.access()` rules are typed per operation by the builder — no annotations, no casts:
73
+
74
+ | Rule | `ctx.data` | `ctx.input` |
75
+ | --- | --- | --- |
76
+ | `read` | not loaded (return `AccessWhere` to filter) | — |
77
+ | `create` | — (no row exists yet) | typed insert shape (pre-validation) |
78
+ | `update` | the existing row — **non-optional** | typed update patch |
79
+ | `delete` | the existing row — **non-optional** | — |
80
+ | `transition` / `serve` | the existing row — non-optional | — |
81
+
82
+ ```ts
83
+ export default collection("production_orders")
84
+ .fields(({ f }) => ({
85
+ toy: f.relation("toys").required(),
86
+ priority: f.select([{ value: "normal" }, { value: "rush" }]),
87
+ }))
88
+ .access({
89
+ create: ({ session, input }) => !!session && input?.priority !== "rush",
90
+ update: async (ctx) => {
91
+ ctx.data; // typed row, non-optional — no `as` cast, no isRecord() dance
92
+ ctx.input; // typed patch
93
+ return (await resolveOrderToy(ctx, ctx.data.toy)).userId !== null;
94
+ },
95
+ });
96
+ ```
97
+
98
+ The package-level helper types are exported from `questpie/types` (also re-exported from `questpie`): `AccessContext`, `RowAccessRule`, `AccessRule`, `AccessWhere`, `CollectionAccess`, `HookContext`, `FieldAccessRule`, `FieldAccessRuleContext`.
99
+
100
+ ## Typed `getContext<App>()`
101
+
102
+ For functions that need the app without threading a ctx parameter (and for Better Auth callbacks — see `references/auth.md`):
103
+
104
+ ```ts
105
+ import { getContext } from "questpie";
106
+ import type { App } from "#questpie"; // type-only — no runtime cycle
107
+
108
+ async function logActivity(action: string) {
109
+ const { app, session, locale } = getContext<App>();
110
+ await app.collections.activity_log.create({
111
+ user: session?.user.id,
112
+ action,
113
+ locale,
114
+ });
115
+ }
116
+ ```
117
+
118
+ Untyped `getContext()` returns the bare context; the `<App>` generic types `app`, `session`, and the derived request-context extensions.
119
+
120
+ ## Standalone Route And Job Types
121
+
122
+ ```ts
123
+ import type { InferRouteInput, InferRouteOutput } from "questpie/types";
124
+ import type { InferJobPayload } from "questpie/queue";
125
+ import createBooking from "../routes/create-booking";
126
+ import sendReminder from "../jobs/send-reminder";
127
+
128
+ type BookingInput = InferRouteInput<typeof createBooking>;
129
+ type BookingResult = InferRouteOutput<typeof createBooking>;
130
+ type ReminderPayload = InferJobPayload<typeof sendReminder>;
131
+ ```
132
+
133
+ ## Key Registries (Advanced, Optional)
134
+
135
+ Names-only registries give `f.relation()` target autocomplete without entering the type graph (no imports — they cannot cycle). Codegen does **not** populate them yet; augment manually when you want the autocomplete:
136
+
137
+ ```ts
138
+ // types/questpie-keys.d.ts (any ambient file)
139
+ declare global {
140
+ namespace Questpie {
141
+ interface CollectionKeys { toys: unknown; production_orders: unknown }
142
+ interface GlobalKeys { factorySettings: unknown }
143
+ interface JobKeys { sendReminder: unknown }
144
+ }
145
+ }
146
+ export {};
147
+ ```
148
+
149
+ `f.relation("toys")` then autocompletes, while arbitrary strings keep compiling (`(string & {})` fallback) — this is autocomplete, not strictness. `KnownCollectionKey` / `KnownGlobalKey` / `KnownJobKey` from `questpie/types` consume the registries in your own signatures.
150
+
151
+ ## Escape Hatches (When Inference Needs Help)
152
+
153
+ For columns whose value type the field cannot infer, stay declarative — see `references/field-types.md`:
154
+
155
+ - `.zod(schema)` — type **and** runtime validation (preferred for `f.json()`)
156
+ - `.$type<T>()` — type-only narrowing
157
+ - `.drizzle(fn)` — raw column builder; `$type` on it narrows the value type
158
+
159
+ ## Never Do
160
+
161
+ | Anti-pattern | Why | Instead |
162
+ | --- | --- | --- |
163
+ | Hand-rolled `EventDoc = { id: string; ownerUser?: string }` | Silent nullability drift vs the real schema | `CollectionDoc<"events">` (row 1) |
164
+ | `ctx.data as MemberDoc` inside own `.access()` | Builder already types it; self-key casts can cycle (TS2456) | Trust `ctx.data` (row 2) |
165
+ | Hand-rolled `CollectionsLike` / `AccessRuleCtx` ctx mirrors | Structural matching of CRUD generics → deep error walls, tsc 5.9 crashes | `AccessContext` param (row 3) |
166
+ | Module-level `app` singleton for callbacks | Import cycles; stale instance in tests | `getContext<App>()` (row 5) |
167
+ | Collection-imported helper returning unannotated `ctx.collections` results | TS7022/TS2502 self-reference | Explicit return annotation (Rule 2) |