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