@palbase/backend 4.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.
@@ -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
 
@@ -43,6 +235,7 @@ the imported singletons — not from `ctx` or any argument.
43
235
  ```
44
236
  my-backend/
45
237
  ├── package.json # depends on @palbase/backend
238
+ ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
46
239
  ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
47
240
  │ └── hello.controller.ts # @Controller("/hello") + @Get example
48
241
  ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
@@ -50,6 +243,8 @@ my-backend/
50
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)
@@ -72,12 +267,15 @@ HTTP endpoints are **not** file-path routed. You author a class controller
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,13 +303,33 @@ 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`.
@@ -134,14 +352,14 @@ export type HelloResponse = z.infer<typeof HelloResponse>;
134
352
  Then write the controller in `controllers/hello.controller.ts`:
135
353
 
136
354
  ```ts
137
- import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
355
+ import { Controller, Get, Query, OptionalUser } from "@palbase/backend";
138
356
  import type { UserT } from "@palbase/backend";
139
- import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
357
+ import { GreetQuery } from "../models/hello/greet.js";
358
+ import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
140
359
 
141
360
  @Controller("/hello", { auth: false })
142
361
  export default class HelloController {
143
362
  @Get("")
144
- @Returns(HelloResponse)
145
363
  greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
146
364
  return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
147
365
  }
@@ -149,9 +367,23 @@ export default class HelloController {
149
367
  ```
150
368
 
151
369
  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.
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.
373
+
374
+ Two things every controller file MUST have:
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>`.
384
+
385
+ See [routing.md](./routing.md) and [endpoints.md](./endpoints.md) for the full
386
+ class-controller model.
155
387
 
156
388
 
157
389
 
@@ -178,22 +410,20 @@ method declares its verb + subpath; the real work lives in a `services/` class
178
410
 
179
411
  ```ts
180
412
  // controllers/places.controller.ts
181
- import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
413
+ import { Controller, Get, Post, Body, User } from "@palbase/backend";
182
414
  import type { UserT } from "@palbase/backend";
183
415
  import { placeService } from "../services/place.service.js";
184
416
  import { ImportNearbyBody } from "../models/places/import.js";
185
- import { PlaceSchema } from "../models/places/shared.js";
417
+ import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
186
418
 
187
419
  @Controller("/places")
188
420
  export default class PlacesController {
189
421
  @Post("/import")
190
- @Returns(PlaceSchema)
191
422
  importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
192
423
  return placeService.importNearby(body.lat, body.lng, user.id);
193
424
  }
194
425
 
195
426
  @Get("/favorites", { auth: false })
196
- @Returns(z.array(PlaceSchema))
197
427
  listFavorites(): PlaceSchema[] {
198
428
  return placeService.listFavorites();
199
429
  }
@@ -211,8 +441,10 @@ Rules:
211
441
  - A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
212
442
  - Input is declared with the parameter decorators — `@Body(schema)`,
213
443
  `@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).
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.
216
448
  - The operationId is derived FLAT from method + full path (`postPlacesImport`),
217
449
  not from the method name. Change `@Post` → `@Put` — no file rename.
218
450
 
@@ -237,22 +469,20 @@ imported singletons (see [services.md](./services.md)).
237
469
 
238
470
  ```ts
239
471
  // controllers/rooms.controller.ts
240
- import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
472
+ import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
241
473
  import type { UserT } from "@palbase/backend";
242
474
  import { CreateRoomBody } from "../models/rooms/create.js";
243
- import { RoomSchema } from "../models/rooms/shared.js";
475
+ import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
244
476
 
245
477
  @Controller("/rooms")
246
478
  export default class RoomsController {
247
479
  @Post("")
248
- @Returns(RoomSchema)
249
480
  async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
250
481
  const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
251
482
  return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
252
483
  }
253
484
 
254
485
  @Get("/{id}")
255
- @Returns(RoomSchema)
256
486
  async getOne(@Param("id") id: string): Promise<RoomSchema> {
257
487
  const room = await Database.findById("rooms", id);
258
488
  if (!room) throw new NotFound("Room does not exist", "room_not_found");
@@ -261,6 +491,19 @@ export default class RoomsController {
261
491
  }
262
492
  ```
263
493
 
494
+ **Two non-negotiables** (the most common codegen mistakes):
495
+
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.)
506
+
264
507
  ## Method decorators
265
508
 
266
509
  `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
@@ -298,10 +541,11 @@ name `User` is the `@User()` decorator.
298
541
 
299
542
  ## Output = return type
300
543
 
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`.
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>`).
305
549
 
306
550
  ## Auth — controller default + route override cascade
307
551
 
@@ -560,7 +804,7 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
560
804
  of the schema, no generic, no cast:
561
805
 
562
806
  ```ts
563
- import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
807
+ import { Controller, Post, Body, Database, z } from "@palbase/backend";
564
808
 
565
809
  const CreateRoomBody = z.object({ name: z.string() });
566
810
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -568,7 +812,8 @@ const RoomOut = z.object({ id: z.string(), name: z.string() });
568
812
  @Controller("/rooms")
569
813
  export default class RoomsController {
570
814
  @Post("")
571
- @Returns(RoomOut)
815
+ // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
816
+ // inline, no separate `export type` needed.
572
817
  async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
573
818
  const room = await Database.tables.rooms.insert({ name: body.name });
574
819
  return { id: room.id, name: room.name }; // room.id: string ✓
@@ -696,101 +941,103 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
696
941
 
697
942
  # Migrations
698
943
 
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.
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.
703
949
 
704
- ## Two kinds of change
950
+ ## The workflow
705
951
 
706
- ### 1. Additive — auto-applied, no migration file
952
+ ```bash
953
+ # 1. Edit db/schema.ts (add a column, a table, a policy, …)
707
954
 
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.
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
711
958
 
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
- }
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
726
962
  ```
727
963
 
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).
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.
732
968
 
733
- ### 2. Destructive / type-changing — needs an explicit migration
969
+ ## Additive vs destructive
734
970
 
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/`:
971
+ The generated SQL labels what it does. An additive change (new table, new column)
972
+ is plain DDL:
739
973
 
974
+ ```sql
975
+ -- palbase db diff: add_priority
976
+ -- generated 20260605T142233
977
+
978
+ ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority text;
740
979
  ```
741
- db/migrations/
742
- 001_user_id_to_text.up.sql
743
- 001_user_id_to_text.down.sql
744
- ```
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:
745
984
 
746
985
  ```sql
747
- -- 001_user_id_to_text.up.sql
748
- 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;
749
988
  ```
750
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
+
751
994
  ```sql
752
- -- 001_user_id_to_text.down.sql
753
- 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;
754
997
  ```
755
998
 
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.
999
+ ## The drift gate
1000
+
1001
+ You can't push a schema change without its migration:
761
1002
 
762
- ## 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.
763
1011
 
764
- On deploy, Palbase compares `db/schema.ts` to the live database:
1012
+ ## `palbase serve` uses the deployed database
765
1013
 
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.
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.
771
1020
 
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.
1021
+ ## Hand-written migrations
775
1022
 
776
- ## 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.
777
1028
 
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.
1029
+ ## Adding a NOT NULL column to a table with rows
783
1030
 
784
- ## 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.
785
1035
 
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.
1036
+ ## Row-Level Security
792
1037
 
793
- 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
794
1041
  `Database.tables.*` access.
795
1042
 
796
1043
 
@@ -889,7 +1136,7 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
889
1136
  ## Flags
890
1137
 
891
1138
  ```ts
892
- import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
1139
+ import { Controller, Get, User, Flags, z } from "@palbase/backend";
893
1140
  import type { UserT } from "@palbase/backend";
894
1141
 
895
1142
  const FlagsOut = z.object({ enabled: z.boolean() });
@@ -897,8 +1144,7 @@ const FlagsOut = z.object({ enabled: z.boolean() });
897
1144
  @Controller("/checkout")
898
1145
  export default class CheckoutController {
899
1146
  @Get("/flags") // auth omitted → required → user is non-null
900
- @Returns(FlagsOut)
901
- async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
1147
+ async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> { // return type names the 200 schema
902
1148
  const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
903
1149
  const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
904
1150
  return { enabled: enabled ?? false };
package/docs/llms.txt CHANGED
@@ -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
  ## Docs
6
6