@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/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
|
|
8
|
-
|
|
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
|
-
##
|
|
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/`
|
|
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
|
-
├──
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
`
|
|
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
|
-
|
|
|
62
|
-
| Endpoints
|
|
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 **
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
##
|
|
11
|
+
## A controller
|
|
10
12
|
|
|
11
13
|
```ts
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
50
|
+
## Method decorators
|
|
44
51
|
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
auth
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
throw new
|
|
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
|
-
|
|
27
|
+
Each class fixes its HTTP status. The constructor is
|
|
28
|
+
`new <Class>(message?, code?, data?)`:
|
|
20
29
|
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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.
|