@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/dist/{chunk-EG7TTYHY.js → chunk-2N4YNN6F.js} +1 -1
- package/dist/{chunk-EG7TTYHY.js.map → chunk-2N4YNN6F.js.map} +1 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/{endpoint-2d_DpASt.d.cts → endpoint-BEHjfvFH.d.cts} +7 -1
- package/dist/{endpoint-2d_DpASt.d.ts → endpoint-BEHjfvFH.d.ts} +7 -1
- package/dist/{index-DZW9CjiY.d.ts → index-BTVdhfsb.d.ts} +9 -3
- package/dist/{index-DzRFS3Tl.d.cts → index-mr3Co63T.d.cts} +9 -3
- package/dist/index.cjs +7 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -17
- package/dist/index.d.ts +5 -17
- package/dist/index.js +8 -22
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/docs/README.md +201 -3
- package/docs/endpoints.md +20 -8
- package/docs/getting-started.md +45 -11
- package/docs/llms-full.txt +349 -103
- package/docs/llms.txt +1 -1
- package/docs/migrations.md +75 -73
- package/docs/routing.md +6 -6
- package/docs/schema.md +3 -2
- package/docs/services.md +2 -3
- package/package.json +1 -1
package/docs/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Palbase Backend SDK (`@palbase/backend`)
|
|
2
2
|
|
|
3
|
-
>
|
|
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
|
|
16
|
-
|
|
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
|
-
##
|
|
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
|
|
112
|
-
branch, so the branch must already be deployed (serve tells
|
|
113
|
-
if it isn't).
|
|
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,
|
|
355
|
+
import { Controller, Get, Query, OptionalUser } from "@palbase/backend";
|
|
138
356
|
import type { UserT } from "@palbase/backend";
|
|
139
|
-
import { GreetQuery
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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,
|
|
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
|
|
215
|
-
|
|
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,
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
##
|
|
950
|
+
## The workflow
|
|
705
951
|
|
|
706
|
-
|
|
952
|
+
```bash
|
|
953
|
+
# 1. Edit db/schema.ts (add a column, a table, a policy, …)
|
|
707
954
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
969
|
+
## Additive vs destructive
|
|
734
970
|
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
--
|
|
748
|
-
ALTER TABLE todos
|
|
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
|
-
--
|
|
753
|
-
ALTER TABLE todos ALTER COLUMN
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
+
## `palbase serve` uses the deployed database
|
|
765
1013
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
@
|
|
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
|
-
>
|
|
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
|
|