@palbase/backend 2.0.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-EG7TTYHY.js +235 -0
- package/dist/chunk-EG7TTYHY.js.map +1 -0
- package/dist/chunk-WUQO76NW.js +101 -0
- package/dist/chunk-WUQO76NW.js.map +1 -0
- package/dist/db/env.cjs +19 -0
- package/dist/db/env.cjs.map +1 -0
- package/dist/db/env.d.cts +45 -0
- package/dist/db/env.d.ts +45 -0
- package/dist/db/env.js +1 -0
- package/dist/db/env.js.map +1 -0
- package/dist/db/index.cjs +143 -231
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +4 -20
- package/dist/db/index.d.ts +4 -20
- package/dist/db/index.js +13 -233
- package/dist/db/index.js.map +1 -1
- package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-2d_DpASt.d.cts} +94 -96
- package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-2d_DpASt.d.ts} +94 -96
- package/dist/index-DZW9CjiY.d.ts +463 -0
- package/dist/index-DzRFS3Tl.d.cts +463 -0
- package/dist/index.cjs +557 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +278 -161
- package/dist/index.d.ts +278 -161
- package/dist/index.js +343 -12
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +57 -2
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -2
- package/dist/test/index.d.ts +1 -2
- package/dist/test/index.js +10 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +33 -12
- package/docs/background.md +19 -13
- package/docs/database.md +70 -17
- package/docs/endpoints.md +103 -79
- package/docs/errors.md +37 -31
- package/docs/events.md +25 -17
- package/docs/getting-started.md +38 -18
- package/docs/llms-full.txt +758 -267
- package/docs/llms.txt +3 -1
- package/docs/migrations.md +98 -0
- package/docs/resources.md +94 -0
- package/docs/routing.md +54 -27
- package/docs/schema.md +163 -42
- package/docs/services.md +17 -14
- package/package.json +12 -2
- package/dist/chunk-4J3F32SH.js +0 -96
- package/dist/chunk-4J3F32SH.js.map +0 -1
- package/dist/chunk-L36JLUPO.js +0 -97
- package/dist/chunk-L36JLUPO.js.map +0 -1
- package/dist/schema-BqfEhIC0.d.cts +0 -133
- package/dist/schema-BqfEhIC0.d.ts +0 -133
package/docs/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Palbase Backend SDK (`@palbase/backend`)
|
|
2
2
|
|
|
3
|
-
> File-based TypeScript backend SDK.
|
|
3
|
+
> File-based TypeScript backend SDK. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use `req`, workers `(payload, meta)`, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). Not Express, not Supabase Edge Functions.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
@@ -15,25 +15,41 @@ This is **not** Express, Fastify, or a Supabase Edge Function. There is no
|
|
|
15
15
|
`app.get(...)`, no manual route registration, no `import express`. You export
|
|
16
16
|
definitions; the runtime wires them up.
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Mental model (important)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|------------------|------------------|--------------------|
|
|
22
|
-
| **Endpoints** (`endpoints/**`) | a single `req` ([PBRequest](./endpoints.md)) | imported singletons: `import { Database } from "@palbase/backend"` |
|
|
23
|
-
| **Workers, Jobs, Hooks, Webhooks** | a `ctx` object | `ctx.db`, `ctx.log`, `ctx.cache`, `ctx.queue` |
|
|
20
|
+
Every handler type imports service singletons the same way:
|
|
24
21
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
```ts
|
|
23
|
+
import { Database, Log, Cache, Queue } from "@palbase/backend";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The **only difference** is the trigger argument:
|
|
27
|
+
|
|
28
|
+
| You are writing… | Handler signature | Trigger arg |
|
|
29
|
+
|------------------|-------------------|-------------|
|
|
30
|
+
| **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@Query`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
|
|
31
|
+
| **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
|
|
32
|
+
| **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
|
|
33
|
+
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
34
|
+
| **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
|
|
35
|
+
| **Middleware** (`middleware/**`) | `(ctx, next)` | `MiddlewareContext` — the **one exception** |
|
|
36
|
+
|
|
37
|
+
`meta` carries non-service data: `env` (branch env vars), `projectId`,
|
|
38
|
+
`environmentId`, and for workers/webhooks `requestId`. Services always come from
|
|
39
|
+
the imported singletons — not from `ctx` or any argument.
|
|
28
40
|
|
|
29
41
|
## Project shape
|
|
30
42
|
|
|
31
43
|
```
|
|
32
44
|
my-backend/
|
|
33
45
|
├── package.json # depends on @palbase/backend
|
|
34
|
-
├──
|
|
35
|
-
│ └── hello
|
|
46
|
+
├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
|
|
47
|
+
│ └── hello.controller.ts # @Controller("/hello") + @Get example
|
|
48
|
+
├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
|
|
49
|
+
│ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
|
|
50
|
+
├── services/ # plain classes/singletons your controllers call
|
|
36
51
|
├── db/schema.ts # table definitions (optional, enables typed DB)
|
|
52
|
+
├── db/migrations/ # explicit SQL migrations for type changes (optional)
|
|
37
53
|
├── workers/ # background job handlers (optional)
|
|
38
54
|
├── jobs/ # cron-scheduled jobs (optional)
|
|
39
55
|
├── hooks/ # auth/storage/document event hooks (optional)
|
|
@@ -41,15 +57,20 @@ my-backend/
|
|
|
41
57
|
└── middleware/ # cross-cutting request middleware (optional)
|
|
42
58
|
```
|
|
43
59
|
|
|
60
|
+
HTTP endpoints are **not** file-path routed. You author a class controller
|
|
61
|
+
(`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
|
|
62
|
+
`controllers/` mounts it. See [routing.md](./routing.md).
|
|
63
|
+
|
|
44
64
|
## Documentation
|
|
45
65
|
|
|
46
66
|
| Topic | File |
|
|
47
67
|
|-------|------|
|
|
48
68
|
| Getting started | [getting-started.md](./getting-started.md) |
|
|
49
|
-
|
|
|
50
|
-
| Endpoints
|
|
69
|
+
| Routing (class controllers) | [routing.md](./routing.md) |
|
|
70
|
+
| Endpoints (decorators) | [endpoints.md](./endpoints.md) |
|
|
51
71
|
| Database & transactions | [database.md](./database.md) |
|
|
52
72
|
| Schema & typed DB | [schema.md](./schema.md) |
|
|
73
|
+
| Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
|
|
53
74
|
| Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
|
|
54
75
|
| Errors | [errors.md](./errors.md) |
|
|
55
76
|
| Workers & Jobs | [background.md](./background.md) |
|
|
@@ -77,7 +98,6 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
|
77
98
|
"private": true,
|
|
78
99
|
"scripts": {
|
|
79
100
|
"dev": "palbase serve",
|
|
80
|
-
"deploy": "palbase push",
|
|
81
101
|
"typecheck": "tsc --noEmit"
|
|
82
102
|
},
|
|
83
103
|
"dependencies": { "@palbase/backend": "latest" },
|
|
@@ -87,157 +107,245 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
|
87
107
|
|
|
88
108
|
## Local dev loop
|
|
89
109
|
|
|
90
|
-
- `palbase serve` — run your backend locally with hot reload.
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
- `palbase serve` — run your backend locally with hot reload. It runs your
|
|
111
|
+
`controllers/` locally but proxies `Database`/`ctx.*` to the **deployed**
|
|
112
|
+
branch, so the branch must already be deployed (serve tells you to push first
|
|
113
|
+
if it isn't). See [migrations.md](./migrations.md) for the schema/migration
|
|
114
|
+
side of this.
|
|
115
|
+
- **Deploy is GitHub-native** — there is no `palbase push`. Commit and
|
|
116
|
+
`git push` to your project's repo; the push triggers a deploy of the backend
|
|
117
|
+
runtime. Push a **branch** to deploy that branch instead of `main`.
|
|
93
118
|
|
|
94
119
|
## Your first endpoint
|
|
95
120
|
|
|
96
|
-
|
|
121
|
+
An endpoint is a method on a class controller. Declare the schemas in `models/`:
|
|
97
122
|
|
|
98
123
|
```ts
|
|
99
|
-
|
|
124
|
+
// models/hello/greet.ts
|
|
125
|
+
import { z } from "@palbase/backend";
|
|
100
126
|
|
|
101
|
-
export
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
export const GreetQuery = z.object({ name: z.string().optional() });
|
|
128
|
+
export type GreetQuery = z.infer<typeof GreetQuery>;
|
|
129
|
+
|
|
130
|
+
export const HelloResponse = z.object({ message: z.string(), user: z.string().nullable() });
|
|
131
|
+
export type HelloResponse = z.infer<typeof HelloResponse>;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Then write the controller in `controllers/hello.controller.ts`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
|
|
138
|
+
import type { UserT } from "@palbase/backend";
|
|
139
|
+
import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
|
|
140
|
+
|
|
141
|
+
@Controller("/hello", { auth: false })
|
|
142
|
+
export default class HelloController {
|
|
143
|
+
@Get("")
|
|
144
|
+
@Returns(HelloResponse)
|
|
145
|
+
greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
|
|
146
|
+
return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
110
149
|
```
|
|
111
150
|
|
|
112
|
-
This is served at `GET /hello`. The
|
|
113
|
-
|
|
151
|
+
This is served at `GET /hello`. The `@Query` schema validates the query string;
|
|
152
|
+
the method's return type (paired with `@Returns(HelloResponse)`) validates and
|
|
153
|
+
describes the response. See [routing.md](./routing.md) and
|
|
154
|
+
[endpoints.md](./endpoints.md) for the full class-controller model.
|
|
114
155
|
|
|
115
156
|
|
|
116
157
|
|
|
117
158
|
<!-- ===== routing.md ===== -->
|
|
118
159
|
|
|
119
|
-
#
|
|
160
|
+
# Routing
|
|
120
161
|
|
|
121
|
-
|
|
122
|
-
|
|
162
|
+
Routes are declared in code with **class controllers**. A controller is a class
|
|
163
|
+
decorated with `@Controller(basePath)`; each route is a method decorated with
|
|
164
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`. Request input + context are injected
|
|
165
|
+
into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/`@User`/
|
|
166
|
+
…). Putting a controller file under `controllers/` mounts it — there is no
|
|
167
|
+
central router and no manual registration.
|
|
123
168
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
| `endpoints/items/post.ts` | `POST /items` |
|
|
128
|
-
| `endpoints/posts/[id]/get.ts` | `GET /posts/:id` |
|
|
129
|
-
| `endpoints/posts/[id]/patch.ts` | `PATCH /posts/:id` |
|
|
130
|
-
| `endpoints/rooms/[id]/sessions/post.ts` | `POST /rooms/:id/sessions` |
|
|
169
|
+
```ts
|
|
170
|
+
import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
|
|
171
|
+
```
|
|
131
172
|
|
|
132
|
-
|
|
173
|
+
## Controllers — class + method decorators
|
|
133
174
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
- There is no central router file and no manual registration. Adding a file adds a route.
|
|
175
|
+
`@Controller(basePath)` marks the class and sets the mount path. Each route
|
|
176
|
+
method declares its verb + subpath; the real work lives in a `services/` class
|
|
177
|
+
(the controller method is thin).
|
|
138
178
|
|
|
139
179
|
```ts
|
|
140
|
-
//
|
|
141
|
-
import {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
180
|
+
// controllers/places.controller.ts
|
|
181
|
+
import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
|
|
182
|
+
import type { UserT } from "@palbase/backend";
|
|
183
|
+
import { placeService } from "../services/place.service.js";
|
|
184
|
+
import { ImportNearbyBody } from "../models/places/import.js";
|
|
185
|
+
import { PlaceSchema } from "../models/places/shared.js";
|
|
186
|
+
|
|
187
|
+
@Controller("/places")
|
|
188
|
+
export default class PlacesController {
|
|
189
|
+
@Post("/import")
|
|
190
|
+
@Returns(PlaceSchema)
|
|
191
|
+
importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
|
|
192
|
+
return placeService.importNearby(body.lat, body.lng, user.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Get("/favorites", { auth: false })
|
|
196
|
+
@Returns(z.array(PlaceSchema))
|
|
197
|
+
listFavorites(): PlaceSchema[] {
|
|
198
|
+
return placeService.listFavorites();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
152
201
|
```
|
|
153
202
|
|
|
203
|
+
| Method name (sugar) | Verb | Full path | operationId (flat) |
|
|
204
|
+
|---|---|---|---|
|
|
205
|
+
| `importNearby` | POST | `/places/import` | `postPlacesImport` |
|
|
206
|
+
| `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
|
|
207
|
+
|
|
208
|
+
Rules:
|
|
209
|
+
|
|
210
|
+
- The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
|
|
211
|
+
- A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
|
|
212
|
+
- Input is declared with the parameter decorators — `@Body(schema)`,
|
|
213
|
+
`@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
|
|
214
|
+
the method's RETURN TYPE; pair it with `@Returns(schema)` so the zod value
|
|
215
|
+
drives the OpenAPI 200 response (or annotate `: void` for no body).
|
|
216
|
+
- The operationId is derived FLAT from method + full path (`postPlacesImport`),
|
|
217
|
+
not from the method name. Change `@Post` → `@Put` — no file rename.
|
|
218
|
+
|
|
219
|
+
See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
|
|
220
|
+
options, the parameter decorators, auth cascade, and error classes).
|
|
221
|
+
|
|
154
222
|
|
|
155
223
|
|
|
156
224
|
<!-- ===== endpoints.md ===== -->
|
|
157
225
|
|
|
158
226
|
# Endpoints
|
|
159
227
|
|
|
160
|
-
An endpoint is
|
|
161
|
-
|
|
162
|
-
|
|
228
|
+
An endpoint is a **method on a class controller** — a class decorated with
|
|
229
|
+
`@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
|
|
230
|
+
`@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
|
|
231
|
+
automatically (see [routing.md](./routing.md)). Request input + context are
|
|
232
|
+
injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
|
|
233
|
+
`@User`/…), each piece direct — no `req` god-object. Services are reached via the
|
|
234
|
+
imported singletons (see [services.md](./services.md)).
|
|
163
235
|
|
|
164
|
-
##
|
|
236
|
+
## A controller
|
|
165
237
|
|
|
166
238
|
```ts
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
239
|
+
// controllers/rooms.controller.ts
|
|
240
|
+
import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
|
|
241
|
+
import type { UserT } from "@palbase/backend";
|
|
242
|
+
import { CreateRoomBody } from "../models/rooms/create.js";
|
|
243
|
+
import { RoomSchema } from "../models/rooms/shared.js";
|
|
244
|
+
|
|
245
|
+
@Controller("/rooms")
|
|
246
|
+
export default class RoomsController {
|
|
247
|
+
@Post("")
|
|
248
|
+
@Returns(RoomSchema)
|
|
249
|
+
async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
|
|
250
|
+
const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
|
|
251
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@Get("/{id}")
|
|
255
|
+
@Returns(RoomSchema)
|
|
256
|
+
async getOne(@Param("id") id: string): Promise<RoomSchema> {
|
|
257
|
+
const room = await Database.findById("rooms", id);
|
|
258
|
+
if (!room) throw new NotFound("Room does not exist", "room_not_found");
|
|
259
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Method decorators
|
|
265
|
+
|
|
266
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
interface RouteOptions {
|
|
270
|
+
auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
|
|
271
|
+
rateLimit?: { max: number; window: number }; // optional, per route
|
|
272
|
+
}
|
|
177
273
|
```
|
|
178
274
|
|
|
179
|
-
|
|
275
|
+
Input and output are NOT method-decorator options: input comes from the
|
|
276
|
+
parameter decorators, output from the method's return type. The method decorator
|
|
277
|
+
only carries route concerns (path, auth, rateLimit).
|
|
278
|
+
|
|
279
|
+
## Parameter decorators
|
|
180
280
|
|
|
181
|
-
|
|
|
182
|
-
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
281
|
+
| Decorator | Injects | Typed by | OpenAPI |
|
|
282
|
+
|---|---|---|---|
|
|
283
|
+
| `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
|
|
284
|
+
| `@Query(schema)` | query params | `: T` | query parameters |
|
|
285
|
+
| `@Param("id")` | one path param | `: string` | path parameter |
|
|
286
|
+
| `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
|
|
287
|
+
| `@User()` | authenticated user | `: UserT` | — (from token) |
|
|
288
|
+
| `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
|
|
289
|
+
| `@Client()` | parsed client info | `: ClientInfo` | — |
|
|
290
|
+
| `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
|
|
291
|
+
| `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
|
|
193
292
|
|
|
194
|
-
`
|
|
293
|
+
`@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
|
|
294
|
+
developer writes the matching type annotation (same name). `UserT` is the
|
|
295
|
+
authenticated-user type (`{ id: string; email?: string; role: string; metadata:
|
|
296
|
+
Record<string, unknown> }`) — exported under the alias `UserT` because the value
|
|
297
|
+
name `User` is the `@User()` decorator.
|
|
195
298
|
|
|
196
|
-
##
|
|
299
|
+
## Output = return type
|
|
300
|
+
|
|
301
|
+
The success response is the method's RETURN TYPE. Pair it with `@Returns(schema)`
|
|
302
|
+
so the zod value drives the OpenAPI 200 response (the type annotation drives
|
|
303
|
+
compile-time checking; the zod value is lossless for codegen). A method that
|
|
304
|
+
returns nothing annotates `: void`.
|
|
305
|
+
|
|
306
|
+
## Auth — controller default + route override cascade
|
|
307
|
+
|
|
308
|
+
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
309
|
+
out. Resolution order (most specific wins):
|
|
310
|
+
|
|
311
|
+
1. Route-level `@Post("", { auth })` — wins if present.
|
|
312
|
+
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
313
|
+
their own `auth`.
|
|
314
|
+
3. Default `true` (secure-by-default).
|
|
197
315
|
|
|
198
316
|
```ts
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
}
|
|
203
323
|
```
|
|
204
324
|
|
|
205
|
-
|
|
206
|
-
|
|
325
|
+
| effective `auth` | `@User()` type to annotate |
|
|
326
|
+
|------------------|----------------------------|
|
|
327
|
+
| required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
|
|
328
|
+
| public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
|
|
207
329
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
| `true` | `User` |
|
|
212
|
-
| `false` | `User \| null` |
|
|
213
|
-
| `{ required: true }` | `User` |
|
|
214
|
-
| `{ required: false }` | `User \| null` |
|
|
215
|
-
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
330
|
+
The runtime always applies the resolved effective auth; the static type you
|
|
331
|
+
annotate is best-effort (a parameter decorator cannot see the controller default
|
|
332
|
+
at the type level).
|
|
216
333
|
|
|
217
|
-
|
|
218
|
-
object with a `role` but no `required` key is treated as authenticated. When
|
|
219
|
-
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
334
|
+
## Errors — global throw classes
|
|
220
335
|
|
|
221
|
-
|
|
336
|
+
Throw an error class anywhere (controller OR service) — no `req`, no per-route
|
|
337
|
+
map. The runtime catches it and emits the standard envelope:
|
|
222
338
|
|
|
223
339
|
```ts
|
|
224
|
-
import {
|
|
340
|
+
import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
|
|
225
341
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
|
|
230
|
-
output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
|
|
231
|
-
handler: async (req) => {
|
|
232
|
-
const room = await Database.insert("rooms", {
|
|
233
|
-
name: req.input.name,
|
|
234
|
-
capacity: req.input.capacity ?? null,
|
|
235
|
-
});
|
|
236
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
237
|
-
},
|
|
238
|
-
});
|
|
342
|
+
throw new Conflict("title taken"); // → 409
|
|
343
|
+
throw new NotFound(); // → 404 ("Not found")
|
|
344
|
+
throw new PalError(418, "teapot", "custom"); // → custom status/code
|
|
239
345
|
```
|
|
240
346
|
|
|
347
|
+
See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
348
|
+
|
|
241
349
|
## Middleware
|
|
242
350
|
|
|
243
351
|
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
@@ -254,23 +362,7 @@ export default defineMiddleware(async (ctx, next) => {
|
|
|
254
362
|
```
|
|
255
363
|
|
|
256
364
|
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
257
|
-
rest of the chain (other middleware, then the endpoint
|
|
258
|
-
the `ctx` model, not `req`.
|
|
259
|
-
|
|
260
|
-
To attach middleware to a specific endpoint, import it and list it in the
|
|
261
|
-
endpoint's `middleware` array:
|
|
262
|
-
|
|
263
|
-
```ts
|
|
264
|
-
import { defineEndpoint, z } from "@palbase/backend";
|
|
265
|
-
import logger from "../../middleware/logger.js";
|
|
266
|
-
|
|
267
|
-
export default defineEndpoint({
|
|
268
|
-
method: "GET",
|
|
269
|
-
middleware: [logger],
|
|
270
|
-
output: z.object({ ok: z.boolean() }),
|
|
271
|
-
handler: async (req) => ({ ok: true }),
|
|
272
|
-
});
|
|
273
|
-
```
|
|
365
|
+
rest of the chain (other middleware, then the endpoint method).
|
|
274
366
|
|
|
275
367
|
|
|
276
368
|
|
|
@@ -278,17 +370,38 @@ export default defineEndpoint({
|
|
|
278
370
|
|
|
279
371
|
# Database
|
|
280
372
|
|
|
281
|
-
|
|
373
|
+
Import the `Database` singleton in every handler type — endpoints, workers,
|
|
374
|
+
jobs, hooks, and webhooks all use the same import:
|
|
282
375
|
|
|
283
376
|
```ts
|
|
284
377
|
import { Database } from "@palbase/backend";
|
|
285
378
|
```
|
|
286
379
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
380
|
+
Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
|
|
381
|
+
and [events.md](./events.md) for worker/job/hook/webhook examples).
|
|
382
|
+
|
|
383
|
+
## Typed by default — `Database.tables`
|
|
384
|
+
|
|
385
|
+
When you declare `db/schema.ts`, `Database.tables.<name>` is typed everywhere
|
|
386
|
+
with no import and no generic. `insert` demands the right columns; rows come
|
|
387
|
+
back typed; nullable columns are `T | null`. This is the path you should use:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
const todo = await Database.tables.todos.insert({ title: "buy milk" });
|
|
391
|
+
todo.id; // string ✓
|
|
392
|
+
todo.done; // boolean ✓
|
|
393
|
+
const open = await Database.tables.todos.findMany({ done: false });
|
|
394
|
+
await Database.tables.todos.update(todo.id, { done: true });
|
|
395
|
+
await Database.tables.todos.delete(todo.id);
|
|
396
|
+
// todo.nope ← compile error
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
See [schema.md](./schema.md) for the full typed-table surface.
|
|
290
400
|
|
|
291
|
-
##
|
|
401
|
+
## Raw string-keyed operations
|
|
402
|
+
|
|
403
|
+
For dynamic table names or read-only SQL, the string-keyed ops are still
|
|
404
|
+
available:
|
|
292
405
|
|
|
293
406
|
| Method | Returns |
|
|
294
407
|
|--------|---------|
|
|
@@ -300,14 +413,6 @@ reached via the context object (see [background.md](./background.md) and
|
|
|
300
413
|
| `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
|
|
301
414
|
| `Database.transaction(fn)` | runs `fn(tx)` in a transaction |
|
|
302
415
|
|
|
303
|
-
```ts
|
|
304
|
-
const row = await Database.insert("todos", { title: "buy milk", done: false });
|
|
305
|
-
const one = await Database.findById("todos", row.id as string);
|
|
306
|
-
const open = await Database.findMany("todos", { done: false });
|
|
307
|
-
await Database.update("todos", row.id as string, { done: true });
|
|
308
|
-
await Database.delete("todos", row.id as string);
|
|
309
|
-
```
|
|
310
|
-
|
|
311
416
|
`findMany`'s `query` is an equality filter: keys are ANDed together. For
|
|
312
417
|
anything richer (ranges, ordering, joins) use `Database.query`.
|
|
313
418
|
|
|
@@ -328,14 +433,54 @@ transaction). Returning commits; throwing rolls back.
|
|
|
328
433
|
|
|
329
434
|
```ts
|
|
330
435
|
await Database.transaction(async (tx) => {
|
|
331
|
-
const order = await tx.insert(
|
|
332
|
-
await tx.insert(
|
|
436
|
+
const order = await tx.tables.orders.insert({ amount: 1000, status: "pending" });
|
|
437
|
+
await tx.tables.order_items.insert({ order_id: order.id, sku: "ABC" });
|
|
333
438
|
// throw here → both inserts roll back
|
|
334
439
|
});
|
|
335
440
|
```
|
|
336
441
|
|
|
337
|
-
|
|
338
|
-
[schema.md](./schema.md).
|
|
442
|
+
The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
|
|
443
|
+
(no nested transaction). See [schema.md](./schema.md) for the full surface.
|
|
444
|
+
|
|
445
|
+
## Bypassing RLS — `Database.asService()`
|
|
446
|
+
|
|
447
|
+
When a table has [Row-Level Security](./schema.md#row-level-security-rls)
|
|
448
|
+
policies, every `Database.*` call runs as the request's verified user
|
|
449
|
+
(`authenticated`), so the database filters out rows the user's policies don't
|
|
450
|
+
allow. That is the secure default.
|
|
451
|
+
|
|
452
|
+
Sometimes you need to read or write **across all users** — an admin endpoint, a
|
|
453
|
+
background job that fans out notifications, a cleanup task. For that, call
|
|
454
|
+
`Database.asService()`. It returns a sibling client that runs as the
|
|
455
|
+
`service_role` (which has `BYPASSRLS`), exposing the exact same surface —
|
|
456
|
+
`tables`, the raw string ops, and `transaction`:
|
|
457
|
+
|
|
458
|
+
```ts
|
|
459
|
+
import { Database } from "@palbase/backend";
|
|
460
|
+
|
|
461
|
+
// RLS-enforced (default): only the caller's own rows.
|
|
462
|
+
const mine = await Database.tables.todos.findMany({});
|
|
463
|
+
|
|
464
|
+
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
465
|
+
const all = await Database.asService().tables.todos.findMany({});
|
|
466
|
+
const rows = await Database.asService().query("SELECT count(*) FROM todos");
|
|
467
|
+
|
|
468
|
+
// A service-role transaction (the role is fixed for the whole tx):
|
|
469
|
+
await Database.asService().transaction(async (tx) => {
|
|
470
|
+
await tx.tables.todos.update(id, { done: true });
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Guidelines:
|
|
475
|
+
|
|
476
|
+
- **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
|
|
477
|
+
only where you genuinely need cross-user access. It is intentionally easy to
|
|
478
|
+
grep for in review.
|
|
479
|
+
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
480
|
+
`asService()`, and `tx` never exposes it — a transaction's role is fixed when
|
|
481
|
+
it begins. Use `Database.transaction(...)` for an authenticated tx and
|
|
482
|
+
`Database.asService().transaction(...)` for a service-role tx; you cannot mix
|
|
483
|
+
enforced and bypassed ops inside one interactive transaction.
|
|
339
484
|
|
|
340
485
|
|
|
341
486
|
|
|
@@ -344,36 +489,50 @@ For a typed `.tables.*` API instead of string table names, see
|
|
|
344
489
|
# Schema & typed database access
|
|
345
490
|
|
|
346
491
|
Declare your tables in `db/schema.ts` with `defineSchema`. This drives
|
|
347
|
-
migrations
|
|
492
|
+
[migrations](./migrations.md) (additive changes auto-apply on deploy; type
|
|
493
|
+
changes need an explicit migration) and makes `Database.tables.*` typed
|
|
494
|
+
everywhere — by default, with no import and no generic.
|
|
348
495
|
|
|
349
496
|
## Defining a schema
|
|
350
497
|
|
|
498
|
+
The table NAME comes from the object key under `tables`. Each table value is an
|
|
499
|
+
object `{ columns, rls?, policies? }` — `columns` is required; `rls` and
|
|
500
|
+
`policies` enable [Row-Level Security](#row-level-security-rls).
|
|
501
|
+
|
|
351
502
|
```ts
|
|
352
503
|
import {
|
|
353
|
-
defineSchema,
|
|
504
|
+
defineSchema,
|
|
354
505
|
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
355
506
|
} from "@palbase/backend";
|
|
356
507
|
|
|
357
508
|
export default defineSchema({
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
509
|
+
tables: {
|
|
510
|
+
rooms: {
|
|
511
|
+
columns: {
|
|
512
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
513
|
+
name: text().notNull(),
|
|
514
|
+
capacity: integer().nullable(),
|
|
515
|
+
is_active: boolean().default(true),
|
|
516
|
+
created_at: timestamp().defaultNow(),
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
sessions: {
|
|
520
|
+
columns: {
|
|
521
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
522
|
+
room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
|
|
523
|
+
user_id: uuid().notNull(),
|
|
524
|
+
data: jsonb().nullable(),
|
|
525
|
+
started_at: timestamp().defaultNow(),
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
orders: {
|
|
529
|
+
columns: {
|
|
530
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
531
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
532
|
+
amount: integer().notNull(),
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
377
536
|
});
|
|
378
537
|
```
|
|
379
538
|
|
|
@@ -394,35 +553,245 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
|
394
553
|
`.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
|
|
395
554
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`.
|
|
396
555
|
|
|
397
|
-
## Typed DB access
|
|
556
|
+
## Typed DB access — by default
|
|
557
|
+
|
|
558
|
+
You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
|
|
559
|
+
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
560
|
+
of the schema, no generic, no cast:
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
|
|
564
|
+
|
|
565
|
+
const CreateRoomBody = z.object({ name: z.string() });
|
|
566
|
+
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
567
|
+
|
|
568
|
+
@Controller("/rooms")
|
|
569
|
+
export default class RoomsController {
|
|
570
|
+
@Post("")
|
|
571
|
+
@Returns(RoomOut)
|
|
572
|
+
async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
573
|
+
const room = await Database.tables.rooms.insert({ name: body.name });
|
|
574
|
+
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
575
|
+
// room.nope ← compile error
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
`Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
|
|
581
|
+
`findById(id)`, `findMany(query?)`, and `Database.transaction(fn)` yields a `tx`
|
|
582
|
+
with the same typed tables. The raw string-keyed ops
|
|
583
|
+
(`Database.insert("rooms", …)`, `Database.query(…)`) are still available for
|
|
584
|
+
dynamic table names and read-only SQL.
|
|
585
|
+
|
|
586
|
+
If you want a row type explicitly, import it from the generated env module:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import type { Tables } from "@palbase/backend/env";
|
|
590
|
+
type Room = Tables["rooms"]["row"];
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
## Row-Level Security (RLS)
|
|
398
594
|
|
|
399
|
-
|
|
400
|
-
|
|
595
|
+
RLS pushes per-user access control **into Postgres**: every `Database.*` query
|
|
596
|
+
runs as the request's verified user (the `authenticated` role with that user's
|
|
597
|
+
claims), and the database itself filters rows your policies don't allow. A
|
|
598
|
+
missing `WHERE user_id = …` in your handler can no longer leak another user's
|
|
599
|
+
rows — the policy enforces it. This is the recommended way to scope data per
|
|
600
|
+
user.
|
|
601
|
+
|
|
602
|
+
Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
|
|
603
|
+
implies `rls: true` automatically (a table with policies must have RLS enabled
|
|
604
|
+
or the policies are inert). Set `rls: true` with no policies only as a
|
|
605
|
+
deliberate deny-all intermediate step.
|
|
606
|
+
|
|
607
|
+
### The `policy()` builder
|
|
608
|
+
|
|
609
|
+
`policy(name)` is a fluent builder, just like the column builders:
|
|
401
610
|
|
|
402
611
|
```ts
|
|
403
|
-
import {
|
|
404
|
-
|
|
612
|
+
import { policy } from "@palbase/backend";
|
|
613
|
+
|
|
614
|
+
policy("pb_owner_all")
|
|
615
|
+
.for("all") // "all" | "select" | "insert" | "update" | "delete"
|
|
616
|
+
.to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
|
|
617
|
+
.using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
|
|
618
|
+
.withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
| Method | Default | Meaning |
|
|
622
|
+
|--------|---------|---------|
|
|
623
|
+
| `.for(cmd)` | `"all"` | The SQL command the policy governs. |
|
|
624
|
+
| `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
|
|
625
|
+
| `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
|
|
626
|
+
| `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
|
|
627
|
+
| `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
|
|
405
628
|
|
|
406
|
-
|
|
629
|
+
**`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
|
|
630
|
+
request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
|
|
631
|
+
once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
632
|
+
`auth.jwt()` are also available. With no user on the request (anon/public),
|
|
633
|
+
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
634
|
+
rows.
|
|
407
635
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
636
|
+
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
637
|
+
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
638
|
+
> other modules (storage, cron, …).
|
|
639
|
+
|
|
640
|
+
### Owner-scoped `todos` example
|
|
641
|
+
|
|
642
|
+
```ts
|
|
643
|
+
import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
|
|
644
|
+
|
|
645
|
+
export default defineSchema({
|
|
646
|
+
tables: {
|
|
647
|
+
todos: {
|
|
648
|
+
columns: {
|
|
649
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
650
|
+
owner: text().notNull(), // palauth user id (TEXT)
|
|
651
|
+
title: text().notNull(),
|
|
652
|
+
done: boolean().default(false),
|
|
653
|
+
created_at: timestamp().defaultNow(),
|
|
654
|
+
},
|
|
655
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
656
|
+
policies: [
|
|
657
|
+
// Read: a user sees only their own todos.
|
|
658
|
+
policy("pb_todos_owner_select")
|
|
659
|
+
.for("select")
|
|
660
|
+
.to("authenticated")
|
|
661
|
+
.using("owner = (select auth.uid())"),
|
|
662
|
+
|
|
663
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
664
|
+
policy("pb_todos_owner_write")
|
|
665
|
+
.for("all")
|
|
666
|
+
.to("authenticated")
|
|
667
|
+
.using("owner = (select auth.uid())")
|
|
668
|
+
.withCheck("owner = (select auth.uid())"),
|
|
669
|
+
],
|
|
670
|
+
},
|
|
415
671
|
},
|
|
416
672
|
});
|
|
417
673
|
```
|
|
418
674
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
675
|
+
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
676
|
+
calling user's rows — no `WHERE owner = …` needed in the handler. To read or
|
|
677
|
+
write across all users (e.g. an admin job), use the explicit bypass:
|
|
678
|
+
`Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
|
|
679
|
+
|
|
680
|
+
### How policies are applied
|
|
681
|
+
|
|
682
|
+
On deploy, Palbase diffs your declared schema against the live database and
|
|
683
|
+
applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
|
|
684
|
+
when the table doesn't already have it, and `CREATE POLICY` only for policies
|
|
685
|
+
that don't already exist (keyed by `(table, name)`). These are non-destructive,
|
|
686
|
+
so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
687
|
+
|
|
688
|
+
> Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
|
|
689
|
+
> auto-applied — rename the policy (new `(table, name)`) or drop the old one in
|
|
690
|
+
> a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
<!-- ===== migrations.md ===== -->
|
|
422
696
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
697
|
+
# Migrations
|
|
698
|
+
|
|
699
|
+
`db/schema.ts` is the single source of truth for your Postgres schema. On every
|
|
700
|
+
deploy, Palbase diffs your declared schema against the live branch database and
|
|
701
|
+
reconciles it — but *how* it reconciles depends on whether the change is safe to
|
|
702
|
+
apply automatically.
|
|
703
|
+
|
|
704
|
+
## Two kinds of change
|
|
705
|
+
|
|
706
|
+
### 1. Additive — auto-applied, no migration file
|
|
707
|
+
|
|
708
|
+
A new table, or a new **nullable** or **defaulted** column, is additive: the
|
|
709
|
+
deploy applies it automatically (`CREATE TABLE` / `ADD COLUMN`) with no manual
|
|
710
|
+
step and no backfill risk. Just edit `db/schema.ts` and deploy.
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
// before
|
|
714
|
+
todos: {
|
|
715
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
716
|
+
title: text().notNull(),
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// after — additive: `notes` (nullable) + `priority` (defaulted) auto-apply on deploy
|
|
720
|
+
todos: {
|
|
721
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
722
|
+
title: text().notNull(),
|
|
723
|
+
notes: text().nullable(),
|
|
724
|
+
priority: text().nullable().default("normal"),
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
> A new **NOT NULL column without a default** is NOT additive-safe on a table
|
|
729
|
+
> that already has rows (there is nothing to put in the existing rows). Make it
|
|
730
|
+
> `.nullable()`, give it a `.default(...)`, or apply it as an explicit migration
|
|
731
|
+
> (add nullable → backfill → set NOT NULL).
|
|
732
|
+
|
|
733
|
+
### 2. Destructive / type-changing — needs an explicit migration
|
|
734
|
+
|
|
735
|
+
Renaming or dropping a column, changing a column's type, or adding a NOT NULL
|
|
736
|
+
constraint can lose or corrupt existing data — so the deploy's **drift-gate
|
|
737
|
+
blocks them** and the deploy fails until you provide an explicit migration.
|
|
738
|
+
Write the SQL yourself in `db/migrations/`:
|
|
739
|
+
|
|
740
|
+
```
|
|
741
|
+
db/migrations/
|
|
742
|
+
001_user_id_to_text.up.sql
|
|
743
|
+
001_user_id_to_text.down.sql
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
```sql
|
|
747
|
+
-- 001_user_id_to_text.up.sql
|
|
748
|
+
ALTER TABLE todos ALTER COLUMN user_id TYPE text USING user_id::text;
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
```sql
|
|
752
|
+
-- 001_user_id_to_text.down.sql
|
|
753
|
+
ALTER TABLE todos ALTER COLUMN user_id TYPE uuid USING user_id::uuid;
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Migrations are golang-migrate style: numbered `NNN_name.up.sql` / `.down.sql`
|
|
757
|
+
pairs, applied in order and tracked so each runs exactly once (idempotent).
|
|
758
|
+
`db/schema.ts` always describes the **end state**; the migration describes **how
|
|
759
|
+
existing data gets there**. Keep the two in sync — after the migration lands,
|
|
760
|
+
`schema.ts` should already reflect the new column type.
|
|
761
|
+
|
|
762
|
+
## The drift-gate
|
|
763
|
+
|
|
764
|
+
On deploy, Palbase compares `db/schema.ts` to the live database:
|
|
765
|
+
|
|
766
|
+
- **Additive** diffs → auto-applied.
|
|
767
|
+
- **Type-changing / destructive** diffs **with** a matching migration → the
|
|
768
|
+
migration runs.
|
|
769
|
+
- **Type-changing / destructive** diffs **without** a migration → the deploy
|
|
770
|
+
**aborts** and your currently-running version keeps serving.
|
|
771
|
+
|
|
772
|
+
This is deliberate: it stops an accidental column-type change from silently
|
|
773
|
+
dropping production data. A blocked deploy is a prompt to write the migration,
|
|
774
|
+
not a failure to work around.
|
|
775
|
+
|
|
776
|
+
## Local dev: `palbase serve` uses the deployed database
|
|
777
|
+
|
|
778
|
+
`palbase serve` runs your controllers locally but proxies `Database` and
|
|
779
|
+
`ctx.*` to the **deployed** branch — it does **not** spin up a local Postgres or
|
|
780
|
+
apply migrations locally. So when your local `db/schema.ts` or `db/migrations/`
|
|
781
|
+
is ahead of what's deployed, serve prints a note: new tables/columns won't exist
|
|
782
|
+
until you push. Deploy to apply them.
|
|
783
|
+
|
|
784
|
+
## Workflow
|
|
785
|
+
|
|
786
|
+
1. Edit `db/schema.ts`.
|
|
787
|
+
2. **Additive** change? → `git push`. It auto-migrates on deploy.
|
|
788
|
+
3. **Type change / rename / drop?** → add `db/migrations/NNN_*.up.sql` (+
|
|
789
|
+
`.down.sql`), then `git push`. The runner applies it; without it the
|
|
790
|
+
drift-gate blocks the deploy.
|
|
791
|
+
4. `palbase serve` warns locally until the change is deployed.
|
|
792
|
+
|
|
793
|
+
See [schema.md](./schema.md) for the column builders and typed
|
|
794
|
+
`Database.tables.*` access.
|
|
426
795
|
|
|
427
796
|
|
|
428
797
|
|
|
@@ -430,9 +799,9 @@ untyped transaction with typed tables.
|
|
|
430
799
|
|
|
431
800
|
# Services
|
|
432
801
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
`ctx.
|
|
802
|
+
Import service singletons from `@palbase/backend` in every handler type —
|
|
803
|
+
endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
|
|
804
|
+
**middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
|
|
436
805
|
|
|
437
806
|
Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
|
|
438
807
|
`Log`, `Notifications`, `Flags`.
|
|
@@ -520,18 +889,120 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
|
|
|
520
889
|
## Flags
|
|
521
890
|
|
|
522
891
|
```ts
|
|
523
|
-
import {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
892
|
+
import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
|
|
893
|
+
import type { UserT } from "@palbase/backend";
|
|
894
|
+
|
|
895
|
+
const FlagsOut = z.object({ enabled: z.boolean() });
|
|
896
|
+
|
|
897
|
+
@Controller("/checkout")
|
|
898
|
+
export default class CheckoutController {
|
|
899
|
+
@Get("/flags") // auth omitted → required → user is non-null
|
|
900
|
+
@Returns(FlagsOut)
|
|
901
|
+
async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
|
|
902
|
+
const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
|
|
903
|
+
const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
|
|
532
904
|
return { enabled: enabled ?? false };
|
|
533
|
-
}
|
|
534
|
-
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
<!-- ===== resources.md ===== -->
|
|
912
|
+
|
|
913
|
+
# Resources
|
|
914
|
+
|
|
915
|
+
A `Resource` models one external connection — a pooled datastore, a stateless
|
|
916
|
+
API client, or a per-user factory. You put it in `resources/`, export an
|
|
917
|
+
instance, and **do not register it**: the framework discovers it, sets it up
|
|
918
|
+
once at boot, and drains it on shutdown. On top of that lifecycle you expose
|
|
919
|
+
your own clean facade.
|
|
920
|
+
|
|
921
|
+
```ts
|
|
922
|
+
import { Resource } from "@palbase/backend";
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
## Lifecycle (boot scope — not per request)
|
|
926
|
+
|
|
927
|
+
A resource is created once at process boot — NOT per request. The framework:
|
|
928
|
+
|
|
929
|
+
1. calls `init(env)` **once**, with only the secrets the resource declared;
|
|
930
|
+
2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
|
|
931
|
+
|
|
932
|
+
The instance lives for the whole process; your facade methods are called
|
|
933
|
+
per-request. This makes "reconnect on every request" structurally impossible.
|
|
934
|
+
|
|
935
|
+
## Pooled datastore — `init` + `shutdown`
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
import { Resource } from "@palbase/backend";
|
|
939
|
+
import neo4j, { type Driver, type Session } from "neo4j-driver";
|
|
940
|
+
|
|
941
|
+
export class Neo4jResource extends Resource {
|
|
942
|
+
static secrets = ["NEO4J_URL", "NEO4J_USER", "NEO4J_PASSWORD"] as const;
|
|
943
|
+
private driver!: Driver;
|
|
944
|
+
async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
|
|
945
|
+
this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
|
|
946
|
+
}
|
|
947
|
+
async shutdown() {
|
|
948
|
+
await this.driver.close();
|
|
949
|
+
}
|
|
950
|
+
session(): Session {
|
|
951
|
+
return this.driver.session();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export const graph = new Neo4jResource();
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
## Stateless API client — `init` only
|
|
959
|
+
|
|
960
|
+
```ts
|
|
961
|
+
import { Resource } from "@palbase/backend";
|
|
962
|
+
import { Client } from "@googlemaps/google-maps-services-js";
|
|
963
|
+
|
|
964
|
+
export class GoogleResource extends Resource {
|
|
965
|
+
static secrets = ["GOOGLE_MAPS_KEY"] as const;
|
|
966
|
+
private client = new Client();
|
|
967
|
+
private key = "";
|
|
968
|
+
init(env: { GOOGLE_MAPS_KEY: string }) {
|
|
969
|
+
this.key = env.GOOGLE_MAPS_KEY;
|
|
970
|
+
}
|
|
971
|
+
nearby(lat: number, lng: number) {
|
|
972
|
+
return this.client.placesNearby({ params: { location: { lat, lng }, radius: 1500, key: this.key } });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export const google = new GoogleResource();
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
A per-user (OAuth) resource adds a factory method on the base, e.g.
|
|
980
|
+
`github.forUser(token)` — the same single model covers pooled, stateless, and
|
|
981
|
+
per-user.
|
|
982
|
+
|
|
983
|
+
## Secrets
|
|
984
|
+
|
|
985
|
+
`static secrets` is the contract:
|
|
986
|
+
|
|
987
|
+
- It **types** the `env` passed to `init` — only the declared names are
|
|
988
|
+
present, each a `string`. An undeclared key is a compile error.
|
|
989
|
+
- A declared secret that is **missing at boot fails the deploy**, naming the
|
|
990
|
+
secret. Secrets are branch-scoped; set them with `palbase secret set NAME ...`
|
|
991
|
+
or in Studio. A resource is initialised once at boot, so rotating a secret
|
|
992
|
+
needs a redeploy/restart.
|
|
993
|
+
|
|
994
|
+
`secrets` is optional — a resource that needs none simply omits it and gets an
|
|
995
|
+
empty `env`.
|
|
996
|
+
|
|
997
|
+
## Using a resource
|
|
998
|
+
|
|
999
|
+
Import the singleton and call your facade — services and handlers reach
|
|
1000
|
+
resources the same way they reach `Database`:
|
|
1001
|
+
|
|
1002
|
+
```ts
|
|
1003
|
+
import { google } from "../resources/google.js";
|
|
1004
|
+
|
|
1005
|
+
const results = (await google.nearby(41.0, 29.0)).data.results;
|
|
535
1006
|
```
|
|
536
1007
|
|
|
537
1008
|
|
|
@@ -540,52 +1011,58 @@ export default defineEndpoint({
|
|
|
540
1011
|
|
|
541
1012
|
# Errors
|
|
542
1013
|
|
|
543
|
-
|
|
544
|
-
envelope:
|
|
1014
|
+
Throw an error class to fail a request. Every one serializes to the standard
|
|
1015
|
+
Palbase error envelope:
|
|
545
1016
|
|
|
546
1017
|
```json
|
|
547
1018
|
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
548
1019
|
```
|
|
549
1020
|
|
|
550
|
-
|
|
1021
|
+
Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
|
|
1022
|
+
per-route error map: the runtime catches any thrown error class and emits the
|
|
1023
|
+
envelope.
|
|
1024
|
+
|
|
1025
|
+
## Named status classes
|
|
551
1026
|
|
|
552
1027
|
```ts
|
|
553
|
-
import {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
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
|
|
557
1036
|
```
|
|
558
1037
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
## 2. Declared errors (typed)
|
|
1038
|
+
Each class fixes its HTTP status. The constructor is
|
|
1039
|
+
`new <Class>(message?, code?, data?)`:
|
|
562
1040
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1041
|
+
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
1042
|
+
derived from the class name).
|
|
1043
|
+
- `code` overrides the wire `error` code (defaults to the class's snake_case
|
|
1044
|
+
code, e.g. `not_found`).
|
|
1045
|
+
- `data` rides along under the envelope's `data` field for structured context.
|
|
566
1046
|
|
|
567
1047
|
```ts
|
|
568
|
-
|
|
1048
|
+
throw new NotFound("Room does not exist", "room_not_found");
|
|
1049
|
+
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
|
1050
|
+
```
|
|
569
1051
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const todo = await Database.findById("todos", req.input.id);
|
|
580
|
-
if (!todo) throw req.errors.notFound(); // no data → no args
|
|
581
|
-
if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
|
|
582
|
-
return { ok: true };
|
|
583
|
-
},
|
|
584
|
-
});
|
|
1052
|
+
## `PalError` / `HttpError` (custom status)
|
|
1053
|
+
|
|
1054
|
+
For a status/code not covered by a named class:
|
|
1055
|
+
|
|
1056
|
+
```ts
|
|
1057
|
+
import { PalError } from "@palbase/backend";
|
|
1058
|
+
throw new PalError(418, "teapot", "I'm a teapot");
|
|
1059
|
+
// optional structured payload (4th arg) rides along under `data`:
|
|
1060
|
+
throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
585
1061
|
```
|
|
586
1062
|
|
|
587
|
-
|
|
588
|
-
|
|
1063
|
+
`PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
|
|
1064
|
+
The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
|
|
1065
|
+
HttpError) … }` matches any of them.
|
|
589
1066
|
|
|
590
1067
|
|
|
591
1068
|
|
|
@@ -593,10 +1070,10 @@ one without `data` takes no arguments. This is enforced by the types.
|
|
|
593
1070
|
|
|
594
1071
|
# Workers & Jobs
|
|
595
1072
|
|
|
596
|
-
Workers and jobs use the
|
|
597
|
-
|
|
598
|
-
**not** receive a `req
|
|
599
|
-
|
|
1073
|
+
Workers and jobs use the **singleton model** — the same imported service
|
|
1074
|
+
singletons as endpoints (`import { Database, Log } from "@palbase/backend"`).
|
|
1075
|
+
They do **not** receive a `req`. Instead, a small `meta` argument carries the
|
|
1076
|
+
non-service data (`env`, `user`, correlation ids).
|
|
600
1077
|
|
|
601
1078
|
## Workers (queue consumers)
|
|
602
1079
|
|
|
@@ -605,7 +1082,7 @@ A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
|
|
|
605
1082
|
|
|
606
1083
|
```ts
|
|
607
1084
|
// workers/process-order.ts
|
|
608
|
-
import { defineWorker } from "@palbase/backend";
|
|
1085
|
+
import { defineWorker, Database, Log } from "@palbase/backend";
|
|
609
1086
|
|
|
610
1087
|
interface OrderPayload { orderId: string; amount: number; }
|
|
611
1088
|
|
|
@@ -614,13 +1091,16 @@ export default defineWorker<OrderPayload>({
|
|
|
614
1091
|
retry: 5, // optional, default 3
|
|
615
1092
|
timeout: 60, // optional, seconds
|
|
616
1093
|
backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
|
|
617
|
-
handler: async (
|
|
618
|
-
|
|
619
|
-
await
|
|
1094
|
+
handler: async (payload, meta) => {
|
|
1095
|
+
Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
|
|
1096
|
+
await Database.update("orders", payload.orderId, { status: "processed" });
|
|
620
1097
|
},
|
|
621
1098
|
});
|
|
622
1099
|
```
|
|
623
1100
|
|
|
1101
|
+
`meta` shape: `{ env, user, requestId, projectId, environmentId }`. Branch env
|
|
1102
|
+
vars are in `meta.env`; services come from the imported singletons.
|
|
1103
|
+
|
|
624
1104
|
Enqueue from an endpoint:
|
|
625
1105
|
|
|
626
1106
|
```ts
|
|
@@ -634,29 +1114,34 @@ A job runs on a cron schedule. File lives under `jobs/`.
|
|
|
634
1114
|
|
|
635
1115
|
```ts
|
|
636
1116
|
// jobs/cleanup.ts
|
|
637
|
-
import { defineJob } from "@palbase/backend";
|
|
1117
|
+
import { defineJob, Database, Log } from "@palbase/backend";
|
|
638
1118
|
|
|
639
1119
|
export default defineJob({
|
|
640
1120
|
name: "cleanup-expired",
|
|
641
1121
|
schedule: "0 3 * * *", // standard cron
|
|
642
1122
|
timeout: 120, // optional, seconds
|
|
643
|
-
handler: async (
|
|
644
|
-
const expired = await
|
|
645
|
-
for (const s of expired) await
|
|
646
|
-
|
|
1123
|
+
handler: async (meta) => {
|
|
1124
|
+
const expired = await Database.findMany("sessions", { expired: true });
|
|
1125
|
+
for (const s of expired) await Database.delete("sessions", s.id as string);
|
|
1126
|
+
Log.info(`cleaned ${expired.length} sessions in ${meta.projectId}`);
|
|
647
1127
|
},
|
|
648
1128
|
});
|
|
649
1129
|
```
|
|
650
1130
|
|
|
1131
|
+
`meta` shape: `{ env, projectId, environmentId }`. No `user` (jobs are
|
|
1132
|
+
system-initiated).
|
|
1133
|
+
|
|
651
1134
|
|
|
652
1135
|
|
|
653
1136
|
<!-- ===== events.md ===== -->
|
|
654
1137
|
|
|
655
1138
|
# Hooks & Webhooks
|
|
656
1139
|
|
|
657
|
-
Like workers/jobs, hooks and webhooks use the
|
|
658
|
-
|
|
659
|
-
|
|
1140
|
+
Like workers/jobs, hooks and webhooks use the **singleton model** — the same
|
|
1141
|
+
imported service singletons as endpoints (`import { Database, Log } from
|
|
1142
|
+
"@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
|
|
1143
|
+
carries the non-service data (`env`, `projectId`, `environmentId`; webhooks also
|
|
1144
|
+
get `requestId`).
|
|
660
1145
|
|
|
661
1146
|
## Hooks (platform events)
|
|
662
1147
|
|
|
@@ -665,21 +1150,24 @@ are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
|
|
|
665
1150
|
|
|
666
1151
|
```ts
|
|
667
1152
|
// hooks/auth.ts
|
|
668
|
-
import { auth } from "@palbase/backend";
|
|
1153
|
+
import { auth, Database, Log } from "@palbase/backend";
|
|
669
1154
|
|
|
670
|
-
export const onUserCreated = auth.onUserCreated(async (
|
|
671
|
-
|
|
672
|
-
await
|
|
1155
|
+
export const onUserCreated = auth.onUserCreated(async (event, meta) => {
|
|
1156
|
+
Log.info(`new user: ${event.user.email}`);
|
|
1157
|
+
await Database.insert("profiles", {
|
|
673
1158
|
user_id: event.user.id,
|
|
674
1159
|
email: event.user.email,
|
|
675
1160
|
});
|
|
676
1161
|
});
|
|
677
1162
|
|
|
678
|
-
export const onSignIn = auth.onSignIn(async (
|
|
679
|
-
|
|
1163
|
+
export const onSignIn = auth.onSignIn(async (event, meta) => {
|
|
1164
|
+
Log.info(`sign in: ${event.user.email} via ${event.provider}`);
|
|
680
1165
|
});
|
|
681
1166
|
```
|
|
682
1167
|
|
|
1168
|
+
`meta` shape: `{ env, projectId, environmentId }`. Branch env vars are in
|
|
1169
|
+
`meta.env`; services come from the imported singletons.
|
|
1170
|
+
|
|
683
1171
|
Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
|
|
684
1172
|
`auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
|
|
685
1173
|
`documents.onDocumentCreated`, `documents.onDocumentUpdated`,
|
|
@@ -692,22 +1180,25 @@ Receive and verify webhooks from third-party providers. Files live under
|
|
|
692
1180
|
|
|
693
1181
|
```ts
|
|
694
1182
|
// webhooks/stripe.ts
|
|
695
|
-
import { defineWebhook } from "@palbase/backend";
|
|
1183
|
+
import { defineWebhook, Database, Log } from "@palbase/backend";
|
|
696
1184
|
|
|
697
1185
|
export default defineWebhook({
|
|
698
1186
|
provider: "stripe",
|
|
699
1187
|
secret: { env: "STRIPE_WEBHOOK_SECRET" }, // signing secret resolved from env
|
|
700
1188
|
events: {
|
|
701
|
-
"checkout.session.completed": async (
|
|
702
|
-
await
|
|
1189
|
+
"checkout.session.completed": async (event, meta) => {
|
|
1190
|
+
await Database.insert("orders", { status: "paid", data: event });
|
|
703
1191
|
},
|
|
704
|
-
"payment_intent.payment_failed": async (
|
|
705
|
-
|
|
706
|
-
await
|
|
1192
|
+
"payment_intent.payment_failed": async (event, meta) => {
|
|
1193
|
+
Log.error("payment failed");
|
|
1194
|
+
await Database.insert("payment_failures", { data: event });
|
|
707
1195
|
},
|
|
708
1196
|
},
|
|
709
1197
|
});
|
|
710
1198
|
```
|
|
711
1199
|
|
|
712
|
-
The signing secret is
|
|
713
|
-
|
|
1200
|
+
The signing secret is resolved by the runtime from `secret: { env: "NAME" }`;
|
|
1201
|
+
your handlers access branch env vars via `meta.env`. The runtime verifies the
|
|
1202
|
+
signature before dispatching to your event handlers.
|
|
1203
|
+
|
|
1204
|
+
`meta` shape: `{ env, requestId, projectId, environmentId }`.
|