@palbase/backend 3.0.0 → 5.0.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/{chunk-B7EUJP5W.js → chunk-2N4YNN6F.js} +113 -3
- package/dist/chunk-2N4YNN6F.js.map +1 -0
- package/dist/{chunk-PHAFZGHN.js → chunk-WUQO76NW.js} +26 -19
- package/dist/chunk-WUQO76NW.js.map +1 -0
- package/dist/db/index.cjs +117 -2
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +11 -1
- package/dist/{endpoint-DJ98tQd6.d.cts → endpoint-BEHjfvFH.d.cts} +99 -57
- package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-BEHjfvFH.d.ts} +99 -57
- package/dist/index-BTVdhfsb.d.ts +469 -0
- package/dist/index-mr3Co63T.d.cts +469 -0
- package/dist/index.cjs +356 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +80 -219
- package/dist/index.d.ts +80 -219
- package/dist/index.js +203 -21
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +34 -19
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +10 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +212 -14
- package/docs/database.md +40 -0
- package/docs/endpoints.md +110 -92
- package/docs/errors.md +37 -30
- package/docs/getting-started.md +64 -26
- package/docs/llms-full.txt +724 -312
- package/docs/llms.txt +1 -1
- package/docs/migrations.md +75 -73
- package/docs/routing.md +39 -45
- package/docs/schema.md +135 -23
- package/docs/services.md +13 -10
- package/package.json +2 -2
- package/dist/chunk-B7EUJP5W.js.map +0 -1
- package/dist/chunk-PHAFZGHN.js.map +0 -1
- package/dist/index-CXUs9iTQ.d.ts +0 -294
- package/dist/index-CZAwpQE1.d.cts +0 -294
package/docs/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Palbase Backend SDK (`@palbase/backend`)
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/… methods and `@Body`/`@Query`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, workers `(payload, meta)`, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). Not Express, not Supabase Edge Functions.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -12,10 +12,202 @@ Write your backend as TypeScript files. Palbase discovers them by path, runs
|
|
|
12
12
|
them inside the managed backend runtime, and exposes them as a typed HTTP API.
|
|
13
13
|
|
|
14
14
|
This is **not** Express, Fastify, or a Supabase Edge Function. There is no
|
|
15
|
-
`app.get(...)`, no manual route registration, no `import express`. You
|
|
16
|
-
|
|
15
|
+
`app.get(...)`, no manual route registration, no `import express`. You author
|
|
16
|
+
**class controllers** with method + parameter decorators (NestJS-style); the
|
|
17
|
+
runtime wires them up.
|
|
17
18
|
|
|
18
|
-
##
|
|
19
|
+
## AGENTS / AI codegen guide (read this first)
|
|
20
|
+
|
|
21
|
+
If you are an AI generating a Palbase backend, this is the contract. Get these
|
|
22
|
+
seven rules right and the deploy succeeds; get them wrong and it fails the
|
|
23
|
+
deploy gate or the TypeScript compile.
|
|
24
|
+
|
|
25
|
+
### Folder layout
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
|
|
29
|
+
models/<controller>/<endpoint>.ts # zod schemas — one folder per controller, one file per endpoint (+ shared.ts)
|
|
30
|
+
services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
|
|
31
|
+
db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The four folders above are the daily surface. These also exist (own docs, linked
|
|
35
|
+
below): `resources/` (external connections — [resources.md](./resources.md)),
|
|
36
|
+
`seeds/` (seed data), `jobs/` + `workers/` (background — [background.md](./background.md)),
|
|
37
|
+
`webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
|
|
38
|
+
|
|
39
|
+
### The 7 rules (checklist)
|
|
40
|
+
|
|
41
|
+
1. **`export default <ControllerClass>` is REQUIRED.** Every `controllers/*.controller.ts`
|
|
42
|
+
must default-export its `@Controller` class, or the deploy FAILS with a
|
|
43
|
+
"not a @Controller / non-controller export" error. Either `export default class X {}`
|
|
44
|
+
or a trailing `export default X;` — both work.
|
|
45
|
+
2. **Methods that call a service are `async` and return `Promise<T>`.** Services
|
|
46
|
+
`await Database`, so they return promises; a sync return type on an async body
|
|
47
|
+
is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
|
|
48
|
+
the body awaits.
|
|
49
|
+
3. **`@User()` (value) vs `UserT` (type) are two different imports.**
|
|
50
|
+
`import { User } from "@palbase/backend"` (the decorator) +
|
|
51
|
+
`import type { UserT } from "@palbase/backend"` (the user type). Write
|
|
52
|
+
`@User() user: UserT`. They are NOT the same symbol.
|
|
53
|
+
4. **The method's RETURN TYPE names the response schema.** Write
|
|
54
|
+
`: Promise<TodoSchema>` (or `: Promise<z.infer<typeof TodoSchema>>`) — codegen
|
|
55
|
+
+ the runtime read that named type to bind the 200 response zod schema. There
|
|
56
|
+
is no `@Returns` decorator. A body route with no named return type is a build
|
|
57
|
+
error; a method with no response body annotates `: Promise<void>` / `: void`.
|
|
58
|
+
5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
|
|
59
|
+
VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
|
|
60
|
+
`@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
|
|
61
|
+
as the type annotation).
|
|
62
|
+
6. **Errors are global throw classes.** `throw new Conflict("…")` / `NotFound` /
|
|
63
|
+
`BadRequest` / `Unauthorized` / `Forbidden` / `TooManyRequests` /
|
|
64
|
+
`PalError(status, code, msg)` — from a controller OR a service, with no `req`.
|
|
65
|
+
There is no per-route errors map and no `req.errors`.
|
|
66
|
+
7. **Auth is secure-by-default.** Every route needs a signed-in user unless it
|
|
67
|
+
opts out: `@Controller("/x", { auth: false })` (whole controller) or
|
|
68
|
+
`@Get("", { auth: false })` (one route). Cascade: route > controller >
|
|
69
|
+
default(required). On a public route inject `@OptionalUser() user: UserT | null`.
|
|
70
|
+
|
|
71
|
+
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
72
|
+
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
73
|
+
> and will not compile against `@palbase/backend` 4.
|
|
74
|
+
|
|
75
|
+
### Complete CRUD example (copy-pasteable, compiles)
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// models/todos/shared.ts — the response shape, reused across endpoints.
|
|
79
|
+
import { z } from "@palbase/backend";
|
|
80
|
+
export const TodoSchema = z.object({
|
|
81
|
+
id: z.string(),
|
|
82
|
+
title: z.string(),
|
|
83
|
+
completed: z.boolean(),
|
|
84
|
+
});
|
|
85
|
+
export type TodoSchema = z.infer<typeof TodoSchema>;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// models/todos/create.ts — the POST body.
|
|
90
|
+
import { z } from "@palbase/backend";
|
|
91
|
+
export const CreateTodoBody = z.object({ title: z.string().min(1) });
|
|
92
|
+
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// services/todo.service.ts — plain class + singleton. The real work.
|
|
97
|
+
import { Database, NotFound } from "@palbase/backend";
|
|
98
|
+
import type { TodoSchema } from "../models/todos/shared.js";
|
|
99
|
+
|
|
100
|
+
export class TodoService {
|
|
101
|
+
list(userId: string): Promise<TodoSchema[]> {
|
|
102
|
+
return Database.tables.todos.findMany({ user_id: userId });
|
|
103
|
+
}
|
|
104
|
+
create(userId: string, title: string): Promise<TodoSchema> {
|
|
105
|
+
return Database.tables.todos.insert({ user_id: userId, title });
|
|
106
|
+
}
|
|
107
|
+
async get(userId: string, id: string): Promise<TodoSchema> {
|
|
108
|
+
const t = await Database.tables.todos.findById(id);
|
|
109
|
+
if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
|
|
110
|
+
return t;
|
|
111
|
+
}
|
|
112
|
+
async remove(userId: string, id: string): Promise<void> {
|
|
113
|
+
const t = await Database.tables.todos.findById(id);
|
|
114
|
+
if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
|
|
115
|
+
await Database.tables.todos.delete(id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export const todoService = new TodoService();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// controllers/todos.controller.ts — class controller. Thin: delegates to the service.
|
|
123
|
+
import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
|
|
124
|
+
import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
|
|
125
|
+
import { todoService } from "../services/todo.service.js";
|
|
126
|
+
import { TodoSchema } from "../models/todos/shared.js";
|
|
127
|
+
import { CreateTodoBody } from "../models/todos/create.js";
|
|
128
|
+
|
|
129
|
+
@Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
|
|
130
|
+
export class TodosController {
|
|
131
|
+
private todos = todoService;
|
|
132
|
+
|
|
133
|
+
@Get("") // GET /todos → operationId getTodos
|
|
134
|
+
async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
|
|
135
|
+
return this.todos.list(user.id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Post("") // POST /todos → postTodos
|
|
139
|
+
async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
|
|
140
|
+
return this.todos.create(user.id, body.title);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@Get("/{id}") // GET /todos/{id} → getTodosById
|
|
144
|
+
async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
|
|
145
|
+
return this.todos.get(user.id, id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Delete("/{id}") // DELETE /todos/{id} → deleteTodosById; no body → : Promise<void>
|
|
149
|
+
async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
|
|
150
|
+
await this.todos.remove(user.id, id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default TodosController; // REQUIRED — the runtime loads the default export
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// db/schema.ts — config-as-code; the deploy auto-migrates additive changes.
|
|
159
|
+
import { defineSchema, uuid, text, boolean, timestamp, policy } from "@palbase/backend";
|
|
160
|
+
export default defineSchema({
|
|
161
|
+
tables: {
|
|
162
|
+
todos: {
|
|
163
|
+
columns: {
|
|
164
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
165
|
+
user_id: text().notNull(), // palauth user id (TEXT, e.g. "usr_…")
|
|
166
|
+
title: text().notNull(),
|
|
167
|
+
completed: boolean().default(false),
|
|
168
|
+
created_at: timestamp().defaultNow(),
|
|
169
|
+
},
|
|
170
|
+
rls: true, // every Database.* query runs as the request user
|
|
171
|
+
policies: [
|
|
172
|
+
policy("pb_owner_all")
|
|
173
|
+
.for("all")
|
|
174
|
+
.to("authenticated")
|
|
175
|
+
.using("user_id = (select auth.uid())")
|
|
176
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### operationId — what the generated clients call
|
|
184
|
+
|
|
185
|
+
The operationId is derived FLAT from the verb + full path (NOT the method name):
|
|
186
|
+
`GET /todos` → `getTodos`, `POST /todos` → `postTodos`, `GET /todos/{id}` →
|
|
187
|
+
`getTodosById`. The generated iOS/TS clients expose these as `pb.getTodos()`,
|
|
188
|
+
`pb.postTodos(...)`, `pb.getTodosById(id:)`. Rename the method freely; the
|
|
189
|
+
operationId is unaffected. Change the verb and the operationId changes — no file
|
|
190
|
+
rename needed.
|
|
191
|
+
|
|
192
|
+
### CLI workflow
|
|
193
|
+
|
|
194
|
+
- `palbase serve` — run `controllers/` locally with hot reload (proxies
|
|
195
|
+
`Database`/services to the deployed branch; runs `gen-types` on startup).
|
|
196
|
+
- `palbase gen-types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
|
|
197
|
+
`Database.tables.*` is typed (no import, no generic). Standalone version of what
|
|
198
|
+
`serve` runs.
|
|
199
|
+
- **Deploy is GitHub-native** — `git push` triggers the deploy (there is no
|
|
200
|
+
`palbase push`). Push a branch to deploy that branch.
|
|
201
|
+
- `palbase secret set NAME=value` / `palbase secret list` — branch-scoped
|
|
202
|
+
secrets (encrypted at rest), read in code via `process.env.NAME`.
|
|
203
|
+
- `palbase secret pull` / `palbase secret push` — sync the branch's env vars with
|
|
204
|
+
a local `.env.local` (gitignored). `pull` writes decrypted values for local dev
|
|
205
|
+
(merging local-only keys); `push` uploads changed keys (new keys default to
|
|
206
|
+
SECRET and need `--secret`/`--plain` classification, existing secrets are left
|
|
207
|
+
untouched unless `--force-secrets`).
|
|
208
|
+
- `tsconfig.json` needs `"experimentalDecorators": true` (the scaffold sets it).
|
|
209
|
+
|
|
210
|
+
## Mental model
|
|
19
211
|
|
|
20
212
|
Every handler type imports service singletons the same way:
|
|
21
213
|
|
|
@@ -27,7 +219,7 @@ The **only difference** is the trigger argument:
|
|
|
27
219
|
|
|
28
220
|
| You are writing… | Handler signature | Trigger arg |
|
|
29
221
|
|------------------|-------------------|-------------|
|
|
30
|
-
| **Endpoints** (`controllers/`
|
|
222
|
+
| **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
|
|
31
223
|
| **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
|
|
32
224
|
| **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
|
|
33
225
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
@@ -43,13 +235,16 @@ the imported singletons — not from `ctx` or any argument.
|
|
|
43
235
|
```
|
|
44
236
|
my-backend/
|
|
45
237
|
├── package.json # depends on @palbase/backend
|
|
46
|
-
├──
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
238
|
+
├── tsconfig.json # experimentalDecorators: true (required for the decorators)
|
|
239
|
+
├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
|
|
240
|
+
│ └── hello.controller.ts # @Controller("/hello") + @Get example
|
|
241
|
+
├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
|
|
242
|
+
│ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
|
|
243
|
+
├── services/ # plain classes/singletons your controllers call
|
|
51
244
|
├── db/schema.ts # table definitions (optional, enables typed DB)
|
|
52
245
|
├── db/migrations/ # explicit SQL migrations for type changes (optional)
|
|
246
|
+
├── resources/ # external connections, set up once at boot (optional)
|
|
247
|
+
├── seeds/ # seed data (optional)
|
|
53
248
|
├── workers/ # background job handlers (optional)
|
|
54
249
|
├── jobs/ # cron-scheduled jobs (optional)
|
|
55
250
|
├── hooks/ # auth/storage/document event hooks (optional)
|
|
@@ -57,27 +252,30 @@ my-backend/
|
|
|
57
252
|
└── middleware/ # cross-cutting request middleware (optional)
|
|
58
253
|
```
|
|
59
254
|
|
|
60
|
-
HTTP endpoints are **not** file-path routed. You author a
|
|
61
|
-
|
|
62
|
-
`
|
|
255
|
+
HTTP endpoints are **not** file-path routed. You author a class controller
|
|
256
|
+
(`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
|
|
257
|
+
`controllers/` mounts it. See [routing.md](./routing.md).
|
|
63
258
|
|
|
64
259
|
## Documentation
|
|
65
260
|
|
|
66
261
|
| Topic | File |
|
|
67
262
|
|-------|------|
|
|
68
263
|
| Getting started | [getting-started.md](./getting-started.md) |
|
|
69
|
-
|
|
|
70
|
-
| Endpoints
|
|
264
|
+
| Routing (class controllers) | [routing.md](./routing.md) |
|
|
265
|
+
| Endpoints (decorators) | [endpoints.md](./endpoints.md) |
|
|
71
266
|
| Database & transactions | [database.md](./database.md) |
|
|
72
267
|
| Schema & typed DB | [schema.md](./schema.md) |
|
|
73
268
|
| Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
|
|
74
269
|
| Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
|
|
270
|
+
| Resources (external connections) | [resources.md](./resources.md) |
|
|
75
271
|
| Errors | [errors.md](./errors.md) |
|
|
76
272
|
| Workers & Jobs | [background.md](./background.md) |
|
|
77
273
|
| Hooks & Webhooks | [events.md](./events.md) |
|
|
78
274
|
|
|
79
275
|
For AI coding tools: a single concatenated corpus is generated at
|
|
80
276
|
[`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
|
|
277
|
+
</content>
|
|
278
|
+
</invoke>
|
|
81
279
|
|
|
82
280
|
|
|
83
281
|
|
|
@@ -88,7 +286,7 @@ For AI coding tools: a single concatenated corpus is generated at
|
|
|
88
286
|
There is no CLI init command. A starter project is created for you when your
|
|
89
287
|
Palbase project is provisioned. You then edit the files locally and deploy them.
|
|
90
288
|
|
|
91
|
-
## package.json
|
|
289
|
+
## package.json + tsconfig.json
|
|
92
290
|
|
|
93
291
|
Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
94
292
|
|
|
@@ -105,121 +303,153 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
|
105
303
|
}
|
|
106
304
|
```
|
|
107
305
|
|
|
306
|
+
The controllers use **decorators**, so the `tsconfig.json` must set
|
|
307
|
+
`experimentalDecorators: true` (the scaffold ships it):
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"compilerOptions": {
|
|
312
|
+
"target": "ES2022",
|
|
313
|
+
"module": "ESNext",
|
|
314
|
+
"moduleResolution": "Bundler",
|
|
315
|
+
"strict": true,
|
|
316
|
+
"experimentalDecorators": true,
|
|
317
|
+
"noEmit": true
|
|
318
|
+
},
|
|
319
|
+
"include": ["controllers/**/*.ts", "models/**/*.ts", "services/**/*.ts", "db/**/*.ts", "*.d.ts"]
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
108
323
|
## Local dev loop
|
|
109
324
|
|
|
110
325
|
- `palbase serve` — run your backend locally with hot reload. It runs your
|
|
111
|
-
`controllers/` locally but proxies `Database
|
|
112
|
-
branch, so the branch must already be deployed (serve tells
|
|
113
|
-
if it isn't).
|
|
114
|
-
side of this.
|
|
326
|
+
`controllers/` locally but proxies `Database` and the service singletons to
|
|
327
|
+
the **deployed** branch, so the branch must already be deployed (serve tells
|
|
328
|
+
you to push first if it isn't). On startup it also runs `gen-types`. See
|
|
329
|
+
[migrations.md](./migrations.md) for the schema/migration side of this.
|
|
330
|
+
- `palbase gen-types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
|
|
331
|
+
`Database.tables.*` is typed (no import, no generic). Run it standalone after
|
|
332
|
+
editing the schema, or rely on `palbase serve` running it for you.
|
|
115
333
|
- **Deploy is GitHub-native** — there is no `palbase push`. Commit and
|
|
116
334
|
`git push` to your project's repo; the push triggers a deploy of the backend
|
|
117
335
|
runtime. Push a **branch** to deploy that branch instead of `main`.
|
|
118
336
|
|
|
119
337
|
## Your first endpoint
|
|
120
338
|
|
|
121
|
-
|
|
122
|
-
`handlers/hello.ts`:
|
|
339
|
+
An endpoint is a method on a class controller. Declare the schemas in `models/`:
|
|
123
340
|
|
|
124
341
|
```ts
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}),
|
|
134
|
-
});
|
|
342
|
+
// models/hello/greet.ts
|
|
343
|
+
import { z } from "@palbase/backend";
|
|
344
|
+
|
|
345
|
+
export const GreetQuery = z.object({ name: z.string().optional() });
|
|
346
|
+
export type GreetQuery = z.infer<typeof GreetQuery>;
|
|
347
|
+
|
|
348
|
+
export const HelloResponse = z.object({ message: z.string(), user: z.string().nullable() });
|
|
349
|
+
export type HelloResponse = z.infer<typeof HelloResponse>;
|
|
135
350
|
```
|
|
136
351
|
|
|
137
|
-
Then
|
|
352
|
+
Then write the controller in `controllers/hello.controller.ts`:
|
|
138
353
|
|
|
139
354
|
```ts
|
|
140
|
-
import {
|
|
141
|
-
import
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})
|
|
355
|
+
import { Controller, Get, Query, OptionalUser } from "@palbase/backend";
|
|
356
|
+
import type { UserT } from "@palbase/backend";
|
|
357
|
+
import { GreetQuery } from "../models/hello/greet.js";
|
|
358
|
+
import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
|
|
359
|
+
|
|
360
|
+
@Controller("/hello", { auth: false })
|
|
361
|
+
export default class HelloController {
|
|
362
|
+
@Get("")
|
|
363
|
+
greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
|
|
364
|
+
return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
146
367
|
```
|
|
147
368
|
|
|
148
|
-
This is served at `GET /hello`. The
|
|
149
|
-
|
|
150
|
-
|
|
369
|
+
This is served at `GET /hello`. The `@Query` schema validates the query string;
|
|
370
|
+
the method's RETURN TYPE (`: HelloResponse`) names the response schema — codegen
|
|
371
|
+
+ the runtime read it to validate and describe the 200 response. There is no
|
|
372
|
+
`@Returns` decorator; a body route with no named return type is a build error.
|
|
151
373
|
|
|
374
|
+
Two things every controller file MUST have:
|
|
152
375
|
|
|
376
|
+
- **A default export of the `@Controller` class.** Above it is `export default class
|
|
377
|
+
HelloController` (inline); the equivalent trailing form is `export class
|
|
378
|
+
HelloController {…}` then `export default HelloController;`. Without a default
|
|
379
|
+
export the deploy fails ("not a @Controller").
|
|
380
|
+
- **`async` + `Promise<T>` once the method awaits a service.** `greet` above is
|
|
381
|
+
synchronous (pure), so it returns `HelloResponse` directly. The moment a method
|
|
382
|
+
calls a service that awaits `Database`, make it `async` and return
|
|
383
|
+
`Promise<HelloResponse>`.
|
|
153
384
|
|
|
154
|
-
|
|
385
|
+
See [routing.md](./routing.md) and [endpoints.md](./endpoints.md) for the full
|
|
386
|
+
class-controller model.
|
|
155
387
|
|
|
156
|
-
# Routing
|
|
157
388
|
|
|
158
|
-
Routes are declared in code. A **handler** is one endpoint unit (schema + thin
|
|
159
|
-
logic) with NO route on it; a **controller** is a route map (method+path →
|
|
160
|
-
handler). Putting a controller file under `controllers/` mounts it — there is no
|
|
161
|
-
central router and no manual registration.
|
|
162
389
|
|
|
163
|
-
|
|
164
|
-
import { defineController, route } from "@palbase/backend";
|
|
165
|
-
```
|
|
390
|
+
<!-- ===== routing.md ===== -->
|
|
166
391
|
|
|
167
|
-
|
|
392
|
+
# Routing
|
|
168
393
|
|
|
169
|
-
|
|
170
|
-
|
|
394
|
+
Routes are declared in code with **class controllers**. A controller is a class
|
|
395
|
+
decorated with `@Controller(basePath)`; each route is a method decorated with
|
|
396
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`. Request input + context are injected
|
|
397
|
+
into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/`@User`/
|
|
398
|
+
…). Putting a controller file under `controllers/` mounts it — there is no
|
|
399
|
+
central router and no manual registration.
|
|
171
400
|
|
|
172
401
|
```ts
|
|
173
|
-
|
|
174
|
-
import { defineHandler, z } from "@palbase/backend";
|
|
175
|
-
import { placeService } from "../../services/place.service.js";
|
|
176
|
-
|
|
177
|
-
export default defineHandler({
|
|
178
|
-
auth: { required: true },
|
|
179
|
-
input: z.object({ lat: z.number(), lng: z.number() }),
|
|
180
|
-
output: z.object({ imported: z.number() }),
|
|
181
|
-
errors: { quotaExceeded: { status: 429, code: "quota_exceeded" } },
|
|
182
|
-
handler: (req) => placeService.importNearby(req.input.lat, req.input.lng),
|
|
183
|
-
});
|
|
402
|
+
import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
|
|
184
403
|
```
|
|
185
404
|
|
|
186
|
-
## Controllers —
|
|
405
|
+
## Controllers — class + method decorators
|
|
187
406
|
|
|
188
|
-
|
|
189
|
-
|
|
407
|
+
`@Controller(basePath)` marks the class and sets the mount path. Each route
|
|
408
|
+
method declares its verb + subpath; the real work lives in a `services/` class
|
|
409
|
+
(the controller method is thin).
|
|
190
410
|
|
|
191
411
|
```ts
|
|
192
412
|
// controllers/places.controller.ts
|
|
193
|
-
import {
|
|
194
|
-
import
|
|
195
|
-
import
|
|
196
|
-
import
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
413
|
+
import { Controller, Get, Post, Body, User } from "@palbase/backend";
|
|
414
|
+
import type { UserT } from "@palbase/backend";
|
|
415
|
+
import { placeService } from "../services/place.service.js";
|
|
416
|
+
import { ImportNearbyBody } from "../models/places/import.js";
|
|
417
|
+
import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
|
|
418
|
+
|
|
419
|
+
@Controller("/places")
|
|
420
|
+
export default class PlacesController {
|
|
421
|
+
@Post("/import")
|
|
422
|
+
importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
|
|
423
|
+
return placeService.importNearby(body.lat, body.lng, user.id);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@Get("/favorites", { auth: false })
|
|
427
|
+
listFavorites(): PlaceSchema[] {
|
|
428
|
+
return placeService.listFavorites();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
203
431
|
```
|
|
204
432
|
|
|
205
|
-
|
|
|
433
|
+
| Method name (sugar) | Verb | Full path | operationId (flat) |
|
|
206
434
|
|---|---|---|---|
|
|
207
435
|
| `importNearby` | POST | `/places/import` | `postPlacesImport` |
|
|
208
|
-
| `addFavorite` | POST | `/places/favorites` | `postPlacesFavorites` |
|
|
209
436
|
| `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
|
|
210
437
|
|
|
211
438
|
Rules:
|
|
212
439
|
|
|
213
440
|
- The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
|
|
214
|
-
- A `{segment}` in a path becomes a
|
|
215
|
-
-
|
|
216
|
-
|
|
441
|
+
- A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
|
|
442
|
+
- Input is declared with the parameter decorators — `@Body(schema)`,
|
|
443
|
+
`@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
|
|
444
|
+
the method's RETURN TYPE (`: PlaceSchema` or `: z.infer<typeof PlaceSchema>`):
|
|
445
|
+
codegen + the runtime read that named type to drive the OpenAPI 200 response.
|
|
446
|
+
A body route with no named return type is a build error — annotate `: void`
|
|
447
|
+
(or `: Promise<void>`) for no body.
|
|
217
448
|
- The operationId is derived FLAT from method + full path (`postPlacesImport`),
|
|
218
|
-
not from the
|
|
219
|
-
rename.
|
|
449
|
+
not from the method name. Change `@Post` → `@Put` — no file rename.
|
|
220
450
|
|
|
221
|
-
See [endpoints.md](./endpoints.md) for the full
|
|
222
|
-
|
|
451
|
+
See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
|
|
452
|
+
options, the parameter decorators, auth cascade, and error classes).
|
|
223
453
|
|
|
224
454
|
|
|
225
455
|
|
|
@@ -227,106 +457,139 @@ auth, input/output, errors, middleware) reference.
|
|
|
227
457
|
|
|
228
458
|
# Endpoints
|
|
229
459
|
|
|
230
|
-
An endpoint is a **
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
460
|
+
An endpoint is a **method on a class controller** — a class decorated with
|
|
461
|
+
`@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
|
|
462
|
+
`@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
|
|
463
|
+
automatically (see [routing.md](./routing.md)). Request input + context are
|
|
464
|
+
injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
|
|
465
|
+
`@User`/…), each piece direct — no `req` god-object. Services are reached via the
|
|
466
|
+
imported singletons (see [services.md](./services.md)).
|
|
235
467
|
|
|
236
|
-
##
|
|
468
|
+
## A controller
|
|
237
469
|
|
|
238
470
|
```ts
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
471
|
+
// controllers/rooms.controller.ts
|
|
472
|
+
import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
|
|
473
|
+
import type { UserT } from "@palbase/backend";
|
|
474
|
+
import { CreateRoomBody } from "../models/rooms/create.js";
|
|
475
|
+
import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
|
|
476
|
+
|
|
477
|
+
@Controller("/rooms")
|
|
478
|
+
export default class RoomsController {
|
|
479
|
+
@Post("")
|
|
480
|
+
async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
|
|
481
|
+
const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
|
|
482
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
483
|
+
}
|
|
252
484
|
|
|
253
|
-
|
|
485
|
+
@Get("/{id}")
|
|
486
|
+
async getOne(@Param("id") id: string): Promise<RoomSchema> {
|
|
487
|
+
const room = await Database.findById("rooms", id);
|
|
488
|
+
if (!room) throw new NotFound("Room does not exist", "room_not_found");
|
|
489
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
```
|
|
254
493
|
|
|
255
|
-
|
|
256
|
-
|-------|------|-------|
|
|
257
|
-
| `req.input` | inferred from `input` schema | request body for POST/PUT/PATCH; `{}` otherwise |
|
|
258
|
-
| `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
|
|
259
|
-
| `req.query` | `Record<string,string>` | parsed query string |
|
|
260
|
-
| `req.headers` | `Record<string,string>` | lowercase keys |
|
|
261
|
-
| `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
|
|
262
|
-
| `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
|
|
263
|
-
| `req.file` | `FileContext \| null` | uploaded file, if any |
|
|
264
|
-
| `req.method` | `string` | the HTTP method |
|
|
265
|
-
| `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
|
|
266
|
-
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
494
|
+
**Two non-negotiables** (the most common codegen mistakes):
|
|
267
495
|
|
|
268
|
-
|
|
496
|
+
1. **`export default <Controller>` is REQUIRED.** Above it is
|
|
497
|
+
`export default class RoomsController`; the trailing form `export class
|
|
498
|
+
RoomsController {…}` + `export default RoomsController;` is equivalent. Without
|
|
499
|
+
a default export the deploy aborts with a "not a @Controller / non-controller
|
|
500
|
+
export" error — the runtime loads the file's default export.
|
|
501
|
+
2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
|
|
502
|
+
returns promises, so a body that `await`s it cannot have a sync return type
|
|
503
|
+
(`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
|
|
504
|
+
`async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
|
|
505
|
+
with no `await` may stay synchronous.)
|
|
269
506
|
|
|
270
|
-
##
|
|
507
|
+
## Method decorators
|
|
271
508
|
|
|
272
|
-
|
|
273
|
-
opts out. Omitting `auth` means AUTH REQUIRED — a forgotten `auth` fails safe
|
|
274
|
-
(401), never silently public. Mark a route PUBLIC with `auth: { required: false }`.
|
|
509
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
|
|
275
510
|
|
|
276
511
|
```ts
|
|
277
|
-
|
|
278
|
-
auth
|
|
279
|
-
|
|
280
|
-
|
|
512
|
+
interface RouteOptions {
|
|
513
|
+
auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
|
|
514
|
+
rateLimit?: { max: number; window: number }; // optional, per route
|
|
515
|
+
}
|
|
281
516
|
```
|
|
282
517
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
| `auth` value | `req.user` type |
|
|
287
|
-
|--------------|-----------------|
|
|
288
|
-
| omitted | `User` (secure-by-default) |
|
|
289
|
-
| `true` | `User` |
|
|
290
|
-
| `false` | `User \| null` (public) |
|
|
291
|
-
| `{ required: true }` | `User` |
|
|
292
|
-
| `{ required: false }` | `User \| null` (public) |
|
|
293
|
-
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
518
|
+
Input and output are NOT method-decorator options: input comes from the
|
|
519
|
+
parameter decorators, output from the method's return type. The method decorator
|
|
520
|
+
only carries route concerns (path, auth, rateLimit).
|
|
294
521
|
|
|
295
|
-
|
|
296
|
-
object with a `role` but no `required` key is treated as authenticated. When
|
|
297
|
-
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
522
|
+
## Parameter decorators
|
|
298
523
|
|
|
299
|
-
|
|
524
|
+
| Decorator | Injects | Typed by | OpenAPI |
|
|
525
|
+
|---|---|---|---|
|
|
526
|
+
| `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
|
|
527
|
+
| `@Query(schema)` | query params | `: T` | query parameters |
|
|
528
|
+
| `@Param("id")` | one path param | `: string` | path parameter |
|
|
529
|
+
| `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
|
|
530
|
+
| `@User()` | authenticated user | `: UserT` | — (from token) |
|
|
531
|
+
| `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
|
|
532
|
+
| `@Client()` | parsed client info | `: ClientInfo` | — |
|
|
533
|
+
| `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
|
|
534
|
+
| `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
|
|
535
|
+
|
|
536
|
+
`@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
|
|
537
|
+
developer writes the matching type annotation (same name). `UserT` is the
|
|
538
|
+
authenticated-user type (`{ id: string; email?: string; role: string; metadata:
|
|
539
|
+
Record<string, unknown> }`) — exported under the alias `UserT` because the value
|
|
540
|
+
name `User` is the `@User()` decorator.
|
|
541
|
+
|
|
542
|
+
## Output = return type
|
|
543
|
+
|
|
544
|
+
The success response is the method's RETURN TYPE — there is no `@Returns`
|
|
545
|
+
decorator. Annotate `: Promise<RoomSchema>` (or `: Promise<z.infer<typeof
|
|
546
|
+
RoomSchema>>`); codegen + the runtime read that named type to bind the OpenAPI
|
|
547
|
+
200 response zod schema. A body route with no named return type is a build
|
|
548
|
+
error. A method that returns nothing annotates `: void` (or `: Promise<void>`).
|
|
549
|
+
|
|
550
|
+
## Auth — controller default + route override cascade
|
|
551
|
+
|
|
552
|
+
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
553
|
+
out. Resolution order (most specific wins):
|
|
554
|
+
|
|
555
|
+
1. Route-level `@Post("", { auth })` — wins if present.
|
|
556
|
+
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
557
|
+
their own `auth`.
|
|
558
|
+
3. Default `true` (secure-by-default).
|
|
300
559
|
|
|
301
560
|
```ts
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
handler: async (req) => {
|
|
309
|
-
const room = await Database.insert("rooms", {
|
|
310
|
-
name: req.input.name,
|
|
311
|
-
capacity: req.input.capacity ?? null,
|
|
312
|
-
});
|
|
313
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
314
|
-
},
|
|
315
|
-
});
|
|
561
|
+
@Controller("/public", { auth: false }) // all routes default public
|
|
562
|
+
export default class PublicController {
|
|
563
|
+
@Get("/open") open(): Info { ... } // inherits → public
|
|
564
|
+
@Get("/secret", { auth: true }) // OVERRIDES → authed
|
|
565
|
+
secret(@User() u: UserT): Secret { ... }
|
|
566
|
+
}
|
|
316
567
|
```
|
|
317
568
|
|
|
318
|
-
|
|
569
|
+
| effective `auth` | `@User()` type to annotate |
|
|
570
|
+
|------------------|----------------------------|
|
|
571
|
+
| required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
|
|
572
|
+
| public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
|
|
573
|
+
|
|
574
|
+
The runtime always applies the resolved effective auth; the static type you
|
|
575
|
+
annotate is best-effort (a parameter decorator cannot see the controller default
|
|
576
|
+
at the type level).
|
|
577
|
+
|
|
578
|
+
## Errors — global throw classes
|
|
579
|
+
|
|
580
|
+
Throw an error class anywhere (controller OR service) — no `req`, no per-route
|
|
581
|
+
map. The runtime catches it and emits the standard envelope:
|
|
319
582
|
|
|
320
583
|
```ts
|
|
321
|
-
|
|
322
|
-
import { defineController, route } from "@palbase/backend";
|
|
323
|
-
import create from "../handlers/rooms/create.js";
|
|
584
|
+
import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
|
|
324
585
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
586
|
+
throw new Conflict("title taken"); // → 409
|
|
587
|
+
throw new NotFound(); // → 404 ("Not found")
|
|
588
|
+
throw new PalError(418, "teapot", "custom"); // → custom status/code
|
|
328
589
|
```
|
|
329
590
|
|
|
591
|
+
See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
592
|
+
|
|
330
593
|
## Middleware
|
|
331
594
|
|
|
332
595
|
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
@@ -343,22 +606,7 @@ export default defineMiddleware(async (ctx, next) => {
|
|
|
343
606
|
```
|
|
344
607
|
|
|
345
608
|
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
346
|
-
rest of the chain (other middleware, then the endpoint
|
|
347
|
-
the `ctx` model, not `req`.
|
|
348
|
-
|
|
349
|
-
To attach middleware to a specific handler, import it and list it in the
|
|
350
|
-
handler's `middleware` array:
|
|
351
|
-
|
|
352
|
-
```ts
|
|
353
|
-
import { defineHandler, z } from "@palbase/backend";
|
|
354
|
-
import logger from "../../middleware/logger.js";
|
|
355
|
-
|
|
356
|
-
export default defineHandler({
|
|
357
|
-
middleware: [logger],
|
|
358
|
-
output: z.object({ ok: z.boolean() }),
|
|
359
|
-
handler: async (req) => ({ ok: true }),
|
|
360
|
-
});
|
|
361
|
-
```
|
|
609
|
+
rest of the chain (other middleware, then the endpoint method).
|
|
362
610
|
|
|
363
611
|
|
|
364
612
|
|
|
@@ -438,6 +686,46 @@ await Database.transaction(async (tx) => {
|
|
|
438
686
|
The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
|
|
439
687
|
(no nested transaction). See [schema.md](./schema.md) for the full surface.
|
|
440
688
|
|
|
689
|
+
## Bypassing RLS — `Database.asService()`
|
|
690
|
+
|
|
691
|
+
When a table has [Row-Level Security](./schema.md#row-level-security-rls)
|
|
692
|
+
policies, every `Database.*` call runs as the request's verified user
|
|
693
|
+
(`authenticated`), so the database filters out rows the user's policies don't
|
|
694
|
+
allow. That is the secure default.
|
|
695
|
+
|
|
696
|
+
Sometimes you need to read or write **across all users** — an admin endpoint, a
|
|
697
|
+
background job that fans out notifications, a cleanup task. For that, call
|
|
698
|
+
`Database.asService()`. It returns a sibling client that runs as the
|
|
699
|
+
`service_role` (which has `BYPASSRLS`), exposing the exact same surface —
|
|
700
|
+
`tables`, the raw string ops, and `transaction`:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { Database } from "@palbase/backend";
|
|
704
|
+
|
|
705
|
+
// RLS-enforced (default): only the caller's own rows.
|
|
706
|
+
const mine = await Database.tables.todos.findMany({});
|
|
707
|
+
|
|
708
|
+
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
709
|
+
const all = await Database.asService().tables.todos.findMany({});
|
|
710
|
+
const rows = await Database.asService().query("SELECT count(*) FROM todos");
|
|
711
|
+
|
|
712
|
+
// A service-role transaction (the role is fixed for the whole tx):
|
|
713
|
+
await Database.asService().transaction(async (tx) => {
|
|
714
|
+
await tx.tables.todos.update(id, { done: true });
|
|
715
|
+
});
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Guidelines:
|
|
719
|
+
|
|
720
|
+
- **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
|
|
721
|
+
only where you genuinely need cross-user access. It is intentionally easy to
|
|
722
|
+
grep for in review.
|
|
723
|
+
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
724
|
+
`asService()`, and `tx` never exposes it — a transaction's role is fixed when
|
|
725
|
+
it begins. Use `Database.transaction(...)` for an authenticated tx and
|
|
726
|
+
`Database.asService().transaction(...)` for a service-role tx; you cannot mix
|
|
727
|
+
enforced and bypassed ops inside one interactive transaction.
|
|
728
|
+
|
|
441
729
|
|
|
442
730
|
|
|
443
731
|
<!-- ===== schema.md ===== -->
|
|
@@ -451,8 +739,9 @@ everywhere — by default, with no import and no generic.
|
|
|
451
739
|
|
|
452
740
|
## Defining a schema
|
|
453
741
|
|
|
454
|
-
The table NAME comes from the object key under `tables
|
|
455
|
-
|
|
742
|
+
The table NAME comes from the object key under `tables`. Each table value is an
|
|
743
|
+
object `{ columns, rls?, policies? }` — `columns` is required; `rls` and
|
|
744
|
+
`policies` enable [Row-Level Security](#row-level-security-rls).
|
|
456
745
|
|
|
457
746
|
```ts
|
|
458
747
|
import {
|
|
@@ -463,23 +752,29 @@ import {
|
|
|
463
752
|
export default defineSchema({
|
|
464
753
|
tables: {
|
|
465
754
|
rooms: {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
755
|
+
columns: {
|
|
756
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
757
|
+
name: text().notNull(),
|
|
758
|
+
capacity: integer().nullable(),
|
|
759
|
+
is_active: boolean().default(true),
|
|
760
|
+
created_at: timestamp().defaultNow(),
|
|
761
|
+
},
|
|
471
762
|
},
|
|
472
763
|
sessions: {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
764
|
+
columns: {
|
|
765
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
766
|
+
room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
|
|
767
|
+
user_id: uuid().notNull(),
|
|
768
|
+
data: jsonb().nullable(),
|
|
769
|
+
started_at: timestamp().defaultNow(),
|
|
770
|
+
},
|
|
478
771
|
},
|
|
479
772
|
orders: {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
773
|
+
columns: {
|
|
774
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
775
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
776
|
+
amount: integer().notNull(),
|
|
777
|
+
},
|
|
483
778
|
},
|
|
484
779
|
},
|
|
485
780
|
});
|
|
@@ -509,17 +804,22 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
|
|
|
509
804
|
of the schema, no generic, no cast:
|
|
510
805
|
|
|
511
806
|
```ts
|
|
512
|
-
import {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
807
|
+
import { Controller, Post, Body, Database, z } from "@palbase/backend";
|
|
808
|
+
|
|
809
|
+
const CreateRoomBody = z.object({ name: z.string() });
|
|
810
|
+
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
811
|
+
|
|
812
|
+
@Controller("/rooms")
|
|
813
|
+
export default class RoomsController {
|
|
814
|
+
@Post("")
|
|
815
|
+
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
816
|
+
// inline, no separate `export type` needed.
|
|
817
|
+
async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
818
|
+
const room = await Database.tables.rooms.insert({ name: body.name });
|
|
519
819
|
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
520
820
|
// room.nope ← compile error
|
|
521
|
-
}
|
|
522
|
-
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
523
823
|
```
|
|
524
824
|
|
|
525
825
|
`Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
|
|
@@ -535,107 +835,209 @@ import type { Tables } from "@palbase/backend/env";
|
|
|
535
835
|
type Room = Tables["rooms"]["row"];
|
|
536
836
|
```
|
|
537
837
|
|
|
838
|
+
## Row-Level Security (RLS)
|
|
538
839
|
|
|
840
|
+
RLS pushes per-user access control **into Postgres**: every `Database.*` query
|
|
841
|
+
runs as the request's verified user (the `authenticated` role with that user's
|
|
842
|
+
claims), and the database itself filters rows your policies don't allow. A
|
|
843
|
+
missing `WHERE user_id = …` in your handler can no longer leak another user's
|
|
844
|
+
rows — the policy enforces it. This is the recommended way to scope data per
|
|
845
|
+
user.
|
|
539
846
|
|
|
540
|
-
|
|
847
|
+
Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
|
|
848
|
+
implies `rls: true` automatically (a table with policies must have RLS enabled
|
|
849
|
+
or the policies are inert). Set `rls: true` with no policies only as a
|
|
850
|
+
deliberate deny-all intermediate step.
|
|
541
851
|
|
|
542
|
-
|
|
852
|
+
### The `policy()` builder
|
|
853
|
+
|
|
854
|
+
`policy(name)` is a fluent builder, just like the column builders:
|
|
855
|
+
|
|
856
|
+
```ts
|
|
857
|
+
import { policy } from "@palbase/backend";
|
|
858
|
+
|
|
859
|
+
policy("pb_owner_all")
|
|
860
|
+
.for("all") // "all" | "select" | "insert" | "update" | "delete"
|
|
861
|
+
.to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
|
|
862
|
+
.using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
|
|
863
|
+
.withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
|
|
864
|
+
```
|
|
543
865
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
866
|
+
| Method | Default | Meaning |
|
|
867
|
+
|--------|---------|---------|
|
|
868
|
+
| `.for(cmd)` | `"all"` | The SQL command the policy governs. |
|
|
869
|
+
| `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
|
|
870
|
+
| `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
|
|
871
|
+
| `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
|
|
872
|
+
| `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
|
|
548
873
|
|
|
549
|
-
|
|
874
|
+
**`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
|
|
875
|
+
request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
|
|
876
|
+
once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
877
|
+
`auth.jwt()` are also available. With no user on the request (anon/public),
|
|
878
|
+
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
879
|
+
rows.
|
|
550
880
|
|
|
551
|
-
|
|
881
|
+
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
882
|
+
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
883
|
+
> other modules (storage, cron, …).
|
|
552
884
|
|
|
553
|
-
|
|
554
|
-
deploy applies it automatically (`CREATE TABLE` / `ADD COLUMN`) with no manual
|
|
555
|
-
step and no backfill risk. Just edit `db/schema.ts` and deploy.
|
|
885
|
+
### Owner-scoped `todos` example
|
|
556
886
|
|
|
557
887
|
```ts
|
|
558
|
-
|
|
559
|
-
todos: {
|
|
560
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
561
|
-
title: text().notNull(),
|
|
562
|
-
}
|
|
888
|
+
import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
|
|
563
889
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
890
|
+
export default defineSchema({
|
|
891
|
+
tables: {
|
|
892
|
+
todos: {
|
|
893
|
+
columns: {
|
|
894
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
895
|
+
owner: text().notNull(), // palauth user id (TEXT)
|
|
896
|
+
title: text().notNull(),
|
|
897
|
+
done: boolean().default(false),
|
|
898
|
+
created_at: timestamp().defaultNow(),
|
|
899
|
+
},
|
|
900
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
901
|
+
policies: [
|
|
902
|
+
// Read: a user sees only their own todos.
|
|
903
|
+
policy("pb_todos_owner_select")
|
|
904
|
+
.for("select")
|
|
905
|
+
.to("authenticated")
|
|
906
|
+
.using("owner = (select auth.uid())"),
|
|
907
|
+
|
|
908
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
909
|
+
policy("pb_todos_owner_write")
|
|
910
|
+
.for("all")
|
|
911
|
+
.to("authenticated")
|
|
912
|
+
.using("owner = (select auth.uid())")
|
|
913
|
+
.withCheck("owner = (select auth.uid())"),
|
|
914
|
+
],
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
});
|
|
571
918
|
```
|
|
572
919
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
920
|
+
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
921
|
+
calling user's rows — no `WHERE owner = …` needed in the handler. To read or
|
|
922
|
+
write across all users (e.g. an admin job), use the explicit bypass:
|
|
923
|
+
`Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
|
|
924
|
+
|
|
925
|
+
### How policies are applied
|
|
577
926
|
|
|
578
|
-
|
|
927
|
+
On deploy, Palbase diffs your declared schema against the live database and
|
|
928
|
+
applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
|
|
929
|
+
when the table doesn't already have it, and `CREATE POLICY` only for policies
|
|
930
|
+
that don't already exist (keyed by `(table, name)`). These are non-destructive,
|
|
931
|
+
so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
579
932
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
933
|
+
> Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
|
|
934
|
+
> auto-applied — rename the policy (new `(table, name)`) or drop the old one in
|
|
935
|
+
> a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
<!-- ===== migrations.md ===== -->
|
|
584
941
|
|
|
942
|
+
# Migrations
|
|
943
|
+
|
|
944
|
+
`db/schema.ts` is the single source of truth for your Postgres schema. You change
|
|
945
|
+
the schema by editing that file, then generating a **migration** from the diff
|
|
946
|
+
with `palbase db diff`. Every schema change — additive or destructive — flows
|
|
947
|
+
through a reviewable migration file committed to git. The deploy applies the
|
|
948
|
+
migrations in `db/migrations/`; nothing is auto-applied behind your back.
|
|
949
|
+
|
|
950
|
+
## The workflow
|
|
951
|
+
|
|
952
|
+
```bash
|
|
953
|
+
# 1. Edit db/schema.ts (add a column, a table, a policy, …)
|
|
954
|
+
|
|
955
|
+
# 2. Generate the migration from the diff (declared schema vs the live branch)
|
|
956
|
+
palbase db diff -f add_priority
|
|
957
|
+
# → writes db/migrations/<timestamp>_add_priority.sql
|
|
958
|
+
|
|
959
|
+
# 3. Review the generated SQL (especially destructive changes — see below),
|
|
960
|
+
# then commit + push. git push deploys; the migration runs on deploy.
|
|
961
|
+
git add db/migrations && git commit -m "add priority column" && git push
|
|
585
962
|
```
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
963
|
+
|
|
964
|
+
`palbase db diff` introspects your **live branch database** (there is no local
|
|
965
|
+
database — every branch runs on the server), diffs it against `db/schema.ts`, and
|
|
966
|
+
writes one migration SQL file. If the schema is already in sync it writes nothing
|
|
967
|
+
and tells you so.
|
|
968
|
+
|
|
969
|
+
## Additive vs destructive
|
|
970
|
+
|
|
971
|
+
The generated SQL labels what it does. An additive change (new table, new column)
|
|
972
|
+
is plain DDL:
|
|
973
|
+
|
|
974
|
+
```sql
|
|
975
|
+
-- palbase db diff: add_priority
|
|
976
|
+
-- generated 20260605T142233
|
|
977
|
+
|
|
978
|
+
ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority text;
|
|
589
979
|
```
|
|
590
980
|
|
|
981
|
+
A **destructive** change (dropping a column or table — losing data) is generated
|
|
982
|
+
with a clear warning comment, and `palbase db diff` prints a warning. Review it
|
|
983
|
+
before committing:
|
|
984
|
+
|
|
591
985
|
```sql
|
|
592
|
-
--
|
|
593
|
-
ALTER TABLE todos
|
|
986
|
+
-- DESTRUCTIVE: dropping todos.notes loses its data
|
|
987
|
+
ALTER TABLE todos DROP COLUMN notes;
|
|
594
988
|
```
|
|
595
989
|
|
|
990
|
+
A **column type change** is emitted as a commented stub — auto-migration never
|
|
991
|
+
alters types, so you write the real `ALTER ... TYPE` with whatever `USING` cast
|
|
992
|
+
and backfill your data needs:
|
|
993
|
+
|
|
596
994
|
```sql
|
|
597
|
-
--
|
|
598
|
-
ALTER TABLE todos ALTER COLUMN
|
|
995
|
+
-- TYPE CHANGE: todos.priority text -> integer (review; auto-migrate does not ALTER types)
|
|
996
|
+
-- ALTER TABLE todos ALTER COLUMN priority TYPE integer;
|
|
599
997
|
```
|
|
600
998
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
existing data gets there**. Keep the two in sync — after the migration lands,
|
|
605
|
-
`schema.ts` should already reflect the new column type.
|
|
999
|
+
## The drift gate
|
|
1000
|
+
|
|
1001
|
+
You can't push a schema change without its migration:
|
|
606
1002
|
|
|
607
|
-
|
|
1003
|
+
- **`palbase db check`** exits non-zero when `db/schema.ts` declares something the
|
|
1004
|
+
database lacks (i.e. you edited the schema but didn't run `palbase db diff`).
|
|
1005
|
+
- The scaffold installs a **git pre-push hook** that runs `palbase db check`, so a
|
|
1006
|
+
plain `git push` is **blocked** until you generate + commit the migration.
|
|
1007
|
+
(Bypass with `git push --no-verify` — but the deploy-time gate still rejects it.)
|
|
1008
|
+
- On deploy, after migrations run, Palbase asserts `db/schema.ts` matches the live
|
|
1009
|
+
database. Any unresolved drift **fails the deploy** and keeps the previous
|
|
1010
|
+
version live — a broken schema never goes out silently.
|
|
608
1011
|
|
|
609
|
-
|
|
1012
|
+
## `palbase serve` uses the deployed database
|
|
610
1013
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1014
|
+
`palbase serve` runs your controllers locally but proxies `Database` and `ctx.*`
|
|
1015
|
+
to the **deployed** branch — it does not spin up a local Postgres. So a schema
|
|
1016
|
+
change in `db/schema.ts` doesn't exist in the database until you generate the
|
|
1017
|
+
migration and push. Run `palbase gen-types` (or just `palbase serve`, which
|
|
1018
|
+
regenerates on change) after editing the schema to refresh `palbase-env.d.ts` so
|
|
1019
|
+
`Database.tables.<name>` is fully typed in your services.
|
|
616
1020
|
|
|
617
|
-
|
|
618
|
-
dropping production data. A blocked deploy is a prompt to write the migration,
|
|
619
|
-
not a failure to work around.
|
|
1021
|
+
## Hand-written migrations
|
|
620
1022
|
|
|
621
|
-
|
|
1023
|
+
`db/migrations/*.sql` is plain SQL applied in filename order and tracked in
|
|
1024
|
+
`schema_migrations` (idempotent — a migration runs once). `palbase db diff`
|
|
1025
|
+
generates them for you, but you can also hand-write one for anything the diff
|
|
1026
|
+
can't express (a data backfill, a complex type change with a `USING` cast, a
|
|
1027
|
+
trigger). Keep `db/schema.ts` as the declared end-state so the drift gate passes.
|
|
622
1028
|
|
|
623
|
-
|
|
624
|
-
`ctx.*` to the **deployed** branch — it does **not** spin up a local Postgres or
|
|
625
|
-
apply migrations locally. So when your local `db/schema.ts` or `db/migrations/`
|
|
626
|
-
is ahead of what's deployed, serve prints a note: new tables/columns won't exist
|
|
627
|
-
until you push. Deploy to apply them.
|
|
1029
|
+
## Adding a NOT NULL column to a table with rows
|
|
628
1030
|
|
|
629
|
-
|
|
1031
|
+
There's nothing to put in existing rows, so do it in two migrations: first add it
|
|
1032
|
+
nullable (or with a default) and backfill, then a follow-up `ALTER ... SET NOT
|
|
1033
|
+
NULL`. `db/schema.ts` describes the end state; the migrations describe how
|
|
1034
|
+
existing data gets there.
|
|
630
1035
|
|
|
631
|
-
|
|
632
|
-
2. **Additive** change? → `git push`. It auto-migrates on deploy.
|
|
633
|
-
3. **Type change / rename / drop?** → add `db/migrations/NNN_*.up.sql` (+
|
|
634
|
-
`.down.sql`), then `git push`. The runner applies it; without it the
|
|
635
|
-
drift-gate blocks the deploy.
|
|
636
|
-
4. `palbase serve` warns locally until the change is deployed.
|
|
1036
|
+
## Row-Level Security
|
|
637
1037
|
|
|
638
|
-
|
|
1038
|
+
Add `rls: true` + `policies: [policy(...)]` to a table in `db/schema.ts`; the
|
|
1039
|
+
generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
|
|
1040
|
+
See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
|
|
639
1041
|
`Database.tables.*` access.
|
|
640
1042
|
|
|
641
1043
|
|
|
@@ -734,17 +1136,20 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
|
|
|
734
1136
|
## Flags
|
|
735
1137
|
|
|
736
1138
|
```ts
|
|
737
|
-
import {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1139
|
+
import { Controller, Get, User, Flags, z } from "@palbase/backend";
|
|
1140
|
+
import type { UserT } from "@palbase/backend";
|
|
1141
|
+
|
|
1142
|
+
const FlagsOut = z.object({ enabled: z.boolean() });
|
|
1143
|
+
|
|
1144
|
+
@Controller("/checkout")
|
|
1145
|
+
export default class CheckoutController {
|
|
1146
|
+
@Get("/flags") // auth omitted → required → user is non-null
|
|
1147
|
+
async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> { // return type names the 200 schema
|
|
1148
|
+
const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
|
|
1149
|
+
const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
|
|
745
1150
|
return { enabled: enabled ?? false };
|
|
746
|
-
}
|
|
747
|
-
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
748
1153
|
```
|
|
749
1154
|
|
|
750
1155
|
|
|
@@ -852,51 +1257,58 @@ const results = (await google.nearby(41.0, 29.0)).data.results;
|
|
|
852
1257
|
|
|
853
1258
|
# Errors
|
|
854
1259
|
|
|
855
|
-
|
|
856
|
-
envelope:
|
|
1260
|
+
Throw an error class to fail a request. Every one serializes to the standard
|
|
1261
|
+
Palbase error envelope:
|
|
857
1262
|
|
|
858
1263
|
```json
|
|
859
1264
|
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
860
1265
|
```
|
|
861
1266
|
|
|
862
|
-
|
|
1267
|
+
Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
|
|
1268
|
+
per-route error map: the runtime catches any thrown error class and emits the
|
|
1269
|
+
envelope.
|
|
1270
|
+
|
|
1271
|
+
## Named status classes
|
|
863
1272
|
|
|
864
1273
|
```ts
|
|
865
|
-
import {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
throw new
|
|
1274
|
+
import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests } from "@palbase/backend";
|
|
1275
|
+
|
|
1276
|
+
throw new Conflict("title taken"); // → 409 ("conflict" code by default)
|
|
1277
|
+
throw new NotFound(); // → 404 ("not_found", "Not found")
|
|
1278
|
+
throw new BadRequest("missing field"); // → 400
|
|
1279
|
+
throw new Unauthorized(); // → 401
|
|
1280
|
+
throw new Forbidden(); // → 403
|
|
1281
|
+
throw new TooManyRequests(); // → 429
|
|
869
1282
|
```
|
|
870
1283
|
|
|
871
|
-
|
|
1284
|
+
Each class fixes its HTTP status. The constructor is
|
|
1285
|
+
`new <Class>(message?, code?, data?)`:
|
|
1286
|
+
|
|
1287
|
+
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
1288
|
+
derived from the class name).
|
|
1289
|
+
- `code` overrides the wire `error` code (defaults to the class's snake_case
|
|
1290
|
+
code, e.g. `not_found`).
|
|
1291
|
+
- `data` rides along under the envelope's `data` field for structured context.
|
|
1292
|
+
|
|
1293
|
+
```ts
|
|
1294
|
+
throw new NotFound("Room does not exist", "room_not_found");
|
|
1295
|
+
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
|
1296
|
+
```
|
|
872
1297
|
|
|
873
|
-
##
|
|
1298
|
+
## `PalError` / `HttpError` (custom status)
|
|
874
1299
|
|
|
875
|
-
|
|
876
|
-
errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
|
|
877
|
-
for iOS callers.
|
|
1300
|
+
For a status/code not covered by a named class:
|
|
878
1301
|
|
|
879
1302
|
```ts
|
|
880
|
-
import {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
output: z.object({ ok: z.boolean() }),
|
|
885
|
-
errors: {
|
|
886
|
-
notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
|
|
887
|
-
locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
|
|
888
|
-
},
|
|
889
|
-
handler: async (req) => {
|
|
890
|
-
const todo = await Database.findById("todos", req.input.id);
|
|
891
|
-
if (!todo) throw req.errors.notFound(); // no data → no args
|
|
892
|
-
if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
|
|
893
|
-
return { ok: true };
|
|
894
|
-
},
|
|
895
|
-
});
|
|
1303
|
+
import { PalError } from "@palbase/backend";
|
|
1304
|
+
throw new PalError(418, "teapot", "I'm a teapot");
|
|
1305
|
+
// optional structured payload (4th arg) rides along under `data`:
|
|
1306
|
+
throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
896
1307
|
```
|
|
897
1308
|
|
|
898
|
-
|
|
899
|
-
|
|
1309
|
+
`PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
|
|
1310
|
+
The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
|
|
1311
|
+
HttpError) … }` matches any of them.
|
|
900
1312
|
|
|
901
1313
|
|
|
902
1314
|
|