@palbase/backend 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-B7EUJP5W.js → chunk-EG7TTYHY.js} +113 -3
- package/dist/chunk-EG7TTYHY.js.map +1 -0
- package/dist/{chunk-PHAFZGHN.js → chunk-WUQO76NW.js} +26 -19
- package/dist/chunk-WUQO76NW.js.map +1 -0
- package/dist/db/index.cjs +117 -2
- 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 +11 -1
- package/dist/{endpoint-DJ98tQd6.d.cts → endpoint-2d_DpASt.d.cts} +92 -56
- package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-2d_DpASt.d.ts} +92 -56
- package/dist/{index-CXUs9iTQ.d.ts → index-DZW9CjiY.d.ts} +210 -41
- package/dist/{index-CZAwpQE1.d.cts → index-DzRFS3Tl.d.cts} +210 -41
- package/dist/index.cjs +371 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -215
- package/dist/index.d.ts +88 -215
- package/dist/index.js +217 -21
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +34 -19
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +10 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +11 -11
- package/docs/database.md +40 -0
- package/docs/endpoints.md +98 -92
- package/docs/errors.md +37 -30
- package/docs/getting-started.md +24 -20
- package/docs/llms-full.txt +401 -235
- package/docs/routing.md +39 -45
- package/docs/schema.md +134 -23
- package/docs/services.md +14 -10
- package/package.json +2 -2
- package/dist/chunk-B7EUJP5W.js.map +0 -1
- package/dist/chunk-PHAFZGHN.js.map +0 -1
package/docs/llms-full.txt
CHANGED
|
@@ -27,7 +27,7 @@ The **only difference** is the trigger argument:
|
|
|
27
27
|
|
|
28
28
|
| You are writing… | Handler signature | Trigger arg |
|
|
29
29
|
|------------------|-------------------|-------------|
|
|
30
|
-
| **Endpoints** (`controllers/`
|
|
30
|
+
| **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
|
|
31
31
|
| **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
|
|
32
32
|
| **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
|
|
33
33
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
@@ -43,11 +43,11 @@ the imported singletons — not from `ctx` or any argument.
|
|
|
43
43
|
```
|
|
44
44
|
my-backend/
|
|
45
45
|
├── package.json # depends on @palbase/backend
|
|
46
|
-
├── controllers/ #
|
|
47
|
-
│ └── hello.controller.ts #
|
|
48
|
-
├──
|
|
49
|
-
│ └── hello.ts
|
|
50
|
-
├── services/ # plain classes/singletons your
|
|
46
|
+
├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
|
|
47
|
+
│ └── hello.controller.ts # @Controller("/hello") + @Get example
|
|
48
|
+
├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
|
|
49
|
+
│ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
|
|
50
|
+
├── services/ # plain classes/singletons your controllers call
|
|
51
51
|
├── db/schema.ts # table definitions (optional, enables typed DB)
|
|
52
52
|
├── db/migrations/ # explicit SQL migrations for type changes (optional)
|
|
53
53
|
├── workers/ # background job handlers (optional)
|
|
@@ -57,17 +57,17 @@ my-backend/
|
|
|
57
57
|
└── middleware/ # cross-cutting request middleware (optional)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
HTTP endpoints are **not** file-path routed. You author a
|
|
61
|
-
|
|
62
|
-
`
|
|
60
|
+
HTTP endpoints are **not** file-path routed. You author a class controller
|
|
61
|
+
(`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
|
|
62
|
+
`controllers/` mounts it. See [routing.md](./routing.md).
|
|
63
63
|
|
|
64
64
|
## Documentation
|
|
65
65
|
|
|
66
66
|
| Topic | File |
|
|
67
67
|
|-------|------|
|
|
68
68
|
| Getting started | [getting-started.md](./getting-started.md) |
|
|
69
|
-
|
|
|
70
|
-
| Endpoints
|
|
69
|
+
| Routing (class controllers) | [routing.md](./routing.md) |
|
|
70
|
+
| Endpoints (decorators) | [endpoints.md](./endpoints.md) |
|
|
71
71
|
| Database & transactions | [database.md](./database.md) |
|
|
72
72
|
| Schema & typed DB | [schema.md](./schema.md) |
|
|
73
73
|
| Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
|
|
@@ -118,36 +118,40 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
|
118
118
|
|
|
119
119
|
## Your first endpoint
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
`handlers/hello.ts`:
|
|
121
|
+
An endpoint is a method on a class controller. Declare the schemas in `models/`:
|
|
123
122
|
|
|
124
123
|
```ts
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}),
|
|
134
|
-
});
|
|
124
|
+
// models/hello/greet.ts
|
|
125
|
+
import { z } from "@palbase/backend";
|
|
126
|
+
|
|
127
|
+
export const GreetQuery = z.object({ name: z.string().optional() });
|
|
128
|
+
export type GreetQuery = z.infer<typeof GreetQuery>;
|
|
129
|
+
|
|
130
|
+
export const HelloResponse = z.object({ message: z.string(), user: z.string().nullable() });
|
|
131
|
+
export type HelloResponse = z.infer<typeof HelloResponse>;
|
|
135
132
|
```
|
|
136
133
|
|
|
137
|
-
Then
|
|
134
|
+
Then write the controller in `controllers/hello.controller.ts`:
|
|
138
135
|
|
|
139
136
|
```ts
|
|
140
|
-
import {
|
|
141
|
-
import
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
137
|
+
import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
|
|
138
|
+
import type { UserT } from "@palbase/backend";
|
|
139
|
+
import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
|
|
140
|
+
|
|
141
|
+
@Controller("/hello", { auth: false })
|
|
142
|
+
export default class HelloController {
|
|
143
|
+
@Get("")
|
|
144
|
+
@Returns(HelloResponse)
|
|
145
|
+
greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
|
|
146
|
+
return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
146
149
|
```
|
|
147
150
|
|
|
148
|
-
This is served at `GET /hello`. The
|
|
149
|
-
|
|
150
|
-
[routing.md](./routing.md)
|
|
151
|
+
This is served at `GET /hello`. The `@Query` schema validates the query string;
|
|
152
|
+
the method's return type (paired with `@Returns(HelloResponse)`) validates and
|
|
153
|
+
describes the response. See [routing.md](./routing.md) and
|
|
154
|
+
[endpoints.md](./endpoints.md) for the full class-controller model.
|
|
151
155
|
|
|
152
156
|
|
|
153
157
|
|
|
@@ -155,71 +159,65 @@ flows into `req.input`; the `output` schema validates your return value. See
|
|
|
155
159
|
|
|
156
160
|
# Routing
|
|
157
161
|
|
|
158
|
-
Routes are declared in code
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
Routes are declared in code with **class controllers**. A controller is a class
|
|
163
|
+
decorated with `@Controller(basePath)`; each route is a method decorated with
|
|
164
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`. Request input + context are injected
|
|
165
|
+
into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/`@User`/
|
|
166
|
+
…). Putting a controller file under `controllers/` mounts it — there is no
|
|
161
167
|
central router and no manual registration.
|
|
162
168
|
|
|
163
169
|
```ts
|
|
164
|
-
import {
|
|
170
|
+
import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
|
|
165
171
|
```
|
|
166
172
|
|
|
167
|
-
##
|
|
173
|
+
## Controllers — class + method decorators
|
|
168
174
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
```ts
|
|
173
|
-
// handlers/places/import-nearby.ts
|
|
174
|
-
import { defineHandler, z } from "@palbase/backend";
|
|
175
|
-
import { placeService } from "../../services/place.service.js";
|
|
176
|
-
|
|
177
|
-
export default defineHandler({
|
|
178
|
-
auth: { required: true },
|
|
179
|
-
input: z.object({ lat: z.number(), lng: z.number() }),
|
|
180
|
-
output: z.object({ imported: z.number() }),
|
|
181
|
-
errors: { quotaExceeded: { status: 429, code: "quota_exceeded" } },
|
|
182
|
-
handler: (req) => placeService.importNearby(req.input.lat, req.input.lng),
|
|
183
|
-
});
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## Controllers — the route map
|
|
187
|
-
|
|
188
|
-
A controller maps method+path to handlers with `route.get|post|put|patch|delete`.
|
|
189
|
-
The route-map KEY is authoring sugar only (it is NOT the operationId).
|
|
175
|
+
`@Controller(basePath)` marks the class and sets the mount path. Each route
|
|
176
|
+
method declares its verb + subpath; the real work lives in a `services/` class
|
|
177
|
+
(the controller method is thin).
|
|
190
178
|
|
|
191
179
|
```ts
|
|
192
180
|
// controllers/places.controller.ts
|
|
193
|
-
import {
|
|
194
|
-
import
|
|
195
|
-
import
|
|
196
|
-
import
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
181
|
+
import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
|
|
182
|
+
import type { UserT } from "@palbase/backend";
|
|
183
|
+
import { placeService } from "../services/place.service.js";
|
|
184
|
+
import { ImportNearbyBody } from "../models/places/import.js";
|
|
185
|
+
import { PlaceSchema } from "../models/places/shared.js";
|
|
186
|
+
|
|
187
|
+
@Controller("/places")
|
|
188
|
+
export default class PlacesController {
|
|
189
|
+
@Post("/import")
|
|
190
|
+
@Returns(PlaceSchema)
|
|
191
|
+
importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
|
|
192
|
+
return placeService.importNearby(body.lat, body.lng, user.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Get("/favorites", { auth: false })
|
|
196
|
+
@Returns(z.array(PlaceSchema))
|
|
197
|
+
listFavorites(): PlaceSchema[] {
|
|
198
|
+
return placeService.listFavorites();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
203
201
|
```
|
|
204
202
|
|
|
205
|
-
|
|
|
203
|
+
| Method name (sugar) | Verb | Full path | operationId (flat) |
|
|
206
204
|
|---|---|---|---|
|
|
207
205
|
| `importNearby` | POST | `/places/import` | `postPlacesImport` |
|
|
208
|
-
| `addFavorite` | POST | `/places/favorites` | `postPlacesFavorites` |
|
|
209
206
|
| `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
|
|
210
207
|
|
|
211
208
|
Rules:
|
|
212
209
|
|
|
213
210
|
- The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
|
|
214
|
-
- A `{segment}` in a path becomes a
|
|
215
|
-
-
|
|
216
|
-
|
|
211
|
+
- A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
|
|
212
|
+
- Input is declared with the parameter decorators — `@Body(schema)`,
|
|
213
|
+
`@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
|
|
214
|
+
the method's RETURN TYPE; pair it with `@Returns(schema)` so the zod value
|
|
215
|
+
drives the OpenAPI 200 response (or annotate `: void` for no body).
|
|
217
216
|
- The operationId is derived FLAT from method + full path (`postPlacesImport`),
|
|
218
|
-
not from the
|
|
219
|
-
rename.
|
|
217
|
+
not from the method name. Change `@Post` → `@Put` — no file rename.
|
|
220
218
|
|
|
221
|
-
See [endpoints.md](./endpoints.md) for the full
|
|
222
|
-
|
|
219
|
+
See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
|
|
220
|
+
options, the parameter decorators, auth cascade, and error classes).
|
|
223
221
|
|
|
224
222
|
|
|
225
223
|
|
|
@@ -227,106 +225,127 @@ auth, input/output, errors, middleware) reference.
|
|
|
227
225
|
|
|
228
226
|
# Endpoints
|
|
229
227
|
|
|
230
|
-
An endpoint is a **
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
An endpoint is a **method on a class controller** — a class decorated with
|
|
229
|
+
`@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
|
|
230
|
+
`@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
|
|
231
|
+
automatically (see [routing.md](./routing.md)). Request input + context are
|
|
232
|
+
injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
|
|
233
|
+
`@User`/…), each piece direct — no `req` god-object. Services are reached via the
|
|
234
|
+
imported singletons (see [services.md](./services.md)).
|
|
235
235
|
|
|
236
|
-
##
|
|
236
|
+
## A controller
|
|
237
237
|
|
|
238
238
|
```ts
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
239
|
+
// controllers/rooms.controller.ts
|
|
240
|
+
import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
|
|
241
|
+
import type { UserT } from "@palbase/backend";
|
|
242
|
+
import { CreateRoomBody } from "../models/rooms/create.js";
|
|
243
|
+
import { RoomSchema } from "../models/rooms/shared.js";
|
|
244
|
+
|
|
245
|
+
@Controller("/rooms")
|
|
246
|
+
export default class RoomsController {
|
|
247
|
+
@Post("")
|
|
248
|
+
@Returns(RoomSchema)
|
|
249
|
+
async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
|
|
250
|
+
const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
|
|
251
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@Get("/{id}")
|
|
255
|
+
@Returns(RoomSchema)
|
|
256
|
+
async getOne(@Param("id") id: string): Promise<RoomSchema> {
|
|
257
|
+
const room = await Database.findById("rooms", id);
|
|
258
|
+
if (!room) throw new NotFound("Room does not exist", "room_not_found");
|
|
259
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
248
262
|
```
|
|
249
263
|
|
|
250
|
-
|
|
251
|
-
that mounts the handler via `route.get|post|put|patch|delete`.
|
|
264
|
+
## Method decorators
|
|
252
265
|
|
|
253
|
-
|
|
266
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
|
|
254
267
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
| `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
|
|
262
|
-
| `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
|
|
263
|
-
| `req.file` | `FileContext \| null` | uploaded file, if any |
|
|
264
|
-
| `req.method` | `string` | the HTTP method |
|
|
265
|
-
| `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
|
|
266
|
-
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
268
|
+
```ts
|
|
269
|
+
interface RouteOptions {
|
|
270
|
+
auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
|
|
271
|
+
rateLimit?: { max: number; window: number }; // optional, per route
|
|
272
|
+
}
|
|
273
|
+
```
|
|
267
274
|
|
|
268
|
-
|
|
275
|
+
Input and output are NOT method-decorator options: input comes from the
|
|
276
|
+
parameter decorators, output from the method's return type. The method decorator
|
|
277
|
+
only carries route concerns (path, auth, rateLimit).
|
|
269
278
|
|
|
270
|
-
##
|
|
279
|
+
## Parameter decorators
|
|
271
280
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
(
|
|
281
|
+
| Decorator | Injects | Typed by | OpenAPI |
|
|
282
|
+
|---|---|---|---|
|
|
283
|
+
| `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
|
|
284
|
+
| `@Query(schema)` | query params | `: T` | query parameters |
|
|
285
|
+
| `@Param("id")` | one path param | `: string` | path parameter |
|
|
286
|
+
| `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
|
|
287
|
+
| `@User()` | authenticated user | `: UserT` | — (from token) |
|
|
288
|
+
| `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
|
|
289
|
+
| `@Client()` | parsed client info | `: ClientInfo` | — |
|
|
290
|
+
| `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
|
|
291
|
+
| `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
|
|
292
|
+
|
|
293
|
+
`@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
|
|
294
|
+
developer writes the matching type annotation (same name). `UserT` is the
|
|
295
|
+
authenticated-user type (`{ id: string; email?: string; role: string; metadata:
|
|
296
|
+
Record<string, unknown> }`) — exported under the alias `UserT` because the value
|
|
297
|
+
name `User` is the `@User()` decorator.
|
|
298
|
+
|
|
299
|
+
## Output = return type
|
|
300
|
+
|
|
301
|
+
The success response is the method's RETURN TYPE. Pair it with `@Returns(schema)`
|
|
302
|
+
so the zod value drives the OpenAPI 200 response (the type annotation drives
|
|
303
|
+
compile-time checking; the zod value is lossless for codegen). A method that
|
|
304
|
+
returns nothing annotates `: void`.
|
|
305
|
+
|
|
306
|
+
## Auth — controller default + route override cascade
|
|
307
|
+
|
|
308
|
+
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
309
|
+
out. Resolution order (most specific wins):
|
|
310
|
+
|
|
311
|
+
1. Route-level `@Post("", { auth })` — wins if present.
|
|
312
|
+
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
313
|
+
their own `auth`.
|
|
314
|
+
3. Default `true` (secure-by-default).
|
|
275
315
|
|
|
276
316
|
```ts
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
317
|
+
@Controller("/public", { auth: false }) // all routes default public
|
|
318
|
+
export default class PublicController {
|
|
319
|
+
@Get("/open") open(): Info { ... } // inherits → public
|
|
320
|
+
@Get("/secret", { auth: true }) // OVERRIDES → authed
|
|
321
|
+
secret(@User() u: UserT): Secret { ... }
|
|
322
|
+
}
|
|
281
323
|
```
|
|
282
324
|
|
|
283
|
-
|
|
284
|
-
|
|
325
|
+
| effective `auth` | `@User()` type to annotate |
|
|
326
|
+
|------------------|----------------------------|
|
|
327
|
+
| required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
|
|
328
|
+
| public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
|
|
285
329
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
| `true` | `User` |
|
|
290
|
-
| `false` | `User \| null` (public) |
|
|
291
|
-
| `{ required: true }` | `User` |
|
|
292
|
-
| `{ required: false }` | `User \| null` (public) |
|
|
293
|
-
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
330
|
+
The runtime always applies the resolved effective auth; the static type you
|
|
331
|
+
annotate is best-effort (a parameter decorator cannot see the controller default
|
|
332
|
+
at the type level).
|
|
294
333
|
|
|
295
|
-
|
|
296
|
-
object with a `role` but no `required` key is treated as authenticated. When
|
|
297
|
-
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
334
|
+
## Errors — global throw classes
|
|
298
335
|
|
|
299
|
-
|
|
336
|
+
Throw an error class anywhere (controller OR service) — no `req`, no per-route
|
|
337
|
+
map. The runtime catches it and emits the standard envelope:
|
|
300
338
|
|
|
301
339
|
```ts
|
|
302
|
-
import {
|
|
303
|
-
|
|
304
|
-
export default defineHandler({
|
|
305
|
-
auth: { required: true },
|
|
306
|
-
input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
|
|
307
|
-
output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
|
|
308
|
-
handler: async (req) => {
|
|
309
|
-
const room = await Database.insert("rooms", {
|
|
310
|
-
name: req.input.name,
|
|
311
|
-
capacity: req.input.capacity ?? null,
|
|
312
|
-
});
|
|
313
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
A controller then mounts it with a method + path:
|
|
319
|
-
|
|
320
|
-
```ts
|
|
321
|
-
// controllers/rooms.controller.ts
|
|
322
|
-
import { defineController, route } from "@palbase/backend";
|
|
323
|
-
import create from "../handlers/rooms/create.js";
|
|
340
|
+
import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
|
|
324
341
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
342
|
+
throw new Conflict("title taken"); // → 409
|
|
343
|
+
throw new NotFound(); // → 404 ("Not found")
|
|
344
|
+
throw new PalError(418, "teapot", "custom"); // → custom status/code
|
|
328
345
|
```
|
|
329
346
|
|
|
347
|
+
See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
348
|
+
|
|
330
349
|
## Middleware
|
|
331
350
|
|
|
332
351
|
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
@@ -343,22 +362,7 @@ export default defineMiddleware(async (ctx, next) => {
|
|
|
343
362
|
```
|
|
344
363
|
|
|
345
364
|
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
346
|
-
rest of the chain (other middleware, then the endpoint
|
|
347
|
-
the `ctx` model, not `req`.
|
|
348
|
-
|
|
349
|
-
To attach middleware to a specific handler, import it and list it in the
|
|
350
|
-
handler's `middleware` array:
|
|
351
|
-
|
|
352
|
-
```ts
|
|
353
|
-
import { defineHandler, z } from "@palbase/backend";
|
|
354
|
-
import logger from "../../middleware/logger.js";
|
|
355
|
-
|
|
356
|
-
export default defineHandler({
|
|
357
|
-
middleware: [logger],
|
|
358
|
-
output: z.object({ ok: z.boolean() }),
|
|
359
|
-
handler: async (req) => ({ ok: true }),
|
|
360
|
-
});
|
|
361
|
-
```
|
|
365
|
+
rest of the chain (other middleware, then the endpoint method).
|
|
362
366
|
|
|
363
367
|
|
|
364
368
|
|
|
@@ -438,6 +442,46 @@ await Database.transaction(async (tx) => {
|
|
|
438
442
|
The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
|
|
439
443
|
(no nested transaction). See [schema.md](./schema.md) for the full surface.
|
|
440
444
|
|
|
445
|
+
## Bypassing RLS — `Database.asService()`
|
|
446
|
+
|
|
447
|
+
When a table has [Row-Level Security](./schema.md#row-level-security-rls)
|
|
448
|
+
policies, every `Database.*` call runs as the request's verified user
|
|
449
|
+
(`authenticated`), so the database filters out rows the user's policies don't
|
|
450
|
+
allow. That is the secure default.
|
|
451
|
+
|
|
452
|
+
Sometimes you need to read or write **across all users** — an admin endpoint, a
|
|
453
|
+
background job that fans out notifications, a cleanup task. For that, call
|
|
454
|
+
`Database.asService()`. It returns a sibling client that runs as the
|
|
455
|
+
`service_role` (which has `BYPASSRLS`), exposing the exact same surface —
|
|
456
|
+
`tables`, the raw string ops, and `transaction`:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
import { Database } from "@palbase/backend";
|
|
460
|
+
|
|
461
|
+
// RLS-enforced (default): only the caller's own rows.
|
|
462
|
+
const mine = await Database.tables.todos.findMany({});
|
|
463
|
+
|
|
464
|
+
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
465
|
+
const all = await Database.asService().tables.todos.findMany({});
|
|
466
|
+
const rows = await Database.asService().query("SELECT count(*) FROM todos");
|
|
467
|
+
|
|
468
|
+
// A service-role transaction (the role is fixed for the whole tx):
|
|
469
|
+
await Database.asService().transaction(async (tx) => {
|
|
470
|
+
await tx.tables.todos.update(id, { done: true });
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Guidelines:
|
|
475
|
+
|
|
476
|
+
- **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
|
|
477
|
+
only where you genuinely need cross-user access. It is intentionally easy to
|
|
478
|
+
grep for in review.
|
|
479
|
+
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
480
|
+
`asService()`, and `tx` never exposes it — a transaction's role is fixed when
|
|
481
|
+
it begins. Use `Database.transaction(...)` for an authenticated tx and
|
|
482
|
+
`Database.asService().transaction(...)` for a service-role tx; you cannot mix
|
|
483
|
+
enforced and bypassed ops inside one interactive transaction.
|
|
484
|
+
|
|
441
485
|
|
|
442
486
|
|
|
443
487
|
<!-- ===== schema.md ===== -->
|
|
@@ -451,8 +495,9 @@ everywhere — by default, with no import and no generic.
|
|
|
451
495
|
|
|
452
496
|
## Defining a schema
|
|
453
497
|
|
|
454
|
-
The table NAME comes from the object key under `tables
|
|
455
|
-
|
|
498
|
+
The table NAME comes from the object key under `tables`. Each table value is an
|
|
499
|
+
object `{ columns, rls?, policies? }` — `columns` is required; `rls` and
|
|
500
|
+
`policies` enable [Row-Level Security](#row-level-security-rls).
|
|
456
501
|
|
|
457
502
|
```ts
|
|
458
503
|
import {
|
|
@@ -463,23 +508,29 @@ import {
|
|
|
463
508
|
export default defineSchema({
|
|
464
509
|
tables: {
|
|
465
510
|
rooms: {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
511
|
+
columns: {
|
|
512
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
513
|
+
name: text().notNull(),
|
|
514
|
+
capacity: integer().nullable(),
|
|
515
|
+
is_active: boolean().default(true),
|
|
516
|
+
created_at: timestamp().defaultNow(),
|
|
517
|
+
},
|
|
471
518
|
},
|
|
472
519
|
sessions: {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
520
|
+
columns: {
|
|
521
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
522
|
+
room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
|
|
523
|
+
user_id: uuid().notNull(),
|
|
524
|
+
data: jsonb().nullable(),
|
|
525
|
+
started_at: timestamp().defaultNow(),
|
|
526
|
+
},
|
|
478
527
|
},
|
|
479
528
|
orders: {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
529
|
+
columns: {
|
|
530
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
531
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
532
|
+
amount: integer().notNull(),
|
|
533
|
+
},
|
|
483
534
|
},
|
|
484
535
|
},
|
|
485
536
|
});
|
|
@@ -509,17 +560,21 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
|
|
|
509
560
|
of the schema, no generic, no cast:
|
|
510
561
|
|
|
511
562
|
```ts
|
|
512
|
-
import {
|
|
563
|
+
import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
|
|
513
564
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
565
|
+
const CreateRoomBody = z.object({ name: z.string() });
|
|
566
|
+
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
567
|
+
|
|
568
|
+
@Controller("/rooms")
|
|
569
|
+
export default class RoomsController {
|
|
570
|
+
@Post("")
|
|
571
|
+
@Returns(RoomOut)
|
|
572
|
+
async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
573
|
+
const room = await Database.tables.rooms.insert({ name: body.name });
|
|
519
574
|
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
520
575
|
// room.nope ← compile error
|
|
521
|
-
}
|
|
522
|
-
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
523
578
|
```
|
|
524
579
|
|
|
525
580
|
`Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
|
|
@@ -535,6 +590,106 @@ import type { Tables } from "@palbase/backend/env";
|
|
|
535
590
|
type Room = Tables["rooms"]["row"];
|
|
536
591
|
```
|
|
537
592
|
|
|
593
|
+
## Row-Level Security (RLS)
|
|
594
|
+
|
|
595
|
+
RLS pushes per-user access control **into Postgres**: every `Database.*` query
|
|
596
|
+
runs as the request's verified user (the `authenticated` role with that user's
|
|
597
|
+
claims), and the database itself filters rows your policies don't allow. A
|
|
598
|
+
missing `WHERE user_id = …` in your handler can no longer leak another user's
|
|
599
|
+
rows — the policy enforces it. This is the recommended way to scope data per
|
|
600
|
+
user.
|
|
601
|
+
|
|
602
|
+
Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
|
|
603
|
+
implies `rls: true` automatically (a table with policies must have RLS enabled
|
|
604
|
+
or the policies are inert). Set `rls: true` with no policies only as a
|
|
605
|
+
deliberate deny-all intermediate step.
|
|
606
|
+
|
|
607
|
+
### The `policy()` builder
|
|
608
|
+
|
|
609
|
+
`policy(name)` is a fluent builder, just like the column builders:
|
|
610
|
+
|
|
611
|
+
```ts
|
|
612
|
+
import { policy } from "@palbase/backend";
|
|
613
|
+
|
|
614
|
+
policy("pb_owner_all")
|
|
615
|
+
.for("all") // "all" | "select" | "insert" | "update" | "delete"
|
|
616
|
+
.to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
|
|
617
|
+
.using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
|
|
618
|
+
.withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
| Method | Default | Meaning |
|
|
622
|
+
|--------|---------|---------|
|
|
623
|
+
| `.for(cmd)` | `"all"` | The SQL command the policy governs. |
|
|
624
|
+
| `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
|
|
625
|
+
| `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
|
|
626
|
+
| `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
|
|
627
|
+
| `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
|
|
628
|
+
|
|
629
|
+
**`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
|
|
630
|
+
request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
|
|
631
|
+
once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
632
|
+
`auth.jwt()` are also available. With no user on the request (anon/public),
|
|
633
|
+
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
634
|
+
rows.
|
|
635
|
+
|
|
636
|
+
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
637
|
+
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
638
|
+
> other modules (storage, cron, …).
|
|
639
|
+
|
|
640
|
+
### Owner-scoped `todos` example
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
|
|
644
|
+
|
|
645
|
+
export default defineSchema({
|
|
646
|
+
tables: {
|
|
647
|
+
todos: {
|
|
648
|
+
columns: {
|
|
649
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
650
|
+
owner: text().notNull(), // palauth user id (TEXT)
|
|
651
|
+
title: text().notNull(),
|
|
652
|
+
done: boolean().default(false),
|
|
653
|
+
created_at: timestamp().defaultNow(),
|
|
654
|
+
},
|
|
655
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
656
|
+
policies: [
|
|
657
|
+
// Read: a user sees only their own todos.
|
|
658
|
+
policy("pb_todos_owner_select")
|
|
659
|
+
.for("select")
|
|
660
|
+
.to("authenticated")
|
|
661
|
+
.using("owner = (select auth.uid())"),
|
|
662
|
+
|
|
663
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
664
|
+
policy("pb_todos_owner_write")
|
|
665
|
+
.for("all")
|
|
666
|
+
.to("authenticated")
|
|
667
|
+
.using("owner = (select auth.uid())")
|
|
668
|
+
.withCheck("owner = (select auth.uid())"),
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
676
|
+
calling user's rows — no `WHERE owner = …` needed in the handler. To read or
|
|
677
|
+
write across all users (e.g. an admin job), use the explicit bypass:
|
|
678
|
+
`Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
|
|
679
|
+
|
|
680
|
+
### How policies are applied
|
|
681
|
+
|
|
682
|
+
On deploy, Palbase diffs your declared schema against the live database and
|
|
683
|
+
applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
|
|
684
|
+
when the table doesn't already have it, and `CREATE POLICY` only for policies
|
|
685
|
+
that don't already exist (keyed by `(table, name)`). These are non-destructive,
|
|
686
|
+
so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
687
|
+
|
|
688
|
+
> Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
|
|
689
|
+
> auto-applied — rename the policy (new `(table, name)`) or drop the old one in
|
|
690
|
+
> a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
|
|
691
|
+
|
|
692
|
+
|
|
538
693
|
|
|
539
694
|
|
|
540
695
|
<!-- ===== migrations.md ===== -->
|
|
@@ -734,17 +889,21 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
|
|
|
734
889
|
## Flags
|
|
735
890
|
|
|
736
891
|
```ts
|
|
737
|
-
import {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
892
|
+
import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
|
|
893
|
+
import type { UserT } from "@palbase/backend";
|
|
894
|
+
|
|
895
|
+
const FlagsOut = z.object({ enabled: z.boolean() });
|
|
896
|
+
|
|
897
|
+
@Controller("/checkout")
|
|
898
|
+
export default class CheckoutController {
|
|
899
|
+
@Get("/flags") // auth omitted → required → user is non-null
|
|
900
|
+
@Returns(FlagsOut)
|
|
901
|
+
async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
|
|
902
|
+
const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
|
|
903
|
+
const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
|
|
745
904
|
return { enabled: enabled ?? false };
|
|
746
|
-
}
|
|
747
|
-
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
748
907
|
```
|
|
749
908
|
|
|
750
909
|
|
|
@@ -852,51 +1011,58 @@ const results = (await google.nearby(41.0, 29.0)).data.results;
|
|
|
852
1011
|
|
|
853
1012
|
# Errors
|
|
854
1013
|
|
|
855
|
-
|
|
856
|
-
envelope:
|
|
1014
|
+
Throw an error class to fail a request. Every one serializes to the standard
|
|
1015
|
+
Palbase error envelope:
|
|
857
1016
|
|
|
858
1017
|
```json
|
|
859
1018
|
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
860
1019
|
```
|
|
861
1020
|
|
|
862
|
-
|
|
1021
|
+
Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
|
|
1022
|
+
per-route error map: the runtime catches any thrown error class and emits the
|
|
1023
|
+
envelope.
|
|
1024
|
+
|
|
1025
|
+
## Named status classes
|
|
863
1026
|
|
|
864
1027
|
```ts
|
|
865
|
-
import {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
throw new
|
|
1028
|
+
import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests } from "@palbase/backend";
|
|
1029
|
+
|
|
1030
|
+
throw new Conflict("title taken"); // → 409 ("conflict" code by default)
|
|
1031
|
+
throw new NotFound(); // → 404 ("not_found", "Not found")
|
|
1032
|
+
throw new BadRequest("missing field"); // → 400
|
|
1033
|
+
throw new Unauthorized(); // → 401
|
|
1034
|
+
throw new Forbidden(); // → 403
|
|
1035
|
+
throw new TooManyRequests(); // → 429
|
|
869
1036
|
```
|
|
870
1037
|
|
|
871
|
-
|
|
1038
|
+
Each class fixes its HTTP status. The constructor is
|
|
1039
|
+
`new <Class>(message?, code?, data?)`:
|
|
872
1040
|
|
|
873
|
-
|
|
1041
|
+
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
1042
|
+
derived from the class name).
|
|
1043
|
+
- `code` overrides the wire `error` code (defaults to the class's snake_case
|
|
1044
|
+
code, e.g. `not_found`).
|
|
1045
|
+
- `data` rides along under the envelope's `data` field for structured context.
|
|
874
1046
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1047
|
+
```ts
|
|
1048
|
+
throw new NotFound("Room does not exist", "room_not_found");
|
|
1049
|
+
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
## `PalError` / `HttpError` (custom status)
|
|
1053
|
+
|
|
1054
|
+
For a status/code not covered by a named class:
|
|
878
1055
|
|
|
879
1056
|
```ts
|
|
880
|
-
import {
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
output: z.object({ ok: z.boolean() }),
|
|
885
|
-
errors: {
|
|
886
|
-
notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
|
|
887
|
-
locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
|
|
888
|
-
},
|
|
889
|
-
handler: async (req) => {
|
|
890
|
-
const todo = await Database.findById("todos", req.input.id);
|
|
891
|
-
if (!todo) throw req.errors.notFound(); // no data → no args
|
|
892
|
-
if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
|
|
893
|
-
return { ok: true };
|
|
894
|
-
},
|
|
895
|
-
});
|
|
1057
|
+
import { PalError } from "@palbase/backend";
|
|
1058
|
+
throw new PalError(418, "teapot", "I'm a teapot");
|
|
1059
|
+
// optional structured payload (4th arg) rides along under `data`:
|
|
1060
|
+
throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
896
1061
|
```
|
|
897
1062
|
|
|
898
|
-
|
|
899
|
-
|
|
1063
|
+
`PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
|
|
1064
|
+
The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
|
|
1065
|
+
HttpError) … }` matches any of them.
|
|
900
1066
|
|
|
901
1067
|
|
|
902
1068
|
|