@palbase/backend 2.0.2 → 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 (53) hide show
  1. package/dist/chunk-EG7TTYHY.js +235 -0
  2. package/dist/chunk-EG7TTYHY.js.map +1 -0
  3. package/dist/chunk-WUQO76NW.js +101 -0
  4. package/dist/chunk-WUQO76NW.js.map +1 -0
  5. package/dist/db/env.cjs +19 -0
  6. package/dist/db/env.cjs.map +1 -0
  7. package/dist/db/env.d.cts +45 -0
  8. package/dist/db/env.d.ts +45 -0
  9. package/dist/db/env.js +1 -0
  10. package/dist/db/env.js.map +1 -0
  11. package/dist/db/index.cjs +143 -231
  12. package/dist/db/index.cjs.map +1 -1
  13. package/dist/db/index.d.cts +4 -20
  14. package/dist/db/index.d.ts +4 -20
  15. package/dist/db/index.js +13 -233
  16. package/dist/db/index.js.map +1 -1
  17. package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-2d_DpASt.d.cts} +94 -96
  18. package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-2d_DpASt.d.ts} +94 -96
  19. package/dist/index-DZW9CjiY.d.ts +463 -0
  20. package/dist/index-DzRFS3Tl.d.cts +463 -0
  21. package/dist/index.cjs +557 -60
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +278 -161
  24. package/dist/index.d.ts +278 -161
  25. package/dist/index.js +343 -12
  26. package/dist/index.js.map +1 -1
  27. package/dist/test/index.cjs +57 -2
  28. package/dist/test/index.cjs.map +1 -1
  29. package/dist/test/index.d.cts +1 -2
  30. package/dist/test/index.d.ts +1 -2
  31. package/dist/test/index.js +10 -2
  32. package/dist/test/index.js.map +1 -1
  33. package/docs/README.md +33 -12
  34. package/docs/background.md +19 -13
  35. package/docs/database.md +70 -17
  36. package/docs/endpoints.md +103 -79
  37. package/docs/errors.md +37 -31
  38. package/docs/events.md +25 -17
  39. package/docs/getting-started.md +38 -18
  40. package/docs/llms-full.txt +758 -267
  41. package/docs/llms.txt +3 -1
  42. package/docs/migrations.md +98 -0
  43. package/docs/resources.md +94 -0
  44. package/docs/routing.md +54 -27
  45. package/docs/schema.md +163 -42
  46. package/docs/services.md +17 -14
  47. package/package.json +12 -2
  48. package/dist/chunk-4J3F32SH.js +0 -96
  49. package/dist/chunk-4J3F32SH.js.map +0 -1
  50. package/dist/chunk-L36JLUPO.js +0 -97
  51. package/dist/chunk-L36JLUPO.js.map +0 -1
  52. package/dist/schema-BqfEhIC0.d.cts +0 -133
  53. package/dist/schema-BqfEhIC0.d.ts +0 -133
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
3
- > File-based TypeScript backend SDK. Endpoints use `req` (PBRequest) + imported service singletons (`Database`, `Cache`, …). Workers/jobs/hooks/webhooks use a `ctx` object. Not Express, not Supabase Edge Functions.
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.
4
4
 
5
5
 
6
6
 
@@ -15,25 +15,41 @@ This is **not** Express, Fastify, or a Supabase Edge Function. There is no
15
15
  `app.get(...)`, no manual route registration, no `import express`. You export
16
16
  definitions; the runtime wires them up.
17
17
 
18
- ## The two mental models (important)
18
+ ## Mental model (important)
19
19
 
20
- | You are writing… | Handler receives | Services come from |
21
- |------------------|------------------|--------------------|
22
- | **Endpoints** (`endpoints/**`) | a single `req` ([PBRequest](./endpoints.md)) | imported singletons: `import { Database } from "@palbase/backend"` |
23
- | **Workers, Jobs, Hooks, Webhooks** | a `ctx` object | `ctx.db`, `ctx.log`, `ctx.cache`, `ctx.queue` |
20
+ Every handler type imports service singletons the same way:
24
21
 
25
- Endpoints use `req` + imported singletons. Everything else uses `ctx`. Do not
26
- mix them: there is no `ctx` inside an endpoint handler, and no imported
27
- `Database` singleton call inside a worker (use `ctx.db`).
22
+ ```ts
23
+ import { Database, Log, Cache, Queue } from "@palbase/backend";
24
+ ```
25
+
26
+ The **only difference** is the trigger argument:
27
+
28
+ | You are writing… | Handler signature | Trigger arg |
29
+ |------------------|-------------------|-------------|
30
+ | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
31
+ | **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
32
+ | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
33
+ | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
34
+ | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
35
+ | **Middleware** (`middleware/**`) | `(ctx, next)` | `MiddlewareContext` — the **one exception** |
36
+
37
+ `meta` carries non-service data: `env` (branch env vars), `projectId`,
38
+ `environmentId`, and for workers/webhooks `requestId`. Services always come from
39
+ the imported singletons — not from `ctx` or any argument.
28
40
 
29
41
  ## Project shape
30
42
 
31
43
  ```
32
44
  my-backend/
33
45
  ├── package.json # depends on @palbase/backend
34
- ├── endpoints/ # HTTP endpoints (file-based routing)
35
- │ └── hello/get.ts # GET /hello
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
36
51
  ├── db/schema.ts # table definitions (optional, enables typed DB)
52
+ ├── db/migrations/ # explicit SQL migrations for type changes (optional)
37
53
  ├── workers/ # background job handlers (optional)
38
54
  ├── jobs/ # cron-scheduled jobs (optional)
39
55
  ├── hooks/ # auth/storage/document event hooks (optional)
@@ -41,15 +57,20 @@ my-backend/
41
57
  └── middleware/ # cross-cutting request middleware (optional)
42
58
  ```
43
59
 
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
+
44
64
  ## Documentation
45
65
 
46
66
  | Topic | File |
47
67
  |-------|------|
48
68
  | Getting started | [getting-started.md](./getting-started.md) |
49
- | File-based routing | [routing.md](./routing.md) |
50
- | Endpoints & `req` | [endpoints.md](./endpoints.md) |
69
+ | Routing (class controllers) | [routing.md](./routing.md) |
70
+ | Endpoints (decorators) | [endpoints.md](./endpoints.md) |
51
71
  | Database & transactions | [database.md](./database.md) |
52
72
  | Schema & typed DB | [schema.md](./schema.md) |
73
+ | Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
53
74
  | Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
54
75
  | Errors | [errors.md](./errors.md) |
55
76
  | Workers & Jobs | [background.md](./background.md) |
@@ -77,7 +98,6 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
77
98
  "private": true,
78
99
  "scripts": {
79
100
  "dev": "palbase serve",
80
- "deploy": "palbase push",
81
101
  "typecheck": "tsc --noEmit"
82
102
  },
83
103
  "dependencies": { "@palbase/backend": "latest" },
@@ -87,157 +107,245 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
87
107
 
88
108
  ## Local dev loop
89
109
 
90
- - `palbase serve` — run your backend locally with hot reload.
91
- - `palbase push` deploy the current directory to your project's backend runtime.
92
- - `palbase push --branch <name>` deploy to a branch instead of `main`.
110
+ - `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.
115
+ - **Deploy is GitHub-native** — there is no `palbase push`. Commit and
116
+ `git push` to your project's repo; the push triggers a deploy of the backend
117
+ runtime. Push a **branch** to deploy that branch instead of `main`.
93
118
 
94
119
  ## Your first endpoint
95
120
 
96
- Create `endpoints/hello/get.ts`:
121
+ An endpoint is a method on a class controller. Declare the schemas in `models/`:
97
122
 
98
123
  ```ts
99
- import { defineEndpoint, z } from "@palbase/backend";
124
+ // models/hello/greet.ts
125
+ import { z } from "@palbase/backend";
100
126
 
101
- export default defineEndpoint({
102
- method: "GET",
103
- input: z.object({ name: z.string().optional() }),
104
- output: z.object({ message: z.string(), user: z.string().nullable() }),
105
- handler: async (req) => ({
106
- message: `hello, ${req.input.name ?? "world"}!`,
107
- user: req.user?.id ?? null,
108
- }),
109
- });
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>;
132
+ ```
133
+
134
+ Then write the controller in `controllers/hello.controller.ts`:
135
+
136
+ ```ts
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
+ }
110
149
  ```
111
150
 
112
- This is served at `GET /hello`. The Zod `input` schema validates the request and
113
- flows into `req.input`; the `output` schema validates your return value.
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.
114
155
 
115
156
 
116
157
 
117
158
  <!-- ===== routing.md ===== -->
118
159
 
119
- # File-based routing
160
+ # Routing
120
161
 
121
- The path of a file under `endpoints/` plus its filename determine the route.
122
- The filename is the HTTP method.
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
167
+ central router and no manual registration.
123
168
 
124
- | File | Route |
125
- |------|-------|
126
- | `endpoints/hello/get.ts` | `GET /hello` |
127
- | `endpoints/items/post.ts` | `POST /items` |
128
- | `endpoints/posts/[id]/get.ts` | `GET /posts/:id` |
129
- | `endpoints/posts/[id]/patch.ts` | `PATCH /posts/:id` |
130
- | `endpoints/rooms/[id]/sessions/post.ts` | `POST /rooms/:id/sessions` |
169
+ ```ts
170
+ import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
171
+ ```
131
172
 
132
- Rules:
173
+ ## Controllers — class + method decorators
133
174
 
134
- - The method file name is one of `get`, `post`, `put`, `patch`, `delete` (`.ts`).
135
- - A `[segment]` directory becomes a `:segment` path param, read via `req.params.segment`.
136
- - Each method file `export default defineEndpoint({...})` — one endpoint per file.
137
- - There is no central router file and no manual registration. Adding a file adds a route.
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).
138
178
 
139
179
  ```ts
140
- // endpoints/posts/[id]/get.ts → GET /posts/:id
141
- import { defineEndpoint, z, Database, HttpError } from "@palbase/backend";
142
-
143
- export default defineEndpoint({
144
- method: "GET",
145
- output: z.object({ id: z.string(), title: z.string() }),
146
- handler: async (req) => {
147
- const post = await Database.findById("posts", req.params.id!);
148
- if (!post) throw new HttpError(404, "post_not_found", "No such post");
149
- return { id: post.id as string, title: post.title as string };
150
- },
151
- });
180
+ // controllers/places.controller.ts
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
+ }
152
201
  ```
153
202
 
203
+ | Method name (sugar) | Verb | Full path | operationId (flat) |
204
+ |---|---|---|---|
205
+ | `importNearby` | POST | `/places/import` | `postPlacesImport` |
206
+ | `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
207
+
208
+ Rules:
209
+
210
+ - The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
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).
216
+ - The operationId is derived FLAT from method + full path (`postPlacesImport`),
217
+ not from the method name. Change `@Post` → `@Put` — no file rename.
218
+
219
+ See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
220
+ options, the parameter decorators, auth cascade, and error classes).
221
+
154
222
 
155
223
 
156
224
  <!-- ===== endpoints.md ===== -->
157
225
 
158
226
  # Endpoints
159
227
 
160
- An endpoint is `export default defineEndpoint({...})` in a method file. The
161
- handler receives **one argument**, `req` (a `PBRequest`). Services are NOT on
162
- `req` import them as 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)).
163
235
 
164
- ## `defineEndpoint` config
236
+ ## A controller
165
237
 
166
238
  ```ts
167
- defineEndpoint({
168
- method: "POST", // required: GET | POST | PUT | PATCH | DELETE
169
- auth: { required: true }, // optional; see Auth below. Omitted → public.
170
- rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
171
- input: z.object({ ... }), // optional Zod schema → validates & types req.input
172
- output: z.object({ ... }), // optional Zod schema → validates the return value
173
- errors: { ... }, // optional declared errors (see errors.md)
174
- middleware: [ ... ], // optional Middleware[] (see below)
175
- handler: async (req) => { ... }, // required
176
- });
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
+ }
262
+ ```
263
+
264
+ ## Method decorators
265
+
266
+ `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
267
+
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
+ }
177
273
  ```
178
274
 
179
- ## `req` (PBRequest)
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).
278
+
279
+ ## Parameter decorators
180
280
 
181
- | Field | Type | Notes |
182
- |-------|------|-------|
183
- | `req.input` | inferred from `input` schema | request body for POST/PUT/PATCH; `{}` otherwise |
184
- | `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
185
- | `req.query` | `Record<string,string>` | parsed query string |
186
- | `req.headers` | `Record<string,string>` | lowercase keys |
187
- | `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
188
- | `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
189
- | `req.file` | `FileContext \| null` | uploaded file, if any |
190
- | `req.method` | `string` | the HTTP method |
191
- | `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
192
- | `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
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` | |
193
292
 
194
- `User` is `{ id: string; email: string; role: string; metadata: Record<string, unknown> }`.
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.
195
298
 
196
- ## Auth
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).
197
315
 
198
316
  ```ts
199
- auth: { required: true } // require any authenticated user → req.user is non-null User
200
- auth: { required: true, role: "admin" } // require a specific role
201
- auth: { required: false } // publicreq.user may be null
202
- // auth omitted entirely // also public req.user is User | null
317
+ @Controller("/public", { auth: false }) // all routes default public
318
+ export default class PublicController {
319
+ @Get("/open") open(): Info { ... } // inheritspublic
320
+ @Get("/secret", { auth: true }) // OVERRIDESauthed
321
+ secret(@User() u: UserT): Secret { ... }
322
+ }
203
323
  ```
204
324
 
205
- Whether `req.user` is non-null is computed from the `auth` config at the type
206
- level:
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` |
207
329
 
208
- | `auth` value | `req.user` type |
209
- |--------------|-----------------|
210
- | omitted | `User \| null` |
211
- | `true` | `User` |
212
- | `false` | `User \| null` |
213
- | `{ required: true }` | `User` |
214
- | `{ required: false }` | `User \| null` |
215
- | `{ 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).
216
333
 
217
- To enforce authentication, set `auth: { required: true }` (or `auth: true`). An
218
- object with a `role` but no `required` key is treated as authenticated. When
219
- `auth` is omitted, the endpoint is public and `req.user` may be null.
334
+ ## Errors global throw classes
220
335
 
221
- ## 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:
222
338
 
223
339
  ```ts
224
- import { defineEndpoint, z, Database } from "@palbase/backend";
340
+ import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
225
341
 
226
- export default defineEndpoint({
227
- method: "POST",
228
- auth: { required: true },
229
- input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
230
- output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
231
- handler: async (req) => {
232
- const room = await Database.insert("rooms", {
233
- name: req.input.name,
234
- capacity: req.input.capacity ?? null,
235
- });
236
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
237
- },
238
- });
342
+ throw new Conflict("title taken"); // → 409
343
+ throw new NotFound(); // → 404 ("Not found")
344
+ throw new PalError(418, "teapot", "custom"); // custom status/code
239
345
  ```
240
346
 
347
+ See [errors.md](./errors.md) for the full set + the wire envelope shape.
348
+
241
349
  ## Middleware
242
350
 
243
351
  A middleware wraps a request. Define one in `middleware/<name>.ts`:
@@ -254,23 +362,7 @@ export default defineMiddleware(async (ctx, next) => {
254
362
  ```
255
363
 
256
364
  The middleware handler receives `(ctx, next)` — call `await next()` to run the
257
- rest of the chain (other middleware, then the endpoint handler). Note this uses
258
- the `ctx` model, not `req`.
259
-
260
- To attach middleware to a specific endpoint, import it and list it in the
261
- endpoint's `middleware` array:
262
-
263
- ```ts
264
- import { defineEndpoint, z } from "@palbase/backend";
265
- import logger from "../../middleware/logger.js";
266
-
267
- export default defineEndpoint({
268
- method: "GET",
269
- middleware: [logger],
270
- output: z.object({ ok: z.boolean() }),
271
- handler: async (req) => ({ ok: true }),
272
- });
273
- ```
365
+ rest of the chain (other middleware, then the endpoint method).
274
366
 
275
367
 
276
368
 
@@ -278,17 +370,38 @@ export default defineEndpoint({
278
370
 
279
371
  # Database
280
372
 
281
- In **endpoints**, import the `Database` singleton:
373
+ Import the `Database` singleton in every handler type — endpoints, workers,
374
+ jobs, hooks, and webhooks all use the same import:
282
375
 
283
376
  ```ts
284
377
  import { Database } from "@palbase/backend";
285
378
  ```
286
379
 
287
- In **workers, jobs, hooks, and webhooks**, use `ctx.db` the same surface,
288
- reached via the context object (see [background.md](./background.md) and
289
- [events.md](./events.md)).
380
+ Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
381
+ and [events.md](./events.md) for worker/job/hook/webhook examples).
382
+
383
+ ## Typed by default — `Database.tables`
384
+
385
+ When you declare `db/schema.ts`, `Database.tables.<name>` is typed everywhere
386
+ with no import and no generic. `insert` demands the right columns; rows come
387
+ back typed; nullable columns are `T | null`. This is the path you should use:
388
+
389
+ ```ts
390
+ const todo = await Database.tables.todos.insert({ title: "buy milk" });
391
+ todo.id; // string ✓
392
+ todo.done; // boolean ✓
393
+ const open = await Database.tables.todos.findMany({ done: false });
394
+ await Database.tables.todos.update(todo.id, { done: true });
395
+ await Database.tables.todos.delete(todo.id);
396
+ // todo.nope ← compile error
397
+ ```
398
+
399
+ See [schema.md](./schema.md) for the full typed-table surface.
290
400
 
291
- ## Operations
401
+ ## Raw string-keyed operations
402
+
403
+ For dynamic table names or read-only SQL, the string-keyed ops are still
404
+ available:
292
405
 
293
406
  | Method | Returns |
294
407
  |--------|---------|
@@ -300,14 +413,6 @@ reached via the context object (see [background.md](./background.md) and
300
413
  | `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
301
414
  | `Database.transaction(fn)` | runs `fn(tx)` in a transaction |
302
415
 
303
- ```ts
304
- const row = await Database.insert("todos", { title: "buy milk", done: false });
305
- const one = await Database.findById("todos", row.id as string);
306
- const open = await Database.findMany("todos", { done: false });
307
- await Database.update("todos", row.id as string, { done: true });
308
- await Database.delete("todos", row.id as string);
309
- ```
310
-
311
416
  `findMany`'s `query` is an equality filter: keys are ANDed together. For
312
417
  anything richer (ranges, ordering, joins) use `Database.query`.
313
418
 
@@ -328,14 +433,54 @@ transaction). Returning commits; throwing rolls back.
328
433
 
329
434
  ```ts
330
435
  await Database.transaction(async (tx) => {
331
- const order = await tx.insert("orders", { amount: 1000, status: "pending" });
332
- await tx.insert("order_items", { order_id: order.id, sku: "ABC" });
436
+ const order = await tx.tables.orders.insert({ amount: 1000, status: "pending" });
437
+ await tx.tables.order_items.insert({ order_id: order.id, sku: "ABC" });
333
438
  // throw here → both inserts roll back
334
439
  });
335
440
  ```
336
441
 
337
- For a typed `.tables.*` API instead of string table names, see
338
- [schema.md](./schema.md).
442
+ The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
443
+ (no nested transaction). See [schema.md](./schema.md) for the full surface.
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.
339
484
 
340
485
 
341
486
 
@@ -344,36 +489,50 @@ For a typed `.tables.*` API instead of string table names, see
344
489
  # Schema & typed database access
345
490
 
346
491
  Declare your tables in `db/schema.ts` with `defineSchema`. This drives
347
- migrations and, via `typedDatabase`, a fully-typed `.tables.*` API.
492
+ [migrations](./migrations.md) (additive changes auto-apply on deploy; type
493
+ changes need an explicit migration) and makes `Database.tables.*` typed
494
+ everywhere — by default, with no import and no generic.
348
495
 
349
496
  ## Defining a schema
350
497
 
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).
501
+
351
502
  ```ts
352
503
  import {
353
- defineSchema, table,
504
+ defineSchema,
354
505
  uuid, text, integer, boolean, timestamp, jsonb, enumType,
355
506
  } from "@palbase/backend";
356
507
 
357
508
  export default defineSchema({
358
- rooms: table("rooms", {
359
- id: uuid().primaryKey().defaultRandom(),
360
- name: text().notNull(),
361
- capacity: integer().nullable(),
362
- is_active: boolean().default(true),
363
- created_at: timestamp().defaultNow(),
364
- }),
365
- sessions: table("sessions", {
366
- id: uuid().primaryKey().defaultRandom(),
367
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
368
- user_id: uuid().notNull(),
369
- data: jsonb().nullable(),
370
- started_at: timestamp().defaultNow(),
371
- }),
372
- orders: table("orders", {
373
- id: uuid().primaryKey().defaultRandom(),
374
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
375
- amount: integer().notNull(),
376
- }),
509
+ tables: {
510
+ rooms: {
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
+ },
518
+ },
519
+ sessions: {
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
+ },
527
+ },
528
+ orders: {
529
+ columns: {
530
+ id: uuid().primaryKey().defaultRandom(),
531
+ status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
532
+ amount: integer().notNull(),
533
+ },
534
+ },
535
+ },
377
536
  });
378
537
  ```
379
538
 
@@ -394,35 +553,245 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
394
553
  `.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
395
554
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`.
396
555
 
397
- ## Typed DB access
556
+ ## Typed DB access — by default
557
+
558
+ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
559
+ `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
560
+ of the schema, no generic, no cast:
561
+
562
+ ```ts
563
+ import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
564
+
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 });
574
+ return { id: room.id, name: room.name }; // room.id: string ✓
575
+ // room.nope ← compile error
576
+ }
577
+ }
578
+ ```
579
+
580
+ `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
581
+ `findById(id)`, `findMany(query?)`, and `Database.transaction(fn)` yields a `tx`
582
+ with the same typed tables. The raw string-keyed ops
583
+ (`Database.insert("rooms", …)`, `Database.query(…)`) are still available for
584
+ dynamic table names and read-only SQL.
585
+
586
+ If you want a row type explicitly, import it from the generated env module:
587
+
588
+ ```ts
589
+ import type { Tables } from "@palbase/backend/env";
590
+ type Room = Tables["rooms"]["row"];
591
+ ```
592
+
593
+ ## Row-Level Security (RLS)
398
594
 
399
- `typedDatabase(schema)` returns a typed facade. `insert` demands the right
400
- columns; rows come back typed; nullable columns are `T | null`.
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:
401
610
 
402
611
  ```ts
403
- import { defineEndpoint, z, typedDatabase } from "@palbase/backend";
404
- import schema from "../../db/schema.js";
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). |
405
628
 
406
- const Db = typedDatabase(schema);
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.
407
635
 
408
- export default defineEndpoint({
409
- method: "POST",
410
- input: z.object({ name: z.string() }),
411
- output: z.object({ id: z.string(), name: z.string() }),
412
- handler: async (req) => {
413
- const room = await Db.tables.rooms.insert({ name: req.input.name });
414
- return { id: room.id, name: room.name };
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
+ },
415
671
  },
416
672
  });
417
673
  ```
418
674
 
419
- Note the `.js` extension on `../../db/schema.js` even though the file is
420
- `db/schema.ts`this is standard ESM module resolution; you still author the
421
- file as `.ts`.
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
+
693
+
694
+
695
+ <!-- ===== migrations.md ===== -->
422
696
 
423
- `Db.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
424
- `findById(id)`, `findMany(query?)`, and `Db.transaction(fn)` mirrors the
425
- untyped transaction with typed tables.
697
+ # Migrations
698
+
699
+ `db/schema.ts` is the single source of truth for your Postgres schema. On every
700
+ deploy, Palbase diffs your declared schema against the live branch database and
701
+ reconciles it — but *how* it reconciles depends on whether the change is safe to
702
+ apply automatically.
703
+
704
+ ## Two kinds of change
705
+
706
+ ### 1. Additive — auto-applied, no migration file
707
+
708
+ A new table, or a new **nullable** or **defaulted** column, is additive: the
709
+ deploy applies it automatically (`CREATE TABLE` / `ADD COLUMN`) with no manual
710
+ step and no backfill risk. Just edit `db/schema.ts` and deploy.
711
+
712
+ ```ts
713
+ // before
714
+ todos: {
715
+ id: uuid().primaryKey().defaultRandom(),
716
+ title: text().notNull(),
717
+ }
718
+
719
+ // after — additive: `notes` (nullable) + `priority` (defaulted) auto-apply on deploy
720
+ todos: {
721
+ id: uuid().primaryKey().defaultRandom(),
722
+ title: text().notNull(),
723
+ notes: text().nullable(),
724
+ priority: text().nullable().default("normal"),
725
+ }
726
+ ```
727
+
728
+ > A new **NOT NULL column without a default** is NOT additive-safe on a table
729
+ > that already has rows (there is nothing to put in the existing rows). Make it
730
+ > `.nullable()`, give it a `.default(...)`, or apply it as an explicit migration
731
+ > (add nullable → backfill → set NOT NULL).
732
+
733
+ ### 2. Destructive / type-changing — needs an explicit migration
734
+
735
+ Renaming or dropping a column, changing a column's type, or adding a NOT NULL
736
+ constraint can lose or corrupt existing data — so the deploy's **drift-gate
737
+ blocks them** and the deploy fails until you provide an explicit migration.
738
+ Write the SQL yourself in `db/migrations/`:
739
+
740
+ ```
741
+ db/migrations/
742
+ 001_user_id_to_text.up.sql
743
+ 001_user_id_to_text.down.sql
744
+ ```
745
+
746
+ ```sql
747
+ -- 001_user_id_to_text.up.sql
748
+ ALTER TABLE todos ALTER COLUMN user_id TYPE text USING user_id::text;
749
+ ```
750
+
751
+ ```sql
752
+ -- 001_user_id_to_text.down.sql
753
+ ALTER TABLE todos ALTER COLUMN user_id TYPE uuid USING user_id::uuid;
754
+ ```
755
+
756
+ Migrations are golang-migrate style: numbered `NNN_name.up.sql` / `.down.sql`
757
+ pairs, applied in order and tracked so each runs exactly once (idempotent).
758
+ `db/schema.ts` always describes the **end state**; the migration describes **how
759
+ existing data gets there**. Keep the two in sync — after the migration lands,
760
+ `schema.ts` should already reflect the new column type.
761
+
762
+ ## The drift-gate
763
+
764
+ On deploy, Palbase compares `db/schema.ts` to the live database:
765
+
766
+ - **Additive** diffs → auto-applied.
767
+ - **Type-changing / destructive** diffs **with** a matching migration → the
768
+ migration runs.
769
+ - **Type-changing / destructive** diffs **without** a migration → the deploy
770
+ **aborts** and your currently-running version keeps serving.
771
+
772
+ This is deliberate: it stops an accidental column-type change from silently
773
+ dropping production data. A blocked deploy is a prompt to write the migration,
774
+ not a failure to work around.
775
+
776
+ ## Local dev: `palbase serve` uses the deployed database
777
+
778
+ `palbase serve` runs your controllers locally but proxies `Database` and
779
+ `ctx.*` to the **deployed** branch — it does **not** spin up a local Postgres or
780
+ apply migrations locally. So when your local `db/schema.ts` or `db/migrations/`
781
+ is ahead of what's deployed, serve prints a note: new tables/columns won't exist
782
+ until you push. Deploy to apply them.
783
+
784
+ ## Workflow
785
+
786
+ 1. Edit `db/schema.ts`.
787
+ 2. **Additive** change? → `git push`. It auto-migrates on deploy.
788
+ 3. **Type change / rename / drop?** → add `db/migrations/NNN_*.up.sql` (+
789
+ `.down.sql`), then `git push`. The runner applies it; without it the
790
+ drift-gate blocks the deploy.
791
+ 4. `palbase serve` warns locally until the change is deployed.
792
+
793
+ See [schema.md](./schema.md) for the column builders and typed
794
+ `Database.tables.*` access.
426
795
 
427
796
 
428
797
 
@@ -430,9 +799,9 @@ untyped transaction with typed tables.
430
799
 
431
800
  # Services
432
801
 
433
- In **endpoints**, import service singletons from `@palbase/backend`. In
434
- **workers/jobs/hooks/webhooks**, the equivalents live on `ctx` (`ctx.cache`,
435
- `ctx.queue`, `ctx.log`, `ctx.db`).
802
+ Import service singletons from `@palbase/backend` in every handler type —
803
+ endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
804
+ **middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
436
805
 
437
806
  Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
438
807
  `Log`, `Notifications`, `Flags`.
@@ -520,18 +889,120 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
520
889
  ## Flags
521
890
 
522
891
  ```ts
523
- import { defineEndpoint, z, Flags } from "@palbase/backend";
524
-
525
- export default defineEndpoint({
526
- method: "GET",
527
- auth: { required: true }, // req.user is non-null here
528
- output: z.object({ enabled: z.boolean() }),
529
- handler: async (req) => {
530
- const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
531
- 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 });
532
904
  return { enabled: enabled ?? false };
533
- },
534
- });
905
+ }
906
+ }
907
+ ```
908
+
909
+
910
+
911
+ <!-- ===== resources.md ===== -->
912
+
913
+ # Resources
914
+
915
+ A `Resource` models one external connection — a pooled datastore, a stateless
916
+ API client, or a per-user factory. You put it in `resources/`, export an
917
+ instance, and **do not register it**: the framework discovers it, sets it up
918
+ once at boot, and drains it on shutdown. On top of that lifecycle you expose
919
+ your own clean facade.
920
+
921
+ ```ts
922
+ import { Resource } from "@palbase/backend";
923
+ ```
924
+
925
+ ## Lifecycle (boot scope — not per request)
926
+
927
+ A resource is created once at process boot — NOT per request. The framework:
928
+
929
+ 1. calls `init(env)` **once**, with only the secrets the resource declared;
930
+ 2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
931
+
932
+ The instance lives for the whole process; your facade methods are called
933
+ per-request. This makes "reconnect on every request" structurally impossible.
934
+
935
+ ## Pooled datastore — `init` + `shutdown`
936
+
937
+ ```ts
938
+ import { Resource } from "@palbase/backend";
939
+ import neo4j, { type Driver, type Session } from "neo4j-driver";
940
+
941
+ export class Neo4jResource extends Resource {
942
+ static secrets = ["NEO4J_URL", "NEO4J_USER", "NEO4J_PASSWORD"] as const;
943
+ private driver!: Driver;
944
+ async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
945
+ this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
946
+ }
947
+ async shutdown() {
948
+ await this.driver.close();
949
+ }
950
+ session(): Session {
951
+ return this.driver.session();
952
+ }
953
+ }
954
+
955
+ export const graph = new Neo4jResource();
956
+ ```
957
+
958
+ ## Stateless API client — `init` only
959
+
960
+ ```ts
961
+ import { Resource } from "@palbase/backend";
962
+ import { Client } from "@googlemaps/google-maps-services-js";
963
+
964
+ export class GoogleResource extends Resource {
965
+ static secrets = ["GOOGLE_MAPS_KEY"] as const;
966
+ private client = new Client();
967
+ private key = "";
968
+ init(env: { GOOGLE_MAPS_KEY: string }) {
969
+ this.key = env.GOOGLE_MAPS_KEY;
970
+ }
971
+ nearby(lat: number, lng: number) {
972
+ return this.client.placesNearby({ params: { location: { lat, lng }, radius: 1500, key: this.key } });
973
+ }
974
+ }
975
+
976
+ export const google = new GoogleResource();
977
+ ```
978
+
979
+ A per-user (OAuth) resource adds a factory method on the base, e.g.
980
+ `github.forUser(token)` — the same single model covers pooled, stateless, and
981
+ per-user.
982
+
983
+ ## Secrets
984
+
985
+ `static secrets` is the contract:
986
+
987
+ - It **types** the `env` passed to `init` — only the declared names are
988
+ present, each a `string`. An undeclared key is a compile error.
989
+ - A declared secret that is **missing at boot fails the deploy**, naming the
990
+ secret. Secrets are branch-scoped; set them with `palbase secret set NAME ...`
991
+ or in Studio. A resource is initialised once at boot, so rotating a secret
992
+ needs a redeploy/restart.
993
+
994
+ `secrets` is optional — a resource that needs none simply omits it and gets an
995
+ empty `env`.
996
+
997
+ ## Using a resource
998
+
999
+ Import the singleton and call your facade — services and handlers reach
1000
+ resources the same way they reach `Database`:
1001
+
1002
+ ```ts
1003
+ import { google } from "../resources/google.js";
1004
+
1005
+ const results = (await google.nearby(41.0, 29.0)).data.results;
535
1006
  ```
536
1007
 
537
1008
 
@@ -540,52 +1011,58 @@ export default defineEndpoint({
540
1011
 
541
1012
  # Errors
542
1013
 
543
- Two ways to fail a request. Both serialize to the standard Palbase error
544
- envelope:
1014
+ Throw an error class to fail a request. Every one serializes to the standard
1015
+ Palbase error envelope:
545
1016
 
546
1017
  ```json
547
1018
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
548
1019
  ```
549
1020
 
550
- ## 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
551
1026
 
552
1027
  ```ts
553
- import { HttpError } from "@palbase/backend";
554
- throw new HttpError(404, "todo_not_found", "No such todo");
555
- // optional structured payload (4th arg) rides along under `data`:
556
- 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
557
1036
  ```
558
1037
 
559
- `new HttpError(status, code, description, data?)`.
560
-
561
- ## 2. Declared errors (typed)
1038
+ Each class fixes its HTTP status. The constructor is
1039
+ `new <Class>(message?, code?, data?)`:
562
1040
 
563
- Declare them on the endpoint; throw via `req.errors.<name>(...)`. Declared
564
- errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
565
- for iOS callers.
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.
566
1046
 
567
1047
  ```ts
568
- import { defineEndpoint, z, Database } from "@palbase/backend";
1048
+ throw new NotFound("Room does not exist", "room_not_found");
1049
+ throw new Conflict("locked", "title_locked", { retryAfter: 30 });
1050
+ ```
569
1051
 
570
- export default defineEndpoint({
571
- method: "POST",
572
- input: z.object({ id: z.string() }),
573
- output: z.object({ ok: z.boolean() }),
574
- errors: {
575
- notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
576
- locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
577
- },
578
- handler: async (req) => {
579
- const todo = await Database.findById("todos", req.input.id);
580
- if (!todo) throw req.errors.notFound(); // no data → no args
581
- if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
582
- return { ok: true };
583
- },
584
- });
1052
+ ## `PalError` / `HttpError` (custom status)
1053
+
1054
+ For a status/code not covered by a named class:
1055
+
1056
+ ```ts
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 });
585
1061
  ```
586
1062
 
587
- A declared error with a `data` Zod schema requires that payload as an argument;
588
- 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.
589
1066
 
590
1067
 
591
1068
 
@@ -593,10 +1070,10 @@ one without `data` takes no arguments. This is enforced by the types.
593
1070
 
594
1071
  # Workers & Jobs
595
1072
 
596
- Workers and jobs use the **`ctx` model**: their handler receives a context
597
- object with `ctx.db`, `ctx.log`, `ctx.cache`, `ctx.queue`, `ctx.env`. They do
598
- **not** receive a `req`, and you do **not** import the `Database` singleton
599
- inside them use `ctx.db`.
1073
+ Workers and jobs use the **singleton model** the same imported service
1074
+ singletons as endpoints (`import { Database, Log } from "@palbase/backend"`).
1075
+ They do **not** receive a `req`. Instead, a small `meta` argument carries the
1076
+ non-service data (`env`, `user`, correlation ids).
600
1077
 
601
1078
  ## Workers (queue consumers)
602
1079
 
@@ -605,7 +1082,7 @@ A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
605
1082
 
606
1083
  ```ts
607
1084
  // workers/process-order.ts
608
- import { defineWorker } from "@palbase/backend";
1085
+ import { defineWorker, Database, Log } from "@palbase/backend";
609
1086
 
610
1087
  interface OrderPayload { orderId: string; amount: number; }
611
1088
 
@@ -614,13 +1091,16 @@ export default defineWorker<OrderPayload>({
614
1091
  retry: 5, // optional, default 3
615
1092
  timeout: 60, // optional, seconds
616
1093
  backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
617
- handler: async (ctx, payload) => {
618
- ctx.log.info(`processing ${payload.orderId} for $${payload.amount}`);
619
- await ctx.db.update("orders", payload.orderId, { status: "processed" });
1094
+ handler: async (payload, meta) => {
1095
+ Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
1096
+ await Database.update("orders", payload.orderId, { status: "processed" });
620
1097
  },
621
1098
  });
622
1099
  ```
623
1100
 
1101
+ `meta` shape: `{ env, user, requestId, projectId, environmentId }`. Branch env
1102
+ vars are in `meta.env`; services come from the imported singletons.
1103
+
624
1104
  Enqueue from an endpoint:
625
1105
 
626
1106
  ```ts
@@ -634,29 +1114,34 @@ A job runs on a cron schedule. File lives under `jobs/`.
634
1114
 
635
1115
  ```ts
636
1116
  // jobs/cleanup.ts
637
- import { defineJob } from "@palbase/backend";
1117
+ import { defineJob, Database, Log } from "@palbase/backend";
638
1118
 
639
1119
  export default defineJob({
640
1120
  name: "cleanup-expired",
641
1121
  schedule: "0 3 * * *", // standard cron
642
1122
  timeout: 120, // optional, seconds
643
- handler: async (ctx) => {
644
- const expired = await ctx.db.findMany("sessions", { expired: true });
645
- for (const s of expired) await ctx.db.delete("sessions", s.id as string);
646
- ctx.log.info(`cleaned ${expired.length} sessions`);
1123
+ handler: async (meta) => {
1124
+ const expired = await Database.findMany("sessions", { expired: true });
1125
+ for (const s of expired) await Database.delete("sessions", s.id as string);
1126
+ Log.info(`cleaned ${expired.length} sessions in ${meta.projectId}`);
647
1127
  },
648
1128
  });
649
1129
  ```
650
1130
 
1131
+ `meta` shape: `{ env, projectId, environmentId }`. No `user` (jobs are
1132
+ system-initiated).
1133
+
651
1134
 
652
1135
 
653
1136
  <!-- ===== events.md ===== -->
654
1137
 
655
1138
  # Hooks & Webhooks
656
1139
 
657
- Like workers/jobs, hooks and webhooks use the **`ctx` model** (`ctx.db`,
658
- `ctx.log`, `ctx.cache`, `ctx.queue`, `ctx.env`) not `req`, and not imported
659
- singletons.
1140
+ Like workers/jobs, hooks and webhooks use the **singleton model** — the same
1141
+ imported service singletons as endpoints (`import { Database, Log } from
1142
+ "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
1143
+ carries the non-service data (`env`, `projectId`, `environmentId`; webhooks also
1144
+ get `requestId`).
660
1145
 
661
1146
  ## Hooks (platform events)
662
1147
 
@@ -665,21 +1150,24 @@ are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
665
1150
 
666
1151
  ```ts
667
1152
  // hooks/auth.ts
668
- import { auth } from "@palbase/backend";
1153
+ import { auth, Database, Log } from "@palbase/backend";
669
1154
 
670
- export const onUserCreated = auth.onUserCreated(async (ctx, event) => {
671
- ctx.log.info(`new user: ${event.user.email}`);
672
- await ctx.db.insert("profiles", {
1155
+ export const onUserCreated = auth.onUserCreated(async (event, meta) => {
1156
+ Log.info(`new user: ${event.user.email}`);
1157
+ await Database.insert("profiles", {
673
1158
  user_id: event.user.id,
674
1159
  email: event.user.email,
675
1160
  });
676
1161
  });
677
1162
 
678
- export const onSignIn = auth.onSignIn(async (ctx, event) => {
679
- ctx.log.info(`sign in: ${event.user.email} via ${event.provider}`);
1163
+ export const onSignIn = auth.onSignIn(async (event, meta) => {
1164
+ Log.info(`sign in: ${event.user.email} via ${event.provider}`);
680
1165
  });
681
1166
  ```
682
1167
 
1168
+ `meta` shape: `{ env, projectId, environmentId }`. Branch env vars are in
1169
+ `meta.env`; services come from the imported singletons.
1170
+
683
1171
  Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
684
1172
  `auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
685
1173
  `documents.onDocumentCreated`, `documents.onDocumentUpdated`,
@@ -692,22 +1180,25 @@ Receive and verify webhooks from third-party providers. Files live under
692
1180
 
693
1181
  ```ts
694
1182
  // webhooks/stripe.ts
695
- import { defineWebhook } from "@palbase/backend";
1183
+ import { defineWebhook, Database, Log } from "@palbase/backend";
696
1184
 
697
1185
  export default defineWebhook({
698
1186
  provider: "stripe",
699
1187
  secret: { env: "STRIPE_WEBHOOK_SECRET" }, // signing secret resolved from env
700
1188
  events: {
701
- "checkout.session.completed": async (ctx, event) => {
702
- await ctx.db.insert("orders", { status: "paid", data: event });
1189
+ "checkout.session.completed": async (event, meta) => {
1190
+ await Database.insert("orders", { status: "paid", data: event });
703
1191
  },
704
- "payment_intent.payment_failed": async (ctx, event) => {
705
- ctx.log.error("payment failed");
706
- await ctx.db.insert("payment_failures", { data: event });
1192
+ "payment_intent.payment_failed": async (event, meta) => {
1193
+ Log.error("payment failed");
1194
+ await Database.insert("payment_failures", { data: event });
707
1195
  },
708
1196
  },
709
1197
  });
710
1198
  ```
711
1199
 
712
- The signing secret is read from the project's env/secrets (`{ env: "NAME" }`);
713
- the runtime verifies the signature before dispatching to your event handlers.
1200
+ The signing secret is resolved by the runtime from `secret: { env: "NAME" }`;
1201
+ your handlers access branch env vars via `meta.env`. The runtime verifies the
1202
+ signature before dispatching to your event handlers.
1203
+
1204
+ `meta` shape: `{ env, requestId, projectId, environmentId }`.