@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.
package/docs/README.md CHANGED
@@ -4,10 +4,202 @@ Write your backend as TypeScript files. Palbase discovers them by path, runs
4
4
  them inside the managed backend runtime, and exposes them as a typed HTTP API.
5
5
 
6
6
  This is **not** Express, Fastify, or a Supabase Edge Function. There is no
7
- `app.get(...)`, no manual route registration, no `import express`. You export
8
- definitions; the runtime wires them up.
7
+ `app.get(...)`, no manual route registration, no `import express`. You author
8
+ **class controllers** with method + parameter decorators (NestJS-style); the
9
+ runtime wires them up.
9
10
 
10
- ## Mental model (important)
11
+ ## AGENTS / AI codegen guide (read this first)
12
+
13
+ If you are an AI generating a Palbase backend, this is the contract. Get these
14
+ seven rules right and the deploy succeeds; get them wrong and it fails the
15
+ deploy gate or the TypeScript compile.
16
+
17
+ ### Folder layout
18
+
19
+ ```
20
+ controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
21
+ models/<controller>/<endpoint>.ts # zod schemas — one folder per controller, one file per endpoint (+ shared.ts)
22
+ services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
23
+ db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
24
+ ```
25
+
26
+ The four folders above are the daily surface. These also exist (own docs, linked
27
+ below): `resources/` (external connections — [resources.md](./resources.md)),
28
+ `seeds/` (seed data), `jobs/` + `workers/` (background — [background.md](./background.md)),
29
+ `webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
30
+
31
+ ### The 7 rules (checklist)
32
+
33
+ 1. **`export default <ControllerClass>` is REQUIRED.** Every `controllers/*.controller.ts`
34
+ must default-export its `@Controller` class, or the deploy FAILS with a
35
+ "not a @Controller / non-controller export" error. Either `export default class X {}`
36
+ or a trailing `export default X;` — both work.
37
+ 2. **Methods that call a service are `async` and return `Promise<T>`.** Services
38
+ `await Database`, so they return promises; a sync return type on an async body
39
+ is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
40
+ the body awaits.
41
+ 3. **`@User()` (value) vs `UserT` (type) are two different imports.**
42
+ `import { User } from "@palbase/backend"` (the decorator) +
43
+ `import type { UserT } from "@palbase/backend"` (the user type). Write
44
+ `@User() user: UserT`. They are NOT the same symbol.
45
+ 4. **The method's RETURN TYPE names the response schema.** Write
46
+ `: Promise<TodoSchema>` (or `: Promise<z.infer<typeof TodoSchema>>`) — codegen
47
+ + the runtime read that named type to bind the 200 response zod schema. There
48
+ is no `@Returns` decorator. A body route with no named return type is a build
49
+ error; a method with no response body annotates `: Promise<void>` / `: void`.
50
+ 5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
51
+ VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
52
+ `@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
53
+ as the type annotation).
54
+ 6. **Errors are global throw classes.** `throw new Conflict("…")` / `NotFound` /
55
+ `BadRequest` / `Unauthorized` / `Forbidden` / `TooManyRequests` /
56
+ `PalError(status, code, msg)` — from a controller OR a service, with no `req`.
57
+ There is no per-route errors map and no `req.errors`.
58
+ 7. **Auth is secure-by-default.** Every route needs a signed-in user unless it
59
+ opts out: `@Controller("/x", { auth: false })` (whole controller) or
60
+ `@Get("", { auth: false })` (one route). Cascade: route > controller >
61
+ default(required). On a public route inject `@OptionalUser() user: UserT | null`.
62
+
63
+ > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
64
+ > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
65
+ > and will not compile against `@palbase/backend` 4.
66
+
67
+ ### Complete CRUD example (copy-pasteable, compiles)
68
+
69
+ ```ts
70
+ // models/todos/shared.ts — the response shape, reused across endpoints.
71
+ import { z } from "@palbase/backend";
72
+ export const TodoSchema = z.object({
73
+ id: z.string(),
74
+ title: z.string(),
75
+ completed: z.boolean(),
76
+ });
77
+ export type TodoSchema = z.infer<typeof TodoSchema>;
78
+ ```
79
+
80
+ ```ts
81
+ // models/todos/create.ts — the POST body.
82
+ import { z } from "@palbase/backend";
83
+ export const CreateTodoBody = z.object({ title: z.string().min(1) });
84
+ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
85
+ ```
86
+
87
+ ```ts
88
+ // services/todo.service.ts — plain class + singleton. The real work.
89
+ import { Database, NotFound } from "@palbase/backend";
90
+ import type { TodoSchema } from "../models/todos/shared.js";
91
+
92
+ export class TodoService {
93
+ list(userId: string): Promise<TodoSchema[]> {
94
+ return Database.tables.todos.findMany({ user_id: userId });
95
+ }
96
+ create(userId: string, title: string): Promise<TodoSchema> {
97
+ return Database.tables.todos.insert({ user_id: userId, title });
98
+ }
99
+ async get(userId: string, id: string): Promise<TodoSchema> {
100
+ const t = await Database.tables.todos.findById(id);
101
+ if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
102
+ return t;
103
+ }
104
+ async remove(userId: string, id: string): Promise<void> {
105
+ const t = await Database.tables.todos.findById(id);
106
+ if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
107
+ await Database.tables.todos.delete(id);
108
+ }
109
+ }
110
+ export const todoService = new TodoService();
111
+ ```
112
+
113
+ ```ts
114
+ // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
115
+ import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
116
+ import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
117
+ import { todoService } from "../services/todo.service.js";
118
+ import { TodoSchema } from "../models/todos/shared.js";
119
+ import { CreateTodoBody } from "../models/todos/create.js";
120
+
121
+ @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
122
+ export class TodosController {
123
+ private todos = todoService;
124
+
125
+ @Get("") // GET /todos → operationId getTodos
126
+ async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
127
+ return this.todos.list(user.id);
128
+ }
129
+
130
+ @Post("") // POST /todos → postTodos
131
+ async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
132
+ return this.todos.create(user.id, body.title);
133
+ }
134
+
135
+ @Get("/{id}") // GET /todos/{id} → getTodosById
136
+ async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
137
+ return this.todos.get(user.id, id);
138
+ }
139
+
140
+ @Delete("/{id}") // DELETE /todos/{id} → deleteTodosById; no body → : Promise<void>
141
+ async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
142
+ await this.todos.remove(user.id, id);
143
+ }
144
+ }
145
+
146
+ export default TodosController; // REQUIRED — the runtime loads the default export
147
+ ```
148
+
149
+ ```ts
150
+ // db/schema.ts — config-as-code; the deploy auto-migrates additive changes.
151
+ import { defineSchema, uuid, text, boolean, timestamp, policy } from "@palbase/backend";
152
+ export default defineSchema({
153
+ tables: {
154
+ todos: {
155
+ columns: {
156
+ id: uuid().primaryKey().defaultRandom(),
157
+ user_id: text().notNull(), // palauth user id (TEXT, e.g. "usr_…")
158
+ title: text().notNull(),
159
+ completed: boolean().default(false),
160
+ created_at: timestamp().defaultNow(),
161
+ },
162
+ rls: true, // every Database.* query runs as the request user
163
+ policies: [
164
+ policy("pb_owner_all")
165
+ .for("all")
166
+ .to("authenticated")
167
+ .using("user_id = (select auth.uid())")
168
+ .withCheck("user_id = (select auth.uid())"),
169
+ ],
170
+ },
171
+ },
172
+ });
173
+ ```
174
+
175
+ ### operationId — what the generated clients call
176
+
177
+ The operationId is derived FLAT from the verb + full path (NOT the method name):
178
+ `GET /todos` → `getTodos`, `POST /todos` → `postTodos`, `GET /todos/{id}` →
179
+ `getTodosById`. The generated iOS/TS clients expose these as `pb.getTodos()`,
180
+ `pb.postTodos(...)`, `pb.getTodosById(id:)`. Rename the method freely; the
181
+ operationId is unaffected. Change the verb and the operationId changes — no file
182
+ rename needed.
183
+
184
+ ### CLI workflow
185
+
186
+ - `palbase serve` — run `controllers/` locally with hot reload (proxies
187
+ `Database`/services to the deployed branch; runs `gen-types` on startup).
188
+ - `palbase gen-types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
189
+ `Database.tables.*` is typed (no import, no generic). Standalone version of what
190
+ `serve` runs.
191
+ - **Deploy is GitHub-native** — `git push` triggers the deploy (there is no
192
+ `palbase push`). Push a branch to deploy that branch.
193
+ - `palbase secret set NAME=value` / `palbase secret list` — branch-scoped
194
+ secrets (encrypted at rest), read in code via `process.env.NAME`.
195
+ - `palbase secret pull` / `palbase secret push` — sync the branch's env vars with
196
+ a local `.env.local` (gitignored). `pull` writes decrypted values for local dev
197
+ (merging local-only keys); `push` uploads changed keys (new keys default to
198
+ SECRET and need `--secret`/`--plain` classification, existing secrets are left
199
+ untouched unless `--force-secrets`).
200
+ - `tsconfig.json` needs `"experimentalDecorators": true` (the scaffold sets it).
201
+
202
+ ## Mental model
11
203
 
12
204
  Every handler type imports service singletons the same way:
13
205
 
@@ -35,6 +227,7 @@ the imported singletons — not from `ctx` or any argument.
35
227
  ```
36
228
  my-backend/
37
229
  ├── package.json # depends on @palbase/backend
230
+ ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
38
231
  ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
39
232
  │ └── hello.controller.ts # @Controller("/hello") + @Get example
40
233
  ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
@@ -42,6 +235,8 @@ my-backend/
42
235
  ├── services/ # plain classes/singletons your controllers call
43
236
  ├── db/schema.ts # table definitions (optional, enables typed DB)
44
237
  ├── db/migrations/ # explicit SQL migrations for type changes (optional)
238
+ ├── resources/ # external connections, set up once at boot (optional)
239
+ ├── seeds/ # seed data (optional)
45
240
  ├── workers/ # background job handlers (optional)
46
241
  ├── jobs/ # cron-scheduled jobs (optional)
47
242
  ├── hooks/ # auth/storage/document event hooks (optional)
@@ -64,9 +259,12 @@ HTTP endpoints are **not** file-path routed. You author a class controller
64
259
  | Schema & typed DB | [schema.md](./schema.md) |
65
260
  | Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
66
261
  | Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
262
+ | Resources (external connections) | [resources.md](./resources.md) |
67
263
  | Errors | [errors.md](./errors.md) |
68
264
  | Workers & Jobs | [background.md](./background.md) |
69
265
  | Hooks & Webhooks | [events.md](./events.md) |
70
266
 
71
267
  For AI coding tools: a single concatenated corpus is generated at
72
268
  [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
269
+ </content>
270
+ </invoke>
package/docs/endpoints.md CHANGED
@@ -12,22 +12,20 @@ imported singletons (see [services.md](./services.md)).
12
12
 
13
13
  ```ts
14
14
  // controllers/rooms.controller.ts
15
- import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
15
+ import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
16
16
  import type { UserT } from "@palbase/backend";
17
17
  import { CreateRoomBody } from "../models/rooms/create.js";
18
- import { RoomSchema } from "../models/rooms/shared.js";
18
+ import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
19
19
 
20
20
  @Controller("/rooms")
21
21
  export default class RoomsController {
22
22
  @Post("")
23
- @Returns(RoomSchema)
24
23
  async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
25
24
  const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
26
25
  return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
27
26
  }
28
27
 
29
28
  @Get("/{id}")
30
- @Returns(RoomSchema)
31
29
  async getOne(@Param("id") id: string): Promise<RoomSchema> {
32
30
  const room = await Database.findById("rooms", id);
33
31
  if (!room) throw new NotFound("Room does not exist", "room_not_found");
@@ -36,6 +34,19 @@ export default class RoomsController {
36
34
  }
37
35
  ```
38
36
 
37
+ **Two non-negotiables** (the most common codegen mistakes):
38
+
39
+ 1. **`export default <Controller>` is REQUIRED.** Above it is
40
+ `export default class RoomsController`; the trailing form `export class
41
+ RoomsController {…}` + `export default RoomsController;` is equivalent. Without
42
+ a default export the deploy aborts with a "not a @Controller / non-controller
43
+ export" error — the runtime loads the file's default export.
44
+ 2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
45
+ returns promises, so a body that `await`s it cannot have a sync return type
46
+ (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
47
+ `async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
48
+ with no `await` may stay synchronous.)
49
+
39
50
  ## Method decorators
40
51
 
41
52
  `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
@@ -73,10 +84,11 @@ name `User` is the `@User()` decorator.
73
84
 
74
85
  ## Output = return type
75
86
 
76
- The success response is the method's RETURN TYPE. Pair it with `@Returns(schema)`
77
- so the zod value drives the OpenAPI 200 response (the type annotation drives
78
- compile-time checking; the zod value is lossless for codegen). A method that
79
- returns nothing annotates `: void`.
87
+ The success response is the method's RETURN TYPE there is no `@Returns`
88
+ decorator. Annotate `: Promise<RoomSchema>` (or `: Promise<z.infer<typeof
89
+ RoomSchema>>`); codegen + the runtime read that named type to bind the OpenAPI
90
+ 200 response zod schema. A body route with no named return type is a build
91
+ error. A method that returns nothing annotates `: void` (or `: Promise<void>`).
80
92
 
81
93
  ## Auth — controller default + route override cascade
82
94
 
@@ -3,7 +3,7 @@
3
3
  There is no CLI init command. A starter project is created for you when your
4
4
  Palbase project is provisioned. You then edit the files locally and deploy them.
5
5
 
6
- ## package.json
6
+ ## package.json + tsconfig.json
7
7
 
8
8
  Your project depends on the SDK and uses the Palbase CLI for the dev loop:
9
9
 
@@ -20,13 +20,33 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
20
20
  }
21
21
  ```
22
22
 
23
+ The controllers use **decorators**, so the `tsconfig.json` must set
24
+ `experimentalDecorators: true` (the scaffold ships it):
25
+
26
+ ```json
27
+ {
28
+ "compilerOptions": {
29
+ "target": "ES2022",
30
+ "module": "ESNext",
31
+ "moduleResolution": "Bundler",
32
+ "strict": true,
33
+ "experimentalDecorators": true,
34
+ "noEmit": true
35
+ },
36
+ "include": ["controllers/**/*.ts", "models/**/*.ts", "services/**/*.ts", "db/**/*.ts", "*.d.ts"]
37
+ }
38
+ ```
39
+
23
40
  ## Local dev loop
24
41
 
25
42
  - `palbase serve` — run your backend locally with hot reload. It runs your
26
- `controllers/` locally but proxies `Database`/`ctx.*` to the **deployed**
27
- branch, so the branch must already be deployed (serve tells you to push first
28
- if it isn't). See [migrations.md](./migrations.md) for the schema/migration
29
- side of this.
43
+ `controllers/` locally but proxies `Database` and the service singletons to
44
+ the **deployed** branch, so the branch must already be deployed (serve tells
45
+ you to push first if it isn't). On startup it also runs `gen-types`. See
46
+ [migrations.md](./migrations.md) for the schema/migration side of this.
47
+ - `palbase gen-types` — regenerate `palbase-env.d.ts` from `db/schema.ts` so
48
+ `Database.tables.*` is typed (no import, no generic). Run it standalone after
49
+ editing the schema, or rely on `palbase serve` running it for you.
30
50
  - **Deploy is GitHub-native** — there is no `palbase push`. Commit and
31
51
  `git push` to your project's repo; the push triggers a deploy of the backend
32
52
  runtime. Push a **branch** to deploy that branch instead of `main`.
@@ -49,14 +69,14 @@ export type HelloResponse = z.infer<typeof HelloResponse>;
49
69
  Then write the controller in `controllers/hello.controller.ts`:
50
70
 
51
71
  ```ts
52
- import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
72
+ import { Controller, Get, Query, OptionalUser } from "@palbase/backend";
53
73
  import type { UserT } from "@palbase/backend";
54
- import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
74
+ import { GreetQuery } from "../models/hello/greet.js";
75
+ import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
55
76
 
56
77
  @Controller("/hello", { auth: false })
57
78
  export default class HelloController {
58
79
  @Get("")
59
- @Returns(HelloResponse)
60
80
  greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
61
81
  return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
62
82
  }
@@ -64,6 +84,20 @@ export default class HelloController {
64
84
  ```
65
85
 
66
86
  This is served at `GET /hello`. The `@Query` schema validates the query string;
67
- the method's return type (paired with `@Returns(HelloResponse)`) validates and
68
- describes the response. See [routing.md](./routing.md) and
69
- [endpoints.md](./endpoints.md) for the full class-controller model.
87
+ the method's RETURN TYPE (`: HelloResponse`) names the response schema — codegen
88
+ + the runtime read it to validate and describe the 200 response. There is no
89
+ `@Returns` decorator; a body route with no named return type is a build error.
90
+
91
+ Two things every controller file MUST have:
92
+
93
+ - **A default export of the `@Controller` class.** Above it is `export default class
94
+ HelloController` (inline); the equivalent trailing form is `export class
95
+ HelloController {…}` then `export default HelloController;`. Without a default
96
+ export the deploy fails ("not a @Controller").
97
+ - **`async` + `Promise<T>` once the method awaits a service.** `greet` above is
98
+ synchronous (pure), so it returns `HelloResponse` directly. The moment a method
99
+ calls a service that awaits `Database`, make it `async` and return
100
+ `Promise<HelloResponse>`.
101
+
102
+ See [routing.md](./routing.md) and [endpoints.md](./endpoints.md) for the full
103
+ class-controller model.