create-questpie 2.0.3 → 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.
- package/dist/index.mjs +244 -30
- package/package.json +1 -1
- package/skills/questpie/AGENTS.md +299 -98
- package/skills/questpie/SKILL.md +50 -17
- package/skills/questpie/coverage.json +213 -0
- package/skills/questpie/references/auth.md +119 -4
- package/skills/questpie/references/business-logic.md +126 -56
- package/skills/questpie/references/crud-api.md +231 -29
- package/skills/questpie/references/data-modeling.md +22 -6
- package/skills/questpie/references/extend.md +34 -7
- package/skills/questpie/references/field-types.md +14 -2
- package/skills/questpie/references/infrastructure-adapters.md +207 -32
- package/skills/questpie/references/mcp.md +147 -0
- package/skills/questpie/references/multi-tenancy.md +1 -2
- package/skills/questpie/references/production.md +218 -53
- package/skills/questpie/references/quickstart.md +6 -8
- package/skills/questpie/references/rules.md +86 -21
- package/skills/questpie/references/sandbox.md +110 -0
- package/skills/questpie/references/tanstack-query.md +34 -11
- package/skills/questpie/references/type-inference.md +167 -0
- package/skills/questpie/references/workflows.md +155 -0
- package/skills/questpie-admin/AGENTS.md +47 -40
- package/skills/questpie-admin/SKILL.md +46 -39
- package/skills/questpie-admin/references/custom-ui.md +1 -1
- package/templates/tanstack-start/AGENTS.md +15 -8
- package/templates/tanstack-start/CLAUDE.md +12 -5
- package/templates/tanstack-start/README.md +7 -6
- package/templates/tanstack-start/package.json +1 -0
- package/templates/tanstack-start/src/questpie/admin/modules.ts +3 -1
- package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +10 -9
- package/templates/tanstack-start/src/questpie/server/config/auth.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/modules.ts +4 -5
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +2 -1
- package/templates/tanstack-start/src/routes/api/$.ts +1 -2
- 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("
|
|
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
|
|
42
|
-
|
|
|
43
|
-
| `read`
|
|
44
|
-
| `create`
|
|
45
|
-
| `update`
|
|
46
|
-
| `delete`
|
|
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,25 +113,49 @@ 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
|
|
91
|
-
| `request` | Current HTTP `Request` (headers, URL)
|
|
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).
|
|
92
148
|
|
|
93
149
|
Access functions may be async. Use `request` for request-scoped checks such as headers, tenant scope, CAPTCHA tokens, or signed public form tokens:
|
|
94
150
|
|
|
95
151
|
```ts
|
|
96
|
-
import {
|
|
152
|
+
import type { AccessContext } from "questpie";
|
|
153
|
+
import { ApiError } from "questpie/errors";
|
|
97
154
|
import { isAdminRequest } from "@questpie/admin/shared";
|
|
98
155
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
async function canCreatePublicSubmission({ request, session }: AccessCtx) {
|
|
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) {
|
|
105
159
|
if (session?.user) return true;
|
|
106
160
|
if (request && isAdminRequest(request)) {
|
|
107
161
|
throw ApiError.unauthorized();
|
|
@@ -179,7 +233,7 @@ import { collection } from "#questpie/factories";
|
|
|
179
233
|
|
|
180
234
|
export default collection("appointments")
|
|
181
235
|
.fields(({ f }) => ({
|
|
182
|
-
customer: f.relation("
|
|
236
|
+
customer: f.relation("user"),
|
|
183
237
|
barber: f.relation("barbers"),
|
|
184
238
|
service: f.relation("services"),
|
|
185
239
|
scheduledAt: f.datetime().required(),
|
|
@@ -247,6 +301,17 @@ export default collection("appointments")
|
|
|
247
301
|
| `db` | All hooks | Database instance |
|
|
248
302
|
| `session` | All hooks | Current auth session |
|
|
249
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
|
+
```
|
|
250
315
|
|
|
251
316
|
### Context-First Pattern
|
|
252
317
|
|
|
@@ -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:
|
|
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.
|
|
405
|
+
await client.collections.posts.updateById({
|
|
405
406
|
id: "abc",
|
|
406
407
|
data: { status: "published" },
|
|
407
408
|
});
|
|
408
|
-
await client.collections.posts.
|
|
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()`
|
|
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
|
-
|
|
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) |
|