@palbase/backend 3.0.0 → 4.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 (37) hide show
  1. package/dist/{chunk-B7EUJP5W.js → chunk-EG7TTYHY.js} +113 -3
  2. package/dist/chunk-EG7TTYHY.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-2d_DpASt.d.cts} +92 -56
  11. package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-2d_DpASt.d.ts} +92 -56
  12. package/dist/{index-CXUs9iTQ.d.ts → index-DZW9CjiY.d.ts} +210 -41
  13. package/dist/{index-CZAwpQE1.d.cts → index-DzRFS3Tl.d.cts} +210 -41
  14. package/dist/index.cjs +371 -42
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +88 -215
  17. package/dist/index.d.ts +88 -215
  18. package/dist/index.js +217 -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 +11 -11
  27. package/docs/database.md +40 -0
  28. package/docs/endpoints.md +98 -92
  29. package/docs/errors.md +37 -30
  30. package/docs/getting-started.md +24 -20
  31. package/docs/llms-full.txt +401 -235
  32. package/docs/routing.md +39 -45
  33. package/docs/schema.md +134 -23
  34. package/docs/services.md +14 -10
  35. package/package.json +2 -2
  36. package/dist/chunk-B7EUJP5W.js.map +0 -1
  37. package/dist/chunk-PHAFZGHN.js.map +0 -1
@@ -27,7 +27,7 @@ The **only difference** is the trigger argument:
27
27
 
28
28
  | You are writing… | Handler signature | Trigger arg |
29
29
  |------------------|-------------------|-------------|
30
- | **Endpoints** (`controllers/` + `handlers/`) | `(req)` | `req` — [PBRequest](./endpoints.md) |
30
+ | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
31
31
  | **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
32
32
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
33
33
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
@@ -43,11 +43,11 @@ the imported singletons — not from `ctx` or any argument.
43
43
  ```
44
44
  my-backend/
45
45
  ├── package.json # depends on @palbase/backend
46
- ├── controllers/ # route maps: method+path handler (mounts the API)
47
- │ └── hello.controller.ts # defineController("/hello", { })
48
- ├── handlers/ # one endpoint unit each (schema + thin logic)
49
- │ └── hello.ts # defineHandler({ auth, input, output, handler })
50
- ├── services/ # plain classes/singletons your handlers call
46
+ ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
47
+ │ └── hello.controller.ts # @Controller("/hello") + @Get example
48
+ ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
49
+ │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
50
+ ├── services/ # plain classes/singletons your controllers call
51
51
  ├── db/schema.ts # table definitions (optional, enables typed DB)
52
52
  ├── db/migrations/ # explicit SQL migrations for type changes (optional)
53
53
  ├── workers/ # background job handlers (optional)
@@ -57,17 +57,17 @@ my-backend/
57
57
  └── middleware/ # cross-cutting request middleware (optional)
58
58
  ```
59
59
 
60
- HTTP endpoints are **not** file-path routed. You author a `handler` (one
61
- endpoint: `auth`/`input`/`output`/`errors` + logic, no route) and mount it in a
62
- `controller` (a route map). See [routing.md](./routing.md).
60
+ HTTP endpoints are **not** file-path routed. You author a class controller
61
+ (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
62
+ `controllers/` mounts it. See [routing.md](./routing.md).
63
63
 
64
64
  ## Documentation
65
65
 
66
66
  | Topic | File |
67
67
  |-------|------|
68
68
  | Getting started | [getting-started.md](./getting-started.md) |
69
- | File-based routing | [routing.md](./routing.md) |
70
- | Endpoints & `req` | [endpoints.md](./endpoints.md) |
69
+ | Routing (class controllers) | [routing.md](./routing.md) |
70
+ | Endpoints (decorators) | [endpoints.md](./endpoints.md) |
71
71
  | Database & transactions | [database.md](./database.md) |
72
72
  | Schema & typed DB | [schema.md](./schema.md) |
73
73
  | Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
@@ -118,36 +118,40 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
118
118
 
119
119
  ## Your first endpoint
120
120
 
121
- A handler is one endpoint unit; a controller maps method+path to it. Create
122
- `handlers/hello.ts`:
121
+ An endpoint is a method on a class controller. Declare the schemas in `models/`:
123
122
 
124
123
  ```ts
125
- import { defineHandler, z } from "@palbase/backend";
126
-
127
- export default defineHandler({
128
- input: z.object({ name: z.string().optional() }),
129
- output: z.object({ message: z.string(), user: z.string().nullable() }),
130
- handler: async (req) => ({
131
- message: `hello, ${req.input.name ?? "world"}!`,
132
- user: req.user?.id ?? null,
133
- }),
134
- });
124
+ // models/hello/greet.ts
125
+ import { z } from "@palbase/backend";
126
+
127
+ export const GreetQuery = z.object({ name: z.string().optional() });
128
+ export type GreetQuery = z.infer<typeof GreetQuery>;
129
+
130
+ export const HelloResponse = z.object({ message: z.string(), user: z.string().nullable() });
131
+ export type HelloResponse = z.infer<typeof HelloResponse>;
135
132
  ```
136
133
 
137
- Then mount it in `controllers/hello.controller.ts`:
134
+ Then write the controller in `controllers/hello.controller.ts`:
138
135
 
139
136
  ```ts
140
- import { defineController, route } from "@palbase/backend";
141
- import hello from "../handlers/hello.js";
142
-
143
- export default defineController("/hello", {
144
- hello: route.get("/", hello),
145
- });
137
+ import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
138
+ import type { UserT } from "@palbase/backend";
139
+ import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
140
+
141
+ @Controller("/hello", { auth: false })
142
+ export default class HelloController {
143
+ @Get("")
144
+ @Returns(HelloResponse)
145
+ greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
146
+ return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
147
+ }
148
+ }
146
149
  ```
147
150
 
148
- This is served at `GET /hello`. The Zod `input` schema validates the request and
149
- flows into `req.input`; the `output` schema validates your return value. See
150
- [routing.md](./routing.md) for the handler + controller model.
151
+ This is served at `GET /hello`. The `@Query` schema validates the query string;
152
+ the method's return type (paired with `@Returns(HelloResponse)`) validates and
153
+ describes the response. See [routing.md](./routing.md) and
154
+ [endpoints.md](./endpoints.md) for the full class-controller model.
151
155
 
152
156
 
153
157
 
@@ -155,71 +159,65 @@ flows into `req.input`; the `output` schema validates your return value. See
155
159
 
156
160
  # Routing
157
161
 
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
162
+ Routes are declared in code with **class controllers**. A controller is a class
163
+ decorated with `@Controller(basePath)`; each route is a method decorated with
164
+ `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`. Request input + context are injected
165
+ into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/`@User`/
166
+ …). Putting a controller file under `controllers/` mounts it — there is no
161
167
  central router and no manual registration.
162
168
 
163
169
  ```ts
164
- import { defineController, route } from "@palbase/backend";
170
+ import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
165
171
  ```
166
172
 
167
- ## Handlersone endpoint per file, no route
173
+ ## Controllersclass + method decorators
168
174
 
169
- A handler declares everything that types `req` (`auth`/`input`/`output`/
170
- `errors`) and the logic; it has no method or path.
171
-
172
- ```ts
173
- // handlers/places/import-nearby.ts
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
- });
184
- ```
185
-
186
- ## Controllers — the route map
187
-
188
- A controller maps method+path to handlers with `route.get|post|put|patch|delete`.
189
- The route-map KEY is authoring sugar only (it is NOT the operationId).
175
+ `@Controller(basePath)` marks the class and sets the mount path. Each route
176
+ method declares its verb + subpath; the real work lives in a `services/` class
177
+ (the controller method is thin).
190
178
 
191
179
  ```ts
192
180
  // controllers/places.controller.ts
193
- import { defineController, route } from "@palbase/backend";
194
- import importNearby from "../handlers/places/import-nearby.js";
195
- import addFavorite from "../handlers/places/add-favorite.js";
196
- import listFavorites from "../handlers/places/list-favorites.js";
197
-
198
- export default defineController("/places", {
199
- importNearby: route.post("/import", importNearby),
200
- addFavorite: route.post("/favorites", addFavorite),
201
- listFavorites: route.get ("/favorites", listFavorites),
202
- });
181
+ import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
182
+ import type { UserT } from "@palbase/backend";
183
+ import { placeService } from "../services/place.service.js";
184
+ import { ImportNearbyBody } from "../models/places/import.js";
185
+ import { PlaceSchema } from "../models/places/shared.js";
186
+
187
+ @Controller("/places")
188
+ export default class PlacesController {
189
+ @Post("/import")
190
+ @Returns(PlaceSchema)
191
+ importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
192
+ return placeService.importNearby(body.lat, body.lng, user.id);
193
+ }
194
+
195
+ @Get("/favorites", { auth: false })
196
+ @Returns(z.array(PlaceSchema))
197
+ listFavorites(): PlaceSchema[] {
198
+ return placeService.listFavorites();
199
+ }
200
+ }
203
201
  ```
204
202
 
205
- | Map key (sugar) | Method | Full path | operationId (flat) |
203
+ | Method name (sugar) | Verb | Full path | operationId (flat) |
206
204
  |---|---|---|---|
207
205
  | `importNearby` | POST | `/places/import` | `postPlacesImport` |
208
- | `addFavorite` | POST | `/places/favorites` | `postPlacesFavorites` |
209
206
  | `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
210
207
 
211
208
  Rules:
212
209
 
213
210
  - The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
214
- - A `{segment}` in a path becomes a route param, read via `req.params.segment`.
215
- - Each route value MUST be a `route.*(...)` result a bare handler is a
216
- compile error, which keeps logic out of controllers.
211
+ - A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
212
+ - Input is declared with the parameter decorators`@Body(schema)`,
213
+ `@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
214
+ the method's RETURN TYPE; pair it with `@Returns(schema)` so the zod value
215
+ drives the OpenAPI 200 response (or annotate `: void` for no body).
217
216
  - The operationId is derived FLAT from method + full path (`postPlacesImport`),
218
- not from the map key. Change a verb with `route.post` → `route.put` — no file
219
- rename.
217
+ not from the method name. Change `@Post` → `@Put` — no file rename.
220
218
 
221
- See [endpoints.md](./endpoints.md) for the full `defineHandler` config (`req`,
222
- auth, input/output, errors, middleware) reference.
219
+ See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
220
+ options, the parameter decorators, auth cascade, and error classes).
223
221
 
224
222
 
225
223
 
@@ -227,106 +225,127 @@ auth, input/output, errors, middleware) reference.
227
225
 
228
226
  # Endpoints
229
227
 
230
- An endpoint is a **handler** `export default defineHandler({...})` in a file
231
- under `handlers/`. A handler has NO method or path; a **controller** maps
232
- method+path to it (see [routing.md](./routing.md)). The handler receives **one
233
- argument**, `req` (a `PBRequest`). Services are NOT on `req` — import them as
234
- singletons (see [services.md](./services.md)).
228
+ An endpoint is a **method on a class controller** a class decorated with
229
+ `@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
230
+ `@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
231
+ automatically (see [routing.md](./routing.md)). Request input + context are
232
+ injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
233
+ `@User`/…), each piece direct — no `req` god-object. Services are reached via the
234
+ imported singletons (see [services.md](./services.md)).
235
235
 
236
- ## `defineHandler` config
236
+ ## A controller
237
237
 
238
238
  ```ts
239
- defineHandler({
240
- auth: { required: true }, // see Auth below. Omitted AUTH REQUIRED (secure-by-default).
241
- rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
242
- input: z.object({ ... }), // optional Zod schema → validates & types req.input
243
- output: z.object({ ... }), // optional Zod schema → validates the return value
244
- errors: { ... }, // optional declared errors (see errors.md)
245
- middleware: [ ... ], // optional Middleware[] (see below)
246
- handler: async (req) => { ... }, // required
247
- });
239
+ // controllers/rooms.controller.ts
240
+ import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
241
+ import type { UserT } from "@palbase/backend";
242
+ import { CreateRoomBody } from "../models/rooms/create.js";
243
+ import { RoomSchema } from "../models/rooms/shared.js";
244
+
245
+ @Controller("/rooms")
246
+ export default class RoomsController {
247
+ @Post("")
248
+ @Returns(RoomSchema)
249
+ async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
250
+ const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
251
+ return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
252
+ }
253
+
254
+ @Get("/{id}")
255
+ @Returns(RoomSchema)
256
+ async getOne(@Param("id") id: string): Promise<RoomSchema> {
257
+ const room = await Database.findById("rooms", id);
258
+ if (!room) throw new NotFound("Room does not exist", "room_not_found");
259
+ return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
260
+ }
261
+ }
248
262
  ```
249
263
 
250
- There is no `method` field — the HTTP verb (and path) live in the controller
251
- that mounts the handler via `route.get|post|put|patch|delete`.
264
+ ## Method decorators
252
265
 
253
- ## `req` (PBRequest)
266
+ `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
254
267
 
255
- | Field | Type | Notes |
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) |
268
+ ```ts
269
+ interface RouteOptions {
270
+ auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
271
+ rateLimit?: { max: number; window: number }; // optional, per route
272
+ }
273
+ ```
267
274
 
268
- `User` is `{ id: string; email?: string; role: string; metadata: Record<string, unknown> }`.
275
+ Input and output are NOT method-decorator options: input comes from the
276
+ parameter decorators, output from the method's return type. The method decorator
277
+ only carries route concerns (path, auth, rateLimit).
269
278
 
270
- ## Auth
279
+ ## Parameter decorators
271
280
 
272
- **Secure by default:** a handler requires authentication UNLESS it explicitly
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 }`.
281
+ | Decorator | Injects | Typed by | OpenAPI |
282
+ |---|---|---|---|
283
+ | `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
284
+ | `@Query(schema)` | query params | `: T` | query parameters |
285
+ | `@Param("id")` | one path param | `: string` | path parameter |
286
+ | `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
287
+ | `@User()` | authenticated user | `: UserT` | — (from token) |
288
+ | `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
289
+ | `@Client()` | parsed client info | `: ClientInfo` | — |
290
+ | `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
291
+ | `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
292
+
293
+ `@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
294
+ developer writes the matching type annotation (same name). `UserT` is the
295
+ authenticated-user type (`{ id: string; email?: string; role: string; metadata:
296
+ Record<string, unknown> }`) — exported under the alias `UserT` because the value
297
+ name `User` is the `@User()` decorator.
298
+
299
+ ## Output = return type
300
+
301
+ The success response is the method's RETURN TYPE. Pair it with `@Returns(schema)`
302
+ so the zod value drives the OpenAPI 200 response (the type annotation drives
303
+ compile-time checking; the zod value is lossless for codegen). A method that
304
+ returns nothing annotates `: void`.
305
+
306
+ ## Auth — controller default + route override cascade
307
+
308
+ **Secure by default:** a route requires authentication UNLESS it explicitly opts
309
+ out. Resolution order (most specific wins):
310
+
311
+ 1. Route-level `@Post("", { auth })` — wins if present.
312
+ 2. Controller-level `@Controller("/x", { auth })` — applies to routes without
313
+ their own `auth`.
314
+ 3. Default `true` (secure-by-default).
275
315
 
276
316
  ```ts
277
- // auth omitted entirely // AUTH REQUIRED req.user is non-null User
278
- auth: { required: true } // same: require any authenticated user
279
- auth: { required: true, role: "admin" } // require a specific role
280
- auth: { required: false } // PUBLICreq.user may be null (anon key only)
317
+ @Controller("/public", { auth: false }) // all routes default public
318
+ export default class PublicController {
319
+ @Get("/open") open(): Info { ... } // inherits public
320
+ @Get("/secret", { auth: true }) // OVERRIDESauthed
321
+ secret(@User() u: UserT): Secret { ... }
322
+ }
281
323
  ```
282
324
 
283
- Whether `req.user` is non-null is computed from the `auth` config at the type
284
- level (and matches the runtime exactly):
325
+ | effective `auth` | `@User()` type to annotate |
326
+ |------------------|----------------------------|
327
+ | required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
328
+ | public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
285
329
 
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` |
330
+ The runtime always applies the resolved effective auth; the static type you
331
+ annotate is best-effort (a parameter decorator cannot see the controller default
332
+ at the type level).
294
333
 
295
- To make a route PUBLIC, set `auth: { required: false }` (or `auth: false`). An
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.
334
+ ## Errors global throw classes
298
335
 
299
- ## Typed input/output
336
+ Throw an error class anywhere (controller OR service) — no `req`, no per-route
337
+ map. The runtime catches it and emits the standard envelope:
300
338
 
301
339
  ```ts
302
- import { defineHandler, z, Database } from "@palbase/backend";
303
-
304
- export default defineHandler({
305
- auth: { required: true },
306
- input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
307
- output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
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
- });
316
- ```
317
-
318
- A controller then mounts it with a method + path:
319
-
320
- ```ts
321
- // controllers/rooms.controller.ts
322
- import { defineController, route } from "@palbase/backend";
323
- import create from "../handlers/rooms/create.js";
340
+ import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
324
341
 
325
- export default defineController("/rooms", {
326
- create: route.post("/", create),
327
- });
342
+ throw new Conflict("title taken"); // → 409
343
+ throw new NotFound(); // → 404 ("Not found")
344
+ throw new PalError(418, "teapot", "custom"); // → custom status/code
328
345
  ```
329
346
 
347
+ See [errors.md](./errors.md) for the full set + the wire envelope shape.
348
+
330
349
  ## Middleware
331
350
 
332
351
  A middleware wraps a request. Define one in `middleware/<name>.ts`:
@@ -343,22 +362,7 @@ export default defineMiddleware(async (ctx, next) => {
343
362
  ```
344
363
 
345
364
  The middleware handler receives `(ctx, next)` — call `await next()` to run the
346
- rest of the chain (other middleware, then the endpoint handler). Note this uses
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
- ```
365
+ rest of the chain (other middleware, then the endpoint method).
362
366
 
363
367
 
364
368
 
@@ -438,6 +442,46 @@ await Database.transaction(async (tx) => {
438
442
  The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
439
443
  (no nested transaction). See [schema.md](./schema.md) for the full surface.
440
444
 
445
+ ## Bypassing RLS — `Database.asService()`
446
+
447
+ When a table has [Row-Level Security](./schema.md#row-level-security-rls)
448
+ policies, every `Database.*` call runs as the request's verified user
449
+ (`authenticated`), so the database filters out rows the user's policies don't
450
+ allow. That is the secure default.
451
+
452
+ Sometimes you need to read or write **across all users** — an admin endpoint, a
453
+ background job that fans out notifications, a cleanup task. For that, call
454
+ `Database.asService()`. It returns a sibling client that runs as the
455
+ `service_role` (which has `BYPASSRLS`), exposing the exact same surface —
456
+ `tables`, the raw string ops, and `transaction`:
457
+
458
+ ```ts
459
+ import { Database } from "@palbase/backend";
460
+
461
+ // RLS-enforced (default): only the caller's own rows.
462
+ const mine = await Database.tables.todos.findMany({});
463
+
464
+ // Service-role bypass: every user's rows. Explicit and greppable.
465
+ const all = await Database.asService().tables.todos.findMany({});
466
+ const rows = await Database.asService().query("SELECT count(*) FROM todos");
467
+
468
+ // A service-role transaction (the role is fixed for the whole tx):
469
+ await Database.asService().transaction(async (tx) => {
470
+ await tx.tables.todos.update(id, { done: true });
471
+ });
472
+ ```
473
+
474
+ Guidelines:
475
+
476
+ - **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
477
+ only where you genuinely need cross-user access. It is intentionally easy to
478
+ grep for in review.
479
+ - **No double-bypass / no nesting.** The sibling does not re-expose
480
+ `asService()`, and `tx` never exposes it — a transaction's role is fixed when
481
+ it begins. Use `Database.transaction(...)` for an authenticated tx and
482
+ `Database.asService().transaction(...)` for a service-role tx; you cannot mix
483
+ enforced and bypassed ops inside one interactive transaction.
484
+
441
485
 
442
486
 
443
487
  <!-- ===== schema.md ===== -->
@@ -451,8 +495,9 @@ everywhere — by default, with no import and no generic.
451
495
 
452
496
  ## Defining a schema
453
497
 
454
- The table NAME comes from the object key under `tables` there is one
455
- canonical form.
498
+ The table NAME comes from the object key under `tables`. Each table value is an
499
+ object `{ columns, rls?, policies? }` — `columns` is required; `rls` and
500
+ `policies` enable [Row-Level Security](#row-level-security-rls).
456
501
 
457
502
  ```ts
458
503
  import {
@@ -463,23 +508,29 @@ import {
463
508
  export default defineSchema({
464
509
  tables: {
465
510
  rooms: {
466
- id: uuid().primaryKey().defaultRandom(),
467
- name: text().notNull(),
468
- capacity: integer().nullable(),
469
- is_active: boolean().default(true),
470
- created_at: timestamp().defaultNow(),
511
+ columns: {
512
+ id: uuid().primaryKey().defaultRandom(),
513
+ name: text().notNull(),
514
+ capacity: integer().nullable(),
515
+ is_active: boolean().default(true),
516
+ created_at: timestamp().defaultNow(),
517
+ },
471
518
  },
472
519
  sessions: {
473
- id: uuid().primaryKey().defaultRandom(),
474
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
475
- user_id: uuid().notNull(),
476
- data: jsonb().nullable(),
477
- started_at: timestamp().defaultNow(),
520
+ columns: {
521
+ id: uuid().primaryKey().defaultRandom(),
522
+ room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
523
+ user_id: uuid().notNull(),
524
+ data: jsonb().nullable(),
525
+ started_at: timestamp().defaultNow(),
526
+ },
478
527
  },
479
528
  orders: {
480
- id: uuid().primaryKey().defaultRandom(),
481
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
482
- amount: integer().notNull(),
529
+ columns: {
530
+ id: uuid().primaryKey().defaultRandom(),
531
+ status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
532
+ amount: integer().notNull(),
533
+ },
483
534
  },
484
535
  },
485
536
  });
@@ -509,17 +560,21 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
509
560
  of the schema, no generic, no cast:
510
561
 
511
562
  ```ts
512
- import { defineHandler, z, Database } from "@palbase/backend";
563
+ import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
513
564
 
514
- export default defineHandler({
515
- input: z.object({ name: z.string() }),
516
- output: z.object({ id: z.string(), name: z.string() }),
517
- handler: async (req) => {
518
- const room = await Database.tables.rooms.insert({ name: req.input.name });
565
+ const CreateRoomBody = z.object({ name: z.string() });
566
+ const RoomOut = z.object({ id: z.string(), name: z.string() });
567
+
568
+ @Controller("/rooms")
569
+ export default class RoomsController {
570
+ @Post("")
571
+ @Returns(RoomOut)
572
+ async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
573
+ const room = await Database.tables.rooms.insert({ name: body.name });
519
574
  return { id: room.id, name: room.name }; // room.id: string ✓
520
575
  // room.nope ← compile error
521
- },
522
- });
576
+ }
577
+ }
523
578
  ```
524
579
 
525
580
  `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
@@ -535,6 +590,106 @@ import type { Tables } from "@palbase/backend/env";
535
590
  type Room = Tables["rooms"]["row"];
536
591
  ```
537
592
 
593
+ ## Row-Level Security (RLS)
594
+
595
+ RLS pushes per-user access control **into Postgres**: every `Database.*` query
596
+ runs as the request's verified user (the `authenticated` role with that user's
597
+ claims), and the database itself filters rows your policies don't allow. A
598
+ missing `WHERE user_id = …` in your handler can no longer leak another user's
599
+ rows — the policy enforces it. This is the recommended way to scope data per
600
+ user.
601
+
602
+ Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
603
+ implies `rls: true` automatically (a table with policies must have RLS enabled
604
+ or the policies are inert). Set `rls: true` with no policies only as a
605
+ deliberate deny-all intermediate step.
606
+
607
+ ### The `policy()` builder
608
+
609
+ `policy(name)` is a fluent builder, just like the column builders:
610
+
611
+ ```ts
612
+ import { policy } from "@palbase/backend";
613
+
614
+ policy("pb_owner_all")
615
+ .for("all") // "all" | "select" | "insert" | "update" | "delete"
616
+ .to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
617
+ .using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
618
+ .withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
619
+ ```
620
+
621
+ | Method | Default | Meaning |
622
+ |--------|---------|---------|
623
+ | `.for(cmd)` | `"all"` | The SQL command the policy governs. |
624
+ | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
625
+ | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
626
+ | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
627
+ | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
628
+
629
+ **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
630
+ request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
631
+ once per statement (an initPlan) instead of once per row. `auth.role()` and
632
+ `auth.jwt()` are also available. With no user on the request (anon/public),
633
+ `auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
634
+ rows.
635
+
636
+ > Name policies with a `pb_` prefix. Palbase reconciliation only manages
637
+ > policies it authored (`pb_`-prefixed) and never touches policies created by
638
+ > other modules (storage, cron, …).
639
+
640
+ ### Owner-scoped `todos` example
641
+
642
+ ```ts
643
+ import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
644
+
645
+ export default defineSchema({
646
+ tables: {
647
+ todos: {
648
+ columns: {
649
+ id: uuid().primaryKey().defaultRandom(),
650
+ owner: text().notNull(), // palauth user id (TEXT)
651
+ title: text().notNull(),
652
+ done: boolean().default(false),
653
+ created_at: timestamp().defaultNow(),
654
+ },
655
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
656
+ policies: [
657
+ // Read: a user sees only their own todos.
658
+ policy("pb_todos_owner_select")
659
+ .for("select")
660
+ .to("authenticated")
661
+ .using("owner = (select auth.uid())"),
662
+
663
+ // Write: a user can insert/update/delete only rows they own.
664
+ policy("pb_todos_owner_write")
665
+ .for("all")
666
+ .to("authenticated")
667
+ .using("owner = (select auth.uid())")
668
+ .withCheck("owner = (select auth.uid())"),
669
+ ],
670
+ },
671
+ },
672
+ });
673
+ ```
674
+
675
+ With this in place, `await Database.tables.todos.findMany({})` returns only the
676
+ calling user's rows — no `WHERE owner = …` needed in the handler. To read or
677
+ write across all users (e.g. an admin job), use the explicit bypass:
678
+ `Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
679
+
680
+ ### How policies are applied
681
+
682
+ On deploy, Palbase diffs your declared schema against the live database and
683
+ applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
684
+ when the table doesn't already have it, and `CREATE POLICY` only for policies
685
+ that don't already exist (keyed by `(table, name)`). These are non-destructive,
686
+ so they apply without the `acceptDataLoss` confirmation that column drops need.
687
+
688
+ > Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
689
+ > auto-applied — rename the policy (new `(table, name)`) or drop the old one in
690
+ > a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
691
+
692
+
538
693
 
539
694
 
540
695
  <!-- ===== migrations.md ===== -->
@@ -734,17 +889,21 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
734
889
  ## Flags
735
890
 
736
891
  ```ts
737
- import { defineHandler, z, Flags } from "@palbase/backend";
738
-
739
- export default defineHandler({
740
- auth: { required: true }, // req.user is non-null here
741
- output: z.object({ enabled: z.boolean() }),
742
- handler: async (req) => {
743
- const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
744
- const { data: variant } = await Flags.getVariant("button-color", { userId: req.user.id });
892
+ import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
893
+ import type { UserT } from "@palbase/backend";
894
+
895
+ const FlagsOut = z.object({ enabled: z.boolean() });
896
+
897
+ @Controller("/checkout")
898
+ export default class CheckoutController {
899
+ @Get("/flags") // auth omitted required user is non-null
900
+ @Returns(FlagsOut)
901
+ async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
902
+ const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
903
+ const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
745
904
  return { enabled: enabled ?? false };
746
- },
747
- });
905
+ }
906
+ }
748
907
  ```
749
908
 
750
909
 
@@ -852,51 +1011,58 @@ const results = (await google.nearby(41.0, 29.0)).data.results;
852
1011
 
853
1012
  # Errors
854
1013
 
855
- Two ways to fail a request. Both serialize to the standard Palbase error
856
- envelope:
1014
+ Throw an error class to fail a request. Every one serializes to the standard
1015
+ Palbase error envelope:
857
1016
 
858
1017
  ```json
859
1018
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
860
1019
  ```
861
1020
 
862
- ## 1. `HttpError` (ad-hoc)
1021
+ Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
1022
+ per-route error map: the runtime catches any thrown error class and emits the
1023
+ envelope.
1024
+
1025
+ ## Named status classes
863
1026
 
864
1027
  ```ts
865
- import { HttpError } from "@palbase/backend";
866
- throw new HttpError(404, "todo_not_found", "No such todo");
867
- // optional structured payload (4th arg) rides along under `data`:
868
- throw new HttpError(423, "todo_locked", "Locked", { retryAfter: 30 });
1028
+ import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests } from "@palbase/backend";
1029
+
1030
+ throw new Conflict("title taken"); // 409 ("conflict" code by default)
1031
+ throw new NotFound(); // → 404 ("not_found", "Not found")
1032
+ throw new BadRequest("missing field"); // → 400
1033
+ throw new Unauthorized(); // → 401
1034
+ throw new Forbidden(); // → 403
1035
+ throw new TooManyRequests(); // → 429
869
1036
  ```
870
1037
 
871
- `new HttpError(status, code, description, data?)`.
1038
+ Each class fixes its HTTP status. The constructor is
1039
+ `new <Class>(message?, code?, data?)`:
872
1040
 
873
- ## 2. Declared errors (typed)
1041
+ - `message` overrides the human-readable `error_description` (defaults to a label
1042
+ derived from the class name).
1043
+ - `code` overrides the wire `error` code (defaults to the class's snake_case
1044
+ code, e.g. `not_found`).
1045
+ - `data` rides along under the envelope's `data` field for structured context.
874
1046
 
875
- Declare them on the handler; throw via `req.errors.<name>(...)`. Declared
876
- errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
877
- for iOS callers.
1047
+ ```ts
1048
+ throw new NotFound("Room does not exist", "room_not_found");
1049
+ throw new Conflict("locked", "title_locked", { retryAfter: 30 });
1050
+ ```
1051
+
1052
+ ## `PalError` / `HttpError` (custom status)
1053
+
1054
+ For a status/code not covered by a named class:
878
1055
 
879
1056
  ```ts
880
- import { defineHandler, z, Database } from "@palbase/backend";
881
-
882
- export default defineHandler({
883
- input: z.object({ id: z.string() }),
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
- });
1057
+ import { PalError } from "@palbase/backend";
1058
+ throw new PalError(418, "teapot", "I'm a teapot");
1059
+ // optional structured payload (4th arg) rides along under `data`:
1060
+ throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
896
1061
  ```
897
1062
 
898
- A declared error with a `data` Zod schema requires that payload as an argument;
899
- one without `data` takes no arguments. This is enforced by the types.
1063
+ `PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
1064
+ The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
1065
+ HttpError) … }` matches any of them.
900
1066
 
901
1067
 
902
1068