@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.
Files changed (41) hide show
  1. package/dist/{chunk-B7EUJP5W.js → chunk-2N4YNN6F.js} +113 -3
  2. package/dist/chunk-2N4YNN6F.js.map +1 -0
  3. package/dist/{chunk-PHAFZGHN.js → chunk-WUQO76NW.js} +26 -19
  4. package/dist/chunk-WUQO76NW.js.map +1 -0
  5. package/dist/db/index.cjs +117 -2
  6. package/dist/db/index.cjs.map +1 -1
  7. package/dist/db/index.d.cts +2 -2
  8. package/dist/db/index.d.ts +2 -2
  9. package/dist/db/index.js +11 -1
  10. package/dist/{endpoint-DJ98tQd6.d.cts → endpoint-BEHjfvFH.d.cts} +99 -57
  11. package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-BEHjfvFH.d.ts} +99 -57
  12. package/dist/index-BTVdhfsb.d.ts +469 -0
  13. package/dist/index-mr3Co63T.d.cts +469 -0
  14. package/dist/index.cjs +356 -42
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +80 -219
  17. package/dist/index.d.ts +80 -219
  18. package/dist/index.js +203 -21
  19. package/dist/index.js.map +1 -1
  20. package/dist/test/index.cjs +34 -19
  21. package/dist/test/index.cjs.map +1 -1
  22. package/dist/test/index.d.cts +1 -1
  23. package/dist/test/index.d.ts +1 -1
  24. package/dist/test/index.js +10 -2
  25. package/dist/test/index.js.map +1 -1
  26. package/docs/README.md +212 -14
  27. package/docs/database.md +40 -0
  28. package/docs/endpoints.md +110 -92
  29. package/docs/errors.md +37 -30
  30. package/docs/getting-started.md +64 -26
  31. package/docs/llms-full.txt +724 -312
  32. package/docs/llms.txt +1 -1
  33. package/docs/migrations.md +75 -73
  34. package/docs/routing.md +39 -45
  35. package/docs/schema.md +135 -23
  36. package/docs/services.md +13 -10
  37. package/package.json +2 -2
  38. package/dist/chunk-B7EUJP5W.js.map +0 -1
  39. package/dist/chunk-PHAFZGHN.js.map +0 -1
  40. package/dist/index-CXUs9iTQ.d.ts +0 -294
  41. package/dist/index-CZAwpQE1.d.cts +0 -294
package/docs/README.md CHANGED
@@ -4,10 +4,202 @@ Write your backend as TypeScript files. Palbase discovers them by path, runs
4
4
  them inside the managed backend runtime, and exposes them as a typed HTTP API.
5
5
 
6
6
  This is **not** Express, Fastify, or a Supabase Edge Function. There is no
7
- `app.get(...)`, no manual route registration, no `import express`. You export
8
- definitions; the runtime wires them up.
7
+ `app.get(...)`, no manual route registration, no `import express`. You author
8
+ **class controllers** with method + parameter decorators (NestJS-style); the
9
+ runtime wires them up.
9
10
 
10
- ## Mental model (important)
11
+ ## AGENTS / AI codegen guide (read this first)
12
+
13
+ If you are an AI generating a Palbase backend, this is the contract. Get these
14
+ seven rules right and the deploy succeeds; get them wrong and it fails the
15
+ deploy gate or the TypeScript compile.
16
+
17
+ ### Folder layout
18
+
19
+ ```
20
+ controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
21
+ models/<controller>/<endpoint>.ts # zod schemas — one folder per controller, one file per endpoint (+ shared.ts)
22
+ services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
23
+ db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
24
+ ```
25
+
26
+ The four folders above are the daily surface. These also exist (own docs, linked
27
+ below): `resources/` (external connections — [resources.md](./resources.md)),
28
+ `seeds/` (seed data), `jobs/` + `workers/` (background — [background.md](./background.md)),
29
+ `webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
30
+
31
+ ### The 7 rules (checklist)
32
+
33
+ 1. **`export default <ControllerClass>` is REQUIRED.** Every `controllers/*.controller.ts`
34
+ must default-export its `@Controller` class, or the deploy FAILS with a
35
+ "not a @Controller / non-controller export" error. Either `export default class X {}`
36
+ or a trailing `export default X;` — both work.
37
+ 2. **Methods that call a service are `async` and return `Promise<T>`.** Services
38
+ `await Database`, so they return promises; a sync return type on an async body
39
+ is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
40
+ the body awaits.
41
+ 3. **`@User()` (value) vs `UserT` (type) are two different imports.**
42
+ `import { User } from "@palbase/backend"` (the decorator) +
43
+ `import type { UserT } from "@palbase/backend"` (the user type). Write
44
+ `@User() user: UserT`. They are NOT the same symbol.
45
+ 4. **The method's RETURN TYPE names the response schema.** Write
46
+ `: Promise<TodoSchema>` (or `: Promise<z.infer<typeof TodoSchema>>`) — codegen
47
+ + the runtime read that named type to bind the 200 response zod schema. There
48
+ is no `@Returns` decorator. A body route with no named return type is a build
49
+ error; a method with no response body annotates `: Promise<void>` / `: void`.
50
+ 5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
51
+ VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
52
+ `@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
53
+ as the type annotation).
54
+ 6. **Errors are global throw classes.** `throw new Conflict("…")` / `NotFound` /
55
+ `BadRequest` / `Unauthorized` / `Forbidden` / `TooManyRequests` /
56
+ `PalError(status, code, msg)` — from a controller OR a service, with no `req`.
57
+ There is no per-route errors map and no `req.errors`.
58
+ 7. **Auth is secure-by-default.** Every route needs a signed-in user unless it
59
+ opts out: `@Controller("/x", { auth: false })` (whole controller) or
60
+ `@Get("", { auth: false })` (one route). Cascade: route > controller >
61
+ default(required). On a public route inject `@OptionalUser() user: UserT | null`.
62
+
63
+ > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
64
+ > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
65
+ > and will not compile against `@palbase/backend` 4.
66
+
67
+ ### Complete CRUD example (copy-pasteable, compiles)
68
+
69
+ ```ts
70
+ // models/todos/shared.ts — the response shape, reused across endpoints.
71
+ import { z } from "@palbase/backend";
72
+ export const TodoSchema = z.object({
73
+ id: z.string(),
74
+ title: z.string(),
75
+ completed: z.boolean(),
76
+ });
77
+ export type TodoSchema = z.infer<typeof TodoSchema>;
78
+ ```
79
+
80
+ ```ts
81
+ // models/todos/create.ts — the POST body.
82
+ import { z } from "@palbase/backend";
83
+ export const CreateTodoBody = z.object({ title: z.string().min(1) });
84
+ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
85
+ ```
86
+
87
+ ```ts
88
+ // services/todo.service.ts — plain class + singleton. The real work.
89
+ import { Database, NotFound } from "@palbase/backend";
90
+ import type { TodoSchema } from "../models/todos/shared.js";
91
+
92
+ export class TodoService {
93
+ list(userId: string): Promise<TodoSchema[]> {
94
+ return Database.tables.todos.findMany({ user_id: userId });
95
+ }
96
+ create(userId: string, title: string): Promise<TodoSchema> {
97
+ return Database.tables.todos.insert({ user_id: userId, title });
98
+ }
99
+ async get(userId: string, id: string): Promise<TodoSchema> {
100
+ const t = await Database.tables.todos.findById(id);
101
+ if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
102
+ return t;
103
+ }
104
+ async remove(userId: string, id: string): Promise<void> {
105
+ const t = await Database.tables.todos.findById(id);
106
+ if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
107
+ await Database.tables.todos.delete(id);
108
+ }
109
+ }
110
+ export const todoService = new TodoService();
111
+ ```
112
+
113
+ ```ts
114
+ // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
115
+ import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
116
+ import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
117
+ import { todoService } from "../services/todo.service.js";
118
+ import { TodoSchema } from "../models/todos/shared.js";
119
+ import { CreateTodoBody } from "../models/todos/create.js";
120
+
121
+ @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
122
+ export class TodosController {
123
+ private todos = todoService;
124
+
125
+ @Get("") // GET /todos → operationId getTodos
126
+ async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
127
+ return this.todos.list(user.id);
128
+ }
129
+
130
+ @Post("") // POST /todos → postTodos
131
+ async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
132
+ return this.todos.create(user.id, body.title);
133
+ }
134
+
135
+ @Get("/{id}") // GET /todos/{id} → getTodosById
136
+ async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
137
+ return this.todos.get(user.id, id);
138
+ }
139
+
140
+ @Delete("/{id}") // DELETE /todos/{id} → deleteTodosById; no body → : Promise<void>
141
+ async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
142
+ await this.todos.remove(user.id, id);
143
+ }
144
+ }
145
+
146
+ export default TodosController; // REQUIRED — the runtime loads the default export
147
+ ```
148
+
149
+ ```ts
150
+ // db/schema.ts — config-as-code; the deploy auto-migrates additive changes.
151
+ import { defineSchema, uuid, text, boolean, timestamp, policy } from "@palbase/backend";
152
+ export default defineSchema({
153
+ tables: {
154
+ todos: {
155
+ columns: {
156
+ id: uuid().primaryKey().defaultRandom(),
157
+ user_id: text().notNull(), // palauth user id (TEXT, e.g. "usr_…")
158
+ title: text().notNull(),
159
+ completed: boolean().default(false),
160
+ created_at: timestamp().defaultNow(),
161
+ },
162
+ rls: true, // every Database.* query runs as the request user
163
+ policies: [
164
+ policy("pb_owner_all")
165
+ .for("all")
166
+ .to("authenticated")
167
+ .using("user_id = (select auth.uid())")
168
+ .withCheck("user_id = (select auth.uid())"),
169
+ ],
170
+ },
171
+ },
172
+ });
173
+ ```
174
+
175
+ ### operationId — what the generated clients call
176
+
177
+ The operationId is derived FLAT from the verb + full path (NOT the method name):
178
+ `GET /todos` → `getTodos`, `POST /todos` → `postTodos`, `GET /todos/{id}` →
179
+ `getTodosById`. The generated iOS/TS clients expose these as `pb.getTodos()`,
180
+ `pb.postTodos(...)`, `pb.getTodosById(id:)`. Rename the method freely; the
181
+ operationId is unaffected. Change the verb and the operationId changes — no file
182
+ rename needed.
183
+
184
+ ### CLI workflow
185
+
186
+ - `palbase serve` — run `controllers/` locally with hot reload (proxies
187
+ `Database`/services to the deployed branch; runs `gen-types` on startup).
188
+ - `palbase gen-types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
189
+ `Database.tables.*` is typed (no import, no generic). Standalone version of what
190
+ `serve` runs.
191
+ - **Deploy is GitHub-native** — `git push` triggers the deploy (there is no
192
+ `palbase push`). Push a branch to deploy that branch.
193
+ - `palbase secret set NAME=value` / `palbase secret list` — branch-scoped
194
+ secrets (encrypted at rest), read in code via `process.env.NAME`.
195
+ - `palbase secret pull` / `palbase secret push` — sync the branch's env vars with
196
+ a local `.env.local` (gitignored). `pull` writes decrypted values for local dev
197
+ (merging local-only keys); `push` uploads changed keys (new keys default to
198
+ SECRET and need `--secret`/`--plain` classification, existing secrets are left
199
+ untouched unless `--force-secrets`).
200
+ - `tsconfig.json` needs `"experimentalDecorators": true` (the scaffold sets it).
201
+
202
+ ## Mental model
11
203
 
12
204
  Every handler type imports service singletons the same way:
13
205
 
@@ -19,7 +211,7 @@ The **only difference** is the trigger argument:
19
211
 
20
212
  | You are writing… | Handler signature | Trigger arg |
21
213
  |------------------|-------------------|-------------|
22
- | **Endpoints** (`controllers/` + `handlers/`) | `(req)` | `req` — [PBRequest](./endpoints.md) |
214
+ | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
23
215
  | **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
24
216
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
25
217
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
@@ -35,13 +227,16 @@ the imported singletons — not from `ctx` or any argument.
35
227
  ```
36
228
  my-backend/
37
229
  ├── package.json # depends on @palbase/backend
38
- ├── controllers/ # route maps: method+path handler (mounts the API)
39
- │ └── hello.controller.ts # defineController("/hello", { … })
40
- ├── handlers/ # one endpoint unit each (schema + thin logic)
41
- │ └── hello.ts # defineHandler({ auth, input, output, handler })
42
- ├── services/ # plain classes/singletons your handlers call
230
+ ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
231
+ ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
232
+ │ └── hello.controller.ts # @Controller("/hello") + @Get example
233
+ ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
234
+ │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
235
+ ├── services/ # plain classes/singletons your controllers call
43
236
  ├── db/schema.ts # table definitions (optional, enables typed DB)
44
237
  ├── db/migrations/ # explicit SQL migrations for type changes (optional)
238
+ ├── resources/ # external connections, set up once at boot (optional)
239
+ ├── seeds/ # seed data (optional)
45
240
  ├── workers/ # background job handlers (optional)
46
241
  ├── jobs/ # cron-scheduled jobs (optional)
47
242
  ├── hooks/ # auth/storage/document event hooks (optional)
@@ -49,24 +244,27 @@ my-backend/
49
244
  └── middleware/ # cross-cutting request middleware (optional)
50
245
  ```
51
246
 
52
- HTTP endpoints are **not** file-path routed. You author a `handler` (one
53
- endpoint: `auth`/`input`/`output`/`errors` + logic, no route) and mount it in a
54
- `controller` (a route map). See [routing.md](./routing.md).
247
+ HTTP endpoints are **not** file-path routed. You author a class controller
248
+ (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
249
+ `controllers/` mounts it. See [routing.md](./routing.md).
55
250
 
56
251
  ## Documentation
57
252
 
58
253
  | Topic | File |
59
254
  |-------|------|
60
255
  | Getting started | [getting-started.md](./getting-started.md) |
61
- | File-based routing | [routing.md](./routing.md) |
62
- | Endpoints & `req` | [endpoints.md](./endpoints.md) |
256
+ | Routing (class controllers) | [routing.md](./routing.md) |
257
+ | Endpoints (decorators) | [endpoints.md](./endpoints.md) |
63
258
  | Database & transactions | [database.md](./database.md) |
64
259
  | Schema & typed DB | [schema.md](./schema.md) |
65
260
  | Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
66
261
  | Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
262
+ | Resources (external connections) | [resources.md](./resources.md) |
67
263
  | Errors | [errors.md](./errors.md) |
68
264
  | Workers & Jobs | [background.md](./background.md) |
69
265
  | Hooks & Webhooks | [events.md](./events.md) |
70
266
 
71
267
  For AI coding tools: a single concatenated corpus is generated at
72
268
  [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
269
+ </content>
270
+ </invoke>
package/docs/database.md CHANGED
@@ -71,3 +71,43 @@ await Database.transaction(async (tx) => {
71
71
 
72
72
  The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
73
73
  (no nested transaction). See [schema.md](./schema.md) for the full surface.
74
+
75
+ ## Bypassing RLS — `Database.asService()`
76
+
77
+ When a table has [Row-Level Security](./schema.md#row-level-security-rls)
78
+ policies, every `Database.*` call runs as the request's verified user
79
+ (`authenticated`), so the database filters out rows the user's policies don't
80
+ allow. That is the secure default.
81
+
82
+ Sometimes you need to read or write **across all users** — an admin endpoint, a
83
+ background job that fans out notifications, a cleanup task. For that, call
84
+ `Database.asService()`. It returns a sibling client that runs as the
85
+ `service_role` (which has `BYPASSRLS`), exposing the exact same surface —
86
+ `tables`, the raw string ops, and `transaction`:
87
+
88
+ ```ts
89
+ import { Database } from "@palbase/backend";
90
+
91
+ // RLS-enforced (default): only the caller's own rows.
92
+ const mine = await Database.tables.todos.findMany({});
93
+
94
+ // Service-role bypass: every user's rows. Explicit and greppable.
95
+ const all = await Database.asService().tables.todos.findMany({});
96
+ const rows = await Database.asService().query("SELECT count(*) FROM todos");
97
+
98
+ // A service-role transaction (the role is fixed for the whole tx):
99
+ await Database.asService().transaction(async (tx) => {
100
+ await tx.tables.todos.update(id, { done: true });
101
+ });
102
+ ```
103
+
104
+ Guidelines:
105
+
106
+ - **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
107
+ only where you genuinely need cross-user access. It is intentionally easy to
108
+ grep for in review.
109
+ - **No double-bypass / no nesting.** The sibling does not re-expose
110
+ `asService()`, and `tx` never exposes it — a transaction's role is fixed when
111
+ it begins. Use `Database.transaction(...)` for an authenticated tx and
112
+ `Database.asService().transaction(...)` for a service-role tx; you cannot mix
113
+ enforced and bypassed ops inside one interactive transaction.
package/docs/endpoints.md CHANGED
@@ -1,105 +1,138 @@
1
1
  # Endpoints
2
2
 
3
- An endpoint is a **handler** `export default defineHandler({...})` in a file
4
- under `handlers/`. A handler has NO method or path; a **controller** maps
5
- method+path to it (see [routing.md](./routing.md)). The handler receives **one
6
- argument**, `req` (a `PBRequest`). Services are NOT on `req` — import them as
7
- singletons (see [services.md](./services.md)).
3
+ An endpoint is a **method on a class controller** a class decorated with
4
+ `@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
5
+ `@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
6
+ automatically (see [routing.md](./routing.md)). Request input + context are
7
+ injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
8
+ `@User`/…), each piece direct — no `req` god-object. Services are reached via the
9
+ imported singletons (see [services.md](./services.md)).
8
10
 
9
- ## `defineHandler` config
11
+ ## A controller
10
12
 
11
13
  ```ts
12
- defineHandler({
13
- auth: { required: true }, // see Auth below. Omitted AUTH REQUIRED (secure-by-default).
14
- rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
15
- input: z.object({ ... }), // optional Zod schema → validates & types req.input
16
- output: z.object({ ... }), // optional Zod schema validates the return value
17
- errors: { ... }, // optional declared errors (see errors.md)
18
- middleware: [ ... ], // optional Middleware[] (see below)
19
- handler: async (req) => { ... }, // required
20
- });
21
- ```
22
-
23
- There is no `method` field the HTTP verb (and path) live in the controller
24
- that mounts the handler via `route.get|post|put|patch|delete`.
14
+ // controllers/rooms.controller.ts
15
+ import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
16
+ import type { UserT } from "@palbase/backend";
17
+ import { CreateRoomBody } from "../models/rooms/create.js";
18
+ import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
19
+
20
+ @Controller("/rooms")
21
+ export default class RoomsController {
22
+ @Post("")
23
+ async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
24
+ const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
25
+ return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
26
+ }
25
27
 
26
- ## `req` (PBRequest)
28
+ @Get("/{id}")
29
+ async getOne(@Param("id") id: string): Promise<RoomSchema> {
30
+ const room = await Database.findById("rooms", id);
31
+ if (!room) throw new NotFound("Room does not exist", "room_not_found");
32
+ return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
33
+ }
34
+ }
35
+ ```
27
36
 
28
- | Field | Type | Notes |
29
- |-------|------|-------|
30
- | `req.input` | inferred from `input` schema | request body for POST/PUT/PATCH; `{}` otherwise |
31
- | `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
32
- | `req.query` | `Record<string,string>` | parsed query string |
33
- | `req.headers` | `Record<string,string>` | lowercase keys |
34
- | `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
35
- | `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
36
- | `req.file` | `FileContext \| null` | uploaded file, if any |
37
- | `req.method` | `string` | the HTTP method |
38
- | `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
39
- | `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
37
+ **Two non-negotiables** (the most common codegen mistakes):
40
38
 
41
- `User` is `{ id: string; email?: string; role: string; metadata: Record<string, unknown> }`.
39
+ 1. **`export default <Controller>` is REQUIRED.** Above it is
40
+ `export default class RoomsController`; the trailing form `export class
41
+ RoomsController {…}` + `export default RoomsController;` is equivalent. Without
42
+ a default export the deploy aborts with a "not a @Controller / non-controller
43
+ export" error — the runtime loads the file's default export.
44
+ 2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
45
+ returns promises, so a body that `await`s it cannot have a sync return type
46
+ (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
47
+ `async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
48
+ with no `await` may stay synchronous.)
42
49
 
43
- ## Auth
50
+ ## Method decorators
44
51
 
45
- **Secure by default:** a handler requires authentication UNLESS it explicitly
46
- opts out. Omitting `auth` means AUTH REQUIRED — a forgotten `auth` fails safe
47
- (401), never silently public. Mark a route PUBLIC with `auth: { required: false }`.
52
+ `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
48
53
 
49
54
  ```ts
50
- // auth omitted entirely // AUTH REQUIRED → req.user is non-null User
51
- auth: { required: true } // same: require any authenticated user
52
- auth: { required: true, role: "admin" } // require a specific role
53
- auth: { required: false } // PUBLIC → req.user may be null (anon key only)
55
+ interface RouteOptions {
56
+ auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
57
+ rateLimit?: { max: number; window: number }; // optional, per route
58
+ }
54
59
  ```
55
60
 
56
- Whether `req.user` is non-null is computed from the `auth` config at the type
57
- level (and matches the runtime exactly):
61
+ Input and output are NOT method-decorator options: input comes from the
62
+ parameter decorators, output from the method's return type. The method decorator
63
+ only carries route concerns (path, auth, rateLimit).
58
64
 
59
- | `auth` value | `req.user` type |
60
- |--------------|-----------------|
61
- | omitted | `User` (secure-by-default) |
62
- | `true` | `User` |
63
- | `false` | `User \| null` (public) |
64
- | `{ required: true }` | `User` |
65
- | `{ required: false }` | `User \| null` (public) |
66
- | `{ role: "admin" }` (object, no `required`) | `User` |
65
+ ## Parameter decorators
67
66
 
68
- To make a route PUBLIC, set `auth: { required: false }` (or `auth: false`). An
69
- object with a `role` but no `required` key is treated as authenticated. When
70
- `auth` is omitted, the endpoint is public and `req.user` may be null.
67
+ | Decorator | Injects | Typed by | OpenAPI |
68
+ |---|---|---|---|
69
+ | `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
70
+ | `@Query(schema)` | query params | `: T` | query parameters |
71
+ | `@Param("id")` | one path param | `: string` | path parameter |
72
+ | `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
73
+ | `@User()` | authenticated user | `: UserT` | — (from token) |
74
+ | `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
75
+ | `@Client()` | parsed client info | `: ClientInfo` | — |
76
+ | `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
77
+ | `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
71
78
 
72
- ## Typed input/output
79
+ `@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
80
+ developer writes the matching type annotation (same name). `UserT` is the
81
+ authenticated-user type (`{ id: string; email?: string; role: string; metadata:
82
+ Record<string, unknown> }`) — exported under the alias `UserT` because the value
83
+ name `User` is the `@User()` decorator.
84
+
85
+ ## Output = return type
86
+
87
+ The success response is the method's RETURN TYPE — there is no `@Returns`
88
+ decorator. Annotate `: Promise<RoomSchema>` (or `: Promise<z.infer<typeof
89
+ RoomSchema>>`); codegen + the runtime read that named type to bind the OpenAPI
90
+ 200 response zod schema. A body route with no named return type is a build
91
+ error. A method that returns nothing annotates `: void` (or `: Promise<void>`).
92
+
93
+ ## Auth — controller default + route override cascade
94
+
95
+ **Secure by default:** a route requires authentication UNLESS it explicitly opts
96
+ out. Resolution order (most specific wins):
97
+
98
+ 1. Route-level `@Post("", { auth })` — wins if present.
99
+ 2. Controller-level `@Controller("/x", { auth })` — applies to routes without
100
+ their own `auth`.
101
+ 3. Default `true` (secure-by-default).
73
102
 
74
103
  ```ts
75
- import { defineHandler, z, Database } from "@palbase/backend";
76
-
77
- export default defineHandler({
78
- auth: { required: true },
79
- input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
80
- output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
81
- handler: async (req) => {
82
- const room = await Database.insert("rooms", {
83
- name: req.input.name,
84
- capacity: req.input.capacity ?? null,
85
- });
86
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
87
- },
88
- });
104
+ @Controller("/public", { auth: false }) // all routes default public
105
+ export default class PublicController {
106
+ @Get("/open") open(): Info { ... } // inherits → public
107
+ @Get("/secret", { auth: true }) // OVERRIDES → authed
108
+ secret(@User() u: UserT): Secret { ... }
109
+ }
89
110
  ```
90
111
 
91
- A controller then mounts it with a method + path:
112
+ | effective `auth` | `@User()` type to annotate |
113
+ |------------------|----------------------------|
114
+ | required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
115
+ | public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
116
+
117
+ The runtime always applies the resolved effective auth; the static type you
118
+ annotate is best-effort (a parameter decorator cannot see the controller default
119
+ at the type level).
120
+
121
+ ## Errors — global throw classes
122
+
123
+ Throw an error class anywhere (controller OR service) — no `req`, no per-route
124
+ map. The runtime catches it and emits the standard envelope:
92
125
 
93
126
  ```ts
94
- // controllers/rooms.controller.ts
95
- import { defineController, route } from "@palbase/backend";
96
- import create from "../handlers/rooms/create.js";
127
+ import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
97
128
 
98
- export default defineController("/rooms", {
99
- create: route.post("/", create),
100
- });
129
+ throw new Conflict("title taken"); // → 409
130
+ throw new NotFound(); // → 404 ("Not found")
131
+ throw new PalError(418, "teapot", "custom"); // → custom status/code
101
132
  ```
102
133
 
134
+ See [errors.md](./errors.md) for the full set + the wire envelope shape.
135
+
103
136
  ## Middleware
104
137
 
105
138
  A middleware wraps a request. Define one in `middleware/<name>.ts`:
@@ -116,19 +149,4 @@ export default defineMiddleware(async (ctx, next) => {
116
149
  ```
117
150
 
118
151
  The middleware handler receives `(ctx, next)` — call `await next()` to run the
119
- rest of the chain (other middleware, then the endpoint handler). Note this uses
120
- the `ctx` model, not `req`.
121
-
122
- To attach middleware to a specific handler, import it and list it in the
123
- handler's `middleware` array:
124
-
125
- ```ts
126
- import { defineHandler, z } from "@palbase/backend";
127
- import logger from "../../middleware/logger.js";
128
-
129
- export default defineHandler({
130
- middleware: [logger],
131
- output: z.object({ ok: z.boolean() }),
132
- handler: async (req) => ({ ok: true }),
133
- });
134
- ```
152
+ rest of the chain (other middleware, then the endpoint method).
package/docs/errors.md CHANGED
@@ -1,47 +1,54 @@
1
1
  # Errors
2
2
 
3
- Two ways to fail a request. Both serialize to the standard Palbase error
4
- envelope:
3
+ Throw an error class to fail a request. Every one serializes to the standard
4
+ Palbase error envelope:
5
5
 
6
6
  ```json
7
7
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
8
8
  ```
9
9
 
10
- ## 1. `HttpError` (ad-hoc)
10
+ Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
11
+ per-route error map: the runtime catches any thrown error class and emits the
12
+ envelope.
13
+
14
+ ## Named status classes
11
15
 
12
16
  ```ts
13
- import { HttpError } from "@palbase/backend";
14
- throw new HttpError(404, "todo_not_found", "No such todo");
15
- // optional structured payload (4th arg) rides along under `data`:
16
- throw new HttpError(423, "todo_locked", "Locked", { retryAfter: 30 });
17
+ import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests } from "@palbase/backend";
18
+
19
+ throw new Conflict("title taken"); // 409 ("conflict" code by default)
20
+ throw new NotFound(); // → 404 ("not_found", "Not found")
21
+ throw new BadRequest("missing field"); // → 400
22
+ throw new Unauthorized(); // → 401
23
+ throw new Forbidden(); // → 403
24
+ throw new TooManyRequests(); // → 429
17
25
  ```
18
26
 
19
- `new HttpError(status, code, description, data?)`.
27
+ Each class fixes its HTTP status. The constructor is
28
+ `new <Class>(message?, code?, data?)`:
20
29
 
21
- ## 2. Declared errors (typed)
30
+ - `message` overrides the human-readable `error_description` (defaults to a label
31
+ derived from the class name).
32
+ - `code` overrides the wire `error` code (defaults to the class's snake_case
33
+ code, e.g. `not_found`).
34
+ - `data` rides along under the envelope's `data` field for structured context.
22
35
 
23
- Declare them on the handler; throw via `req.errors.<name>(...)`. Declared
24
- errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
25
- for iOS callers.
36
+ ```ts
37
+ throw new NotFound("Room does not exist", "room_not_found");
38
+ throw new Conflict("locked", "title_locked", { retryAfter: 30 });
39
+ ```
40
+
41
+ ## `PalError` / `HttpError` (custom status)
42
+
43
+ For a status/code not covered by a named class:
26
44
 
27
45
  ```ts
28
- import { defineHandler, z, Database } from "@palbase/backend";
29
-
30
- export default defineHandler({
31
- input: z.object({ id: z.string() }),
32
- output: z.object({ ok: z.boolean() }),
33
- errors: {
34
- notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
35
- locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
36
- },
37
- handler: async (req) => {
38
- const todo = await Database.findById("todos", req.input.id);
39
- if (!todo) throw req.errors.notFound(); // no data → no args
40
- if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
41
- return { ok: true };
42
- },
43
- });
46
+ import { PalError } from "@palbase/backend";
47
+ throw new PalError(418, "teapot", "I'm a teapot");
48
+ // optional structured payload (4th arg) rides along under `data`:
49
+ throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
44
50
  ```
45
51
 
46
- A declared error with a `data` Zod schema requires that payload as an argument;
47
- one without `data` takes no arguments. This is enforced by the types.
52
+ `PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
53
+ The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
54
+ HttpError) … }` matches any of them.