@palbase/backend 2.0.2 → 3.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-4J3F32SH.js → chunk-B7EUJP5W.js} +38 -9
- package/dist/chunk-B7EUJP5W.js.map +1 -0
- package/dist/{chunk-L36JLUPO.js → chunk-PHAFZGHN.js} +43 -46
- package/dist/chunk-PHAFZGHN.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 +28 -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 +3 -233
- package/dist/db/index.js.map +1 -1
- package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-DJ98tQd6.d.cts} +30 -68
- package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-DJ98tQd6.d.ts} +30 -68
- package/dist/index-CXUs9iTQ.d.ts +294 -0
- package/dist/index-CZAwpQE1.d.cts +294 -0
- package/dist/index.cjs +229 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +398 -154
- package/dist/index.d.ts +398 -154
- package/dist/index.js +147 -12
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +41 -1
- 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 +1 -1
- package/docs/README.md +31 -10
- package/docs/background.md +19 -13
- package/docs/database.md +30 -17
- package/docs/endpoints.md +42 -24
- package/docs/errors.md +3 -4
- package/docs/events.md +25 -17
- package/docs/getting-started.md +25 -9
- package/docs/llms-full.txt +489 -164
- package/docs/llms.txt +3 -1
- package/docs/migrations.md +98 -0
- package/docs/resources.md +94 -0
- package/docs/routing.md +59 -26
- package/docs/schema.md +48 -38
- package/docs/services.md +5 -6
- package/package.json +11 -1
- package/dist/chunk-4J3F32SH.js.map +0 -1
- 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/` + `handlers/`) | `(req)` | `req` — [PBRequest](./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/ # route maps: method+path → handler (mounts the API)
|
|
47
|
+
│ └── hello.controller.ts # defineController("/hello", { … })
|
|
48
|
+
├── handlers/ # one endpoint unit each (schema + thin logic)
|
|
49
|
+
│ └── hello.ts # defineHandler({ auth, input, output, handler })
|
|
50
|
+
├── services/ # plain classes/singletons your handlers 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,6 +57,10 @@ 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 `handler` (one
|
|
61
|
+
endpoint: `auth`/`input`/`output`/`errors` + logic, no route) and mount it in a
|
|
62
|
+
`controller` (a route map). See [routing.md](./routing.md).
|
|
63
|
+
|
|
44
64
|
## Documentation
|
|
45
65
|
|
|
46
66
|
| Topic | File |
|
|
@@ -50,6 +70,7 @@ my-backend/
|
|
|
50
70
|
| Endpoints & `req` | [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,19 +107,24 @@ 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
|
+
A handler is one endpoint unit; a controller maps method+path to it. Create
|
|
122
|
+
`handlers/hello.ts`:
|
|
97
123
|
|
|
98
124
|
```ts
|
|
99
|
-
import {
|
|
125
|
+
import { defineHandler, z } from "@palbase/backend";
|
|
100
126
|
|
|
101
|
-
export default
|
|
102
|
-
method: "GET",
|
|
127
|
+
export default defineHandler({
|
|
103
128
|
input: z.object({ name: z.string().optional() }),
|
|
104
129
|
output: z.object({ message: z.string(), user: z.string().nullable() }),
|
|
105
130
|
handler: async (req) => ({
|
|
@@ -109,64 +134,110 @@ export default defineEndpoint({
|
|
|
109
134
|
});
|
|
110
135
|
```
|
|
111
136
|
|
|
137
|
+
Then mount it in `controllers/hello.controller.ts`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { defineController, route } from "@palbase/backend";
|
|
141
|
+
import hello from "../handlers/hello.js";
|
|
142
|
+
|
|
143
|
+
export default defineController("/hello", {
|
|
144
|
+
hello: route.get("/", hello),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
112
148
|
This is served at `GET /hello`. The Zod `input` schema validates the request and
|
|
113
|
-
flows into `req.input`; the `output` schema validates your return value.
|
|
149
|
+
flows into `req.input`; the `output` schema validates your return value. See
|
|
150
|
+
[routing.md](./routing.md) for the handler + controller model.
|
|
114
151
|
|
|
115
152
|
|
|
116
153
|
|
|
117
154
|
<!-- ===== routing.md ===== -->
|
|
118
155
|
|
|
119
|
-
#
|
|
156
|
+
# Routing
|
|
120
157
|
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
Routes are declared in code. A **handler** is one endpoint unit (schema + thin
|
|
159
|
+
logic) with NO route on it; a **controller** is a route map (method+path →
|
|
160
|
+
handler). Putting a controller file under `controllers/` mounts it — there is no
|
|
161
|
+
central router and no manual registration.
|
|
123
162
|
|
|
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` |
|
|
163
|
+
```ts
|
|
164
|
+
import { defineController, route } from "@palbase/backend";
|
|
165
|
+
```
|
|
131
166
|
|
|
132
|
-
|
|
167
|
+
## Handlers — one endpoint per file, no route
|
|
133
168
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
- Each method file `export default defineEndpoint({...})` — one endpoint per file.
|
|
137
|
-
- There is no central router file and no manual registration. Adding a file adds a route.
|
|
169
|
+
A handler declares everything that types `req` (`auth`/`input`/`output`/
|
|
170
|
+
`errors`) and the logic; it has no method or path.
|
|
138
171
|
|
|
139
172
|
```ts
|
|
140
|
-
//
|
|
141
|
-
import {
|
|
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
|
+
```
|
|
142
185
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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).
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// controllers/places.controller.ts
|
|
193
|
+
import { defineController, route } from "@palbase/backend";
|
|
194
|
+
import importNearby from "../handlers/places/import-nearby.js";
|
|
195
|
+
import addFavorite from "../handlers/places/add-favorite.js";
|
|
196
|
+
import listFavorites from "../handlers/places/list-favorites.js";
|
|
197
|
+
|
|
198
|
+
export default defineController("/places", {
|
|
199
|
+
importNearby: route.post("/import", importNearby),
|
|
200
|
+
addFavorite: route.post("/favorites", addFavorite),
|
|
201
|
+
listFavorites: route.get ("/favorites", listFavorites),
|
|
151
202
|
});
|
|
152
203
|
```
|
|
153
204
|
|
|
205
|
+
| Map key (sugar) | Method | Full path | operationId (flat) |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| `importNearby` | POST | `/places/import` | `postPlacesImport` |
|
|
208
|
+
| `addFavorite` | POST | `/places/favorites` | `postPlacesFavorites` |
|
|
209
|
+
| `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
|
|
210
|
+
|
|
211
|
+
Rules:
|
|
212
|
+
|
|
213
|
+
- The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
|
|
214
|
+
- A `{segment}` in a path becomes a route param, read via `req.params.segment`.
|
|
215
|
+
- Each route value MUST be a `route.*(...)` result — a bare handler is a
|
|
216
|
+
compile error, which keeps logic out of controllers.
|
|
217
|
+
- The operationId is derived FLAT from method + full path (`postPlacesImport`),
|
|
218
|
+
not from the map key. Change a verb with `route.post` → `route.put` — no file
|
|
219
|
+
rename.
|
|
220
|
+
|
|
221
|
+
See [endpoints.md](./endpoints.md) for the full `defineHandler` config (`req`,
|
|
222
|
+
auth, input/output, errors, middleware) reference.
|
|
223
|
+
|
|
154
224
|
|
|
155
225
|
|
|
156
226
|
<!-- ===== endpoints.md ===== -->
|
|
157
227
|
|
|
158
228
|
# Endpoints
|
|
159
229
|
|
|
160
|
-
An endpoint is `export default
|
|
161
|
-
|
|
162
|
-
|
|
230
|
+
An endpoint is a **handler** — `export default defineHandler({...})` in a file
|
|
231
|
+
under `handlers/`. A handler has NO method or path; a **controller** maps
|
|
232
|
+
method+path to it (see [routing.md](./routing.md)). The handler receives **one
|
|
233
|
+
argument**, `req` (a `PBRequest`). Services are NOT on `req` — import them as
|
|
234
|
+
singletons (see [services.md](./services.md)).
|
|
163
235
|
|
|
164
|
-
## `
|
|
236
|
+
## `defineHandler` config
|
|
165
237
|
|
|
166
238
|
```ts
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
auth: { required: true }, // optional; see Auth below. Omitted → public.
|
|
239
|
+
defineHandler({
|
|
240
|
+
auth: { required: true }, // see Auth below. Omitted → AUTH REQUIRED (secure-by-default).
|
|
170
241
|
rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
|
|
171
242
|
input: z.object({ ... }), // optional Zod schema → validates & types req.input
|
|
172
243
|
output: z.object({ ... }), // optional Zod schema → validates the return value
|
|
@@ -176,6 +247,9 @@ defineEndpoint({
|
|
|
176
247
|
});
|
|
177
248
|
```
|
|
178
249
|
|
|
250
|
+
There is no `method` field — the HTTP verb (and path) live in the controller
|
|
251
|
+
that mounts the handler via `route.get|post|put|patch|delete`.
|
|
252
|
+
|
|
179
253
|
## `req` (PBRequest)
|
|
180
254
|
|
|
181
255
|
| Field | Type | Notes |
|
|
@@ -191,40 +265,43 @@ defineEndpoint({
|
|
|
191
265
|
| `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
|
|
192
266
|
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
193
267
|
|
|
194
|
-
`User` is `{ id: string; email
|
|
268
|
+
`User` is `{ id: string; email?: string; role: string; metadata: Record<string, unknown> }`.
|
|
195
269
|
|
|
196
270
|
## Auth
|
|
197
271
|
|
|
272
|
+
**Secure by default:** a handler requires authentication UNLESS it explicitly
|
|
273
|
+
opts out. Omitting `auth` means AUTH REQUIRED — a forgotten `auth` fails safe
|
|
274
|
+
(401), never silently public. Mark a route PUBLIC with `auth: { required: false }`.
|
|
275
|
+
|
|
198
276
|
```ts
|
|
199
|
-
auth
|
|
277
|
+
// auth omitted entirely // AUTH REQUIRED → req.user is non-null User
|
|
278
|
+
auth: { required: true } // same: require any authenticated user
|
|
200
279
|
auth: { required: true, role: "admin" } // require a specific role
|
|
201
|
-
auth: { required: false } //
|
|
202
|
-
// auth omitted entirely // also public → req.user is User | null
|
|
280
|
+
auth: { required: false } // PUBLIC → req.user may be null (anon key only)
|
|
203
281
|
```
|
|
204
282
|
|
|
205
283
|
Whether `req.user` is non-null is computed from the `auth` config at the type
|
|
206
|
-
level:
|
|
284
|
+
level (and matches the runtime exactly):
|
|
207
285
|
|
|
208
286
|
| `auth` value | `req.user` type |
|
|
209
287
|
|--------------|-----------------|
|
|
210
|
-
| omitted | `User
|
|
288
|
+
| omitted | `User` (secure-by-default) |
|
|
211
289
|
| `true` | `User` |
|
|
212
|
-
| `false` | `User \| null` |
|
|
290
|
+
| `false` | `User \| null` (public) |
|
|
213
291
|
| `{ required: true }` | `User` |
|
|
214
|
-
| `{ required: false }` | `User \| null` |
|
|
292
|
+
| `{ required: false }` | `User \| null` (public) |
|
|
215
293
|
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
216
294
|
|
|
217
|
-
To
|
|
295
|
+
To make a route PUBLIC, set `auth: { required: false }` (or `auth: false`). An
|
|
218
296
|
object with a `role` but no `required` key is treated as authenticated. When
|
|
219
297
|
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
220
298
|
|
|
221
299
|
## Typed input/output
|
|
222
300
|
|
|
223
301
|
```ts
|
|
224
|
-
import {
|
|
302
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
225
303
|
|
|
226
|
-
export default
|
|
227
|
-
method: "POST",
|
|
304
|
+
export default defineHandler({
|
|
228
305
|
auth: { required: true },
|
|
229
306
|
input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
|
|
230
307
|
output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
|
|
@@ -238,6 +315,18 @@ export default defineEndpoint({
|
|
|
238
315
|
});
|
|
239
316
|
```
|
|
240
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";
|
|
324
|
+
|
|
325
|
+
export default defineController("/rooms", {
|
|
326
|
+
create: route.post("/", create),
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
241
330
|
## Middleware
|
|
242
331
|
|
|
243
332
|
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
@@ -257,15 +346,14 @@ The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
|
257
346
|
rest of the chain (other middleware, then the endpoint handler). Note this uses
|
|
258
347
|
the `ctx` model, not `req`.
|
|
259
348
|
|
|
260
|
-
To attach middleware to a specific
|
|
261
|
-
|
|
349
|
+
To attach middleware to a specific handler, import it and list it in the
|
|
350
|
+
handler's `middleware` array:
|
|
262
351
|
|
|
263
352
|
```ts
|
|
264
|
-
import {
|
|
353
|
+
import { defineHandler, z } from "@palbase/backend";
|
|
265
354
|
import logger from "../../middleware/logger.js";
|
|
266
355
|
|
|
267
|
-
export default
|
|
268
|
-
method: "GET",
|
|
356
|
+
export default defineHandler({
|
|
269
357
|
middleware: [logger],
|
|
270
358
|
output: z.object({ ok: z.boolean() }),
|
|
271
359
|
handler: async (req) => ({ ok: true }),
|
|
@@ -278,17 +366,38 @@ export default defineEndpoint({
|
|
|
278
366
|
|
|
279
367
|
# Database
|
|
280
368
|
|
|
281
|
-
|
|
369
|
+
Import the `Database` singleton in every handler type — endpoints, workers,
|
|
370
|
+
jobs, hooks, and webhooks all use the same import:
|
|
282
371
|
|
|
283
372
|
```ts
|
|
284
373
|
import { Database } from "@palbase/backend";
|
|
285
374
|
```
|
|
286
375
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
376
|
+
Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
|
|
377
|
+
and [events.md](./events.md) for worker/job/hook/webhook examples).
|
|
378
|
+
|
|
379
|
+
## Typed by default — `Database.tables`
|
|
380
|
+
|
|
381
|
+
When you declare `db/schema.ts`, `Database.tables.<name>` is typed everywhere
|
|
382
|
+
with no import and no generic. `insert` demands the right columns; rows come
|
|
383
|
+
back typed; nullable columns are `T | null`. This is the path you should use:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
const todo = await Database.tables.todos.insert({ title: "buy milk" });
|
|
387
|
+
todo.id; // string ✓
|
|
388
|
+
todo.done; // boolean ✓
|
|
389
|
+
const open = await Database.tables.todos.findMany({ done: false });
|
|
390
|
+
await Database.tables.todos.update(todo.id, { done: true });
|
|
391
|
+
await Database.tables.todos.delete(todo.id);
|
|
392
|
+
// todo.nope ← compile error
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
See [schema.md](./schema.md) for the full typed-table surface.
|
|
290
396
|
|
|
291
|
-
##
|
|
397
|
+
## Raw string-keyed operations
|
|
398
|
+
|
|
399
|
+
For dynamic table names or read-only SQL, the string-keyed ops are still
|
|
400
|
+
available:
|
|
292
401
|
|
|
293
402
|
| Method | Returns |
|
|
294
403
|
|--------|---------|
|
|
@@ -300,14 +409,6 @@ reached via the context object (see [background.md](./background.md) and
|
|
|
300
409
|
| `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
|
|
301
410
|
| `Database.transaction(fn)` | runs `fn(tx)` in a transaction |
|
|
302
411
|
|
|
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
412
|
`findMany`'s `query` is an equality filter: keys are ANDed together. For
|
|
312
413
|
anything richer (ranges, ordering, joins) use `Database.query`.
|
|
313
414
|
|
|
@@ -328,14 +429,14 @@ transaction). Returning commits; throwing rolls back.
|
|
|
328
429
|
|
|
329
430
|
```ts
|
|
330
431
|
await Database.transaction(async (tx) => {
|
|
331
|
-
const order = await tx.insert(
|
|
332
|
-
await tx.insert(
|
|
432
|
+
const order = await tx.tables.orders.insert({ amount: 1000, status: "pending" });
|
|
433
|
+
await tx.tables.order_items.insert({ order_id: order.id, sku: "ABC" });
|
|
333
434
|
// throw here → both inserts roll back
|
|
334
435
|
});
|
|
335
436
|
```
|
|
336
437
|
|
|
337
|
-
|
|
338
|
-
[schema.md](./schema.md).
|
|
438
|
+
The `tx` carries the same typed `tx.tables.<name>` API as `Database.tables`
|
|
439
|
+
(no nested transaction). See [schema.md](./schema.md) for the full surface.
|
|
339
440
|
|
|
340
441
|
|
|
341
442
|
|
|
@@ -344,36 +445,43 @@ For a typed `.tables.*` API instead of string table names, see
|
|
|
344
445
|
# Schema & typed database access
|
|
345
446
|
|
|
346
447
|
Declare your tables in `db/schema.ts` with `defineSchema`. This drives
|
|
347
|
-
migrations
|
|
448
|
+
[migrations](./migrations.md) (additive changes auto-apply on deploy; type
|
|
449
|
+
changes need an explicit migration) and makes `Database.tables.*` typed
|
|
450
|
+
everywhere — by default, with no import and no generic.
|
|
348
451
|
|
|
349
452
|
## Defining a schema
|
|
350
453
|
|
|
454
|
+
The table NAME comes from the object key under `tables` — there is one
|
|
455
|
+
canonical form.
|
|
456
|
+
|
|
351
457
|
```ts
|
|
352
458
|
import {
|
|
353
|
-
defineSchema,
|
|
459
|
+
defineSchema,
|
|
354
460
|
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
355
461
|
} from "@palbase/backend";
|
|
356
462
|
|
|
357
463
|
export default defineSchema({
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
464
|
+
tables: {
|
|
465
|
+
rooms: {
|
|
466
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
467
|
+
name: text().notNull(),
|
|
468
|
+
capacity: integer().nullable(),
|
|
469
|
+
is_active: boolean().default(true),
|
|
470
|
+
created_at: timestamp().defaultNow(),
|
|
471
|
+
},
|
|
472
|
+
sessions: {
|
|
473
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
474
|
+
room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
|
|
475
|
+
user_id: uuid().notNull(),
|
|
476
|
+
data: jsonb().nullable(),
|
|
477
|
+
started_at: timestamp().defaultNow(),
|
|
478
|
+
},
|
|
479
|
+
orders: {
|
|
480
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
481
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
482
|
+
amount: integer().notNull(),
|
|
483
|
+
},
|
|
484
|
+
},
|
|
377
485
|
});
|
|
378
486
|
```
|
|
379
487
|
|
|
@@ -394,35 +502,141 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
|
394
502
|
`.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
|
|
395
503
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`.
|
|
396
504
|
|
|
397
|
-
## Typed DB access
|
|
505
|
+
## Typed DB access — by default
|
|
398
506
|
|
|
399
|
-
|
|
400
|
-
|
|
507
|
+
You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
|
|
508
|
+
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
509
|
+
of the schema, no generic, no cast:
|
|
401
510
|
|
|
402
511
|
```ts
|
|
403
|
-
import {
|
|
404
|
-
import schema from "../../db/schema.js";
|
|
512
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
405
513
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
export default defineEndpoint({
|
|
409
|
-
method: "POST",
|
|
514
|
+
export default defineHandler({
|
|
410
515
|
input: z.object({ name: z.string() }),
|
|
411
516
|
output: z.object({ id: z.string(), name: z.string() }),
|
|
412
517
|
handler: async (req) => {
|
|
413
|
-
const room = await
|
|
414
|
-
return { id: room.id, name: room.name };
|
|
518
|
+
const room = await Database.tables.rooms.insert({ name: req.input.name });
|
|
519
|
+
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
520
|
+
// room.nope ← compile error
|
|
415
521
|
},
|
|
416
522
|
});
|
|
417
523
|
```
|
|
418
524
|
|
|
419
|
-
|
|
420
|
-
`
|
|
421
|
-
|
|
525
|
+
`Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
|
|
526
|
+
`findById(id)`, `findMany(query?)`, and `Database.transaction(fn)` yields a `tx`
|
|
527
|
+
with the same typed tables. The raw string-keyed ops
|
|
528
|
+
(`Database.insert("rooms", …)`, `Database.query(…)`) are still available for
|
|
529
|
+
dynamic table names and read-only SQL.
|
|
530
|
+
|
|
531
|
+
If you want a row type explicitly, import it from the generated env module:
|
|
532
|
+
|
|
533
|
+
```ts
|
|
534
|
+
import type { Tables } from "@palbase/backend/env";
|
|
535
|
+
type Room = Tables["rooms"]["row"];
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
<!-- ===== migrations.md ===== -->
|
|
541
|
+
|
|
542
|
+
# Migrations
|
|
543
|
+
|
|
544
|
+
`db/schema.ts` is the single source of truth for your Postgres schema. On every
|
|
545
|
+
deploy, Palbase diffs your declared schema against the live branch database and
|
|
546
|
+
reconciles it — but *how* it reconciles depends on whether the change is safe to
|
|
547
|
+
apply automatically.
|
|
548
|
+
|
|
549
|
+
## Two kinds of change
|
|
550
|
+
|
|
551
|
+
### 1. Additive — auto-applied, no migration file
|
|
552
|
+
|
|
553
|
+
A new table, or a new **nullable** or **defaulted** column, is additive: the
|
|
554
|
+
deploy applies it automatically (`CREATE TABLE` / `ADD COLUMN`) with no manual
|
|
555
|
+
step and no backfill risk. Just edit `db/schema.ts` and deploy.
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
// before
|
|
559
|
+
todos: {
|
|
560
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
561
|
+
title: text().notNull(),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// after — additive: `notes` (nullable) + `priority` (defaulted) auto-apply on deploy
|
|
565
|
+
todos: {
|
|
566
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
567
|
+
title: text().notNull(),
|
|
568
|
+
notes: text().nullable(),
|
|
569
|
+
priority: text().nullable().default("normal"),
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
> A new **NOT NULL column without a default** is NOT additive-safe on a table
|
|
574
|
+
> that already has rows (there is nothing to put in the existing rows). Make it
|
|
575
|
+
> `.nullable()`, give it a `.default(...)`, or apply it as an explicit migration
|
|
576
|
+
> (add nullable → backfill → set NOT NULL).
|
|
577
|
+
|
|
578
|
+
### 2. Destructive / type-changing — needs an explicit migration
|
|
579
|
+
|
|
580
|
+
Renaming or dropping a column, changing a column's type, or adding a NOT NULL
|
|
581
|
+
constraint can lose or corrupt existing data — so the deploy's **drift-gate
|
|
582
|
+
blocks them** and the deploy fails until you provide an explicit migration.
|
|
583
|
+
Write the SQL yourself in `db/migrations/`:
|
|
584
|
+
|
|
585
|
+
```
|
|
586
|
+
db/migrations/
|
|
587
|
+
001_user_id_to_text.up.sql
|
|
588
|
+
001_user_id_to_text.down.sql
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
```sql
|
|
592
|
+
-- 001_user_id_to_text.up.sql
|
|
593
|
+
ALTER TABLE todos ALTER COLUMN user_id TYPE text USING user_id::text;
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
```sql
|
|
597
|
+
-- 001_user_id_to_text.down.sql
|
|
598
|
+
ALTER TABLE todos ALTER COLUMN user_id TYPE uuid USING user_id::uuid;
|
|
599
|
+
```
|
|
422
600
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
601
|
+
Migrations are golang-migrate style: numbered `NNN_name.up.sql` / `.down.sql`
|
|
602
|
+
pairs, applied in order and tracked so each runs exactly once (idempotent).
|
|
603
|
+
`db/schema.ts` always describes the **end state**; the migration describes **how
|
|
604
|
+
existing data gets there**. Keep the two in sync — after the migration lands,
|
|
605
|
+
`schema.ts` should already reflect the new column type.
|
|
606
|
+
|
|
607
|
+
## The drift-gate
|
|
608
|
+
|
|
609
|
+
On deploy, Palbase compares `db/schema.ts` to the live database:
|
|
610
|
+
|
|
611
|
+
- **Additive** diffs → auto-applied.
|
|
612
|
+
- **Type-changing / destructive** diffs **with** a matching migration → the
|
|
613
|
+
migration runs.
|
|
614
|
+
- **Type-changing / destructive** diffs **without** a migration → the deploy
|
|
615
|
+
**aborts** and your currently-running version keeps serving.
|
|
616
|
+
|
|
617
|
+
This is deliberate: it stops an accidental column-type change from silently
|
|
618
|
+
dropping production data. A blocked deploy is a prompt to write the migration,
|
|
619
|
+
not a failure to work around.
|
|
620
|
+
|
|
621
|
+
## Local dev: `palbase serve` uses the deployed database
|
|
622
|
+
|
|
623
|
+
`palbase serve` runs your controllers locally but proxies `Database` and
|
|
624
|
+
`ctx.*` to the **deployed** branch — it does **not** spin up a local Postgres or
|
|
625
|
+
apply migrations locally. So when your local `db/schema.ts` or `db/migrations/`
|
|
626
|
+
is ahead of what's deployed, serve prints a note: new tables/columns won't exist
|
|
627
|
+
until you push. Deploy to apply them.
|
|
628
|
+
|
|
629
|
+
## Workflow
|
|
630
|
+
|
|
631
|
+
1. Edit `db/schema.ts`.
|
|
632
|
+
2. **Additive** change? → `git push`. It auto-migrates on deploy.
|
|
633
|
+
3. **Type change / rename / drop?** → add `db/migrations/NNN_*.up.sql` (+
|
|
634
|
+
`.down.sql`), then `git push`. The runner applies it; without it the
|
|
635
|
+
drift-gate blocks the deploy.
|
|
636
|
+
4. `palbase serve` warns locally until the change is deployed.
|
|
637
|
+
|
|
638
|
+
See [schema.md](./schema.md) for the column builders and typed
|
|
639
|
+
`Database.tables.*` access.
|
|
426
640
|
|
|
427
641
|
|
|
428
642
|
|
|
@@ -430,9 +644,9 @@ untyped transaction with typed tables.
|
|
|
430
644
|
|
|
431
645
|
# Services
|
|
432
646
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
`ctx.
|
|
647
|
+
Import service singletons from `@palbase/backend` in every handler type —
|
|
648
|
+
endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
|
|
649
|
+
**middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
|
|
436
650
|
|
|
437
651
|
Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
|
|
438
652
|
`Log`, `Notifications`, `Flags`.
|
|
@@ -520,10 +734,9 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
|
|
|
520
734
|
## Flags
|
|
521
735
|
|
|
522
736
|
```ts
|
|
523
|
-
import {
|
|
737
|
+
import { defineHandler, z, Flags } from "@palbase/backend";
|
|
524
738
|
|
|
525
|
-
export default
|
|
526
|
-
method: "GET",
|
|
739
|
+
export default defineHandler({
|
|
527
740
|
auth: { required: true }, // req.user is non-null here
|
|
528
741
|
output: z.object({ enabled: z.boolean() }),
|
|
529
742
|
handler: async (req) => {
|
|
@@ -536,6 +749,105 @@ export default defineEndpoint({
|
|
|
536
749
|
|
|
537
750
|
|
|
538
751
|
|
|
752
|
+
<!-- ===== resources.md ===== -->
|
|
753
|
+
|
|
754
|
+
# Resources
|
|
755
|
+
|
|
756
|
+
A `Resource` models one external connection — a pooled datastore, a stateless
|
|
757
|
+
API client, or a per-user factory. You put it in `resources/`, export an
|
|
758
|
+
instance, and **do not register it**: the framework discovers it, sets it up
|
|
759
|
+
once at boot, and drains it on shutdown. On top of that lifecycle you expose
|
|
760
|
+
your own clean facade.
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
import { Resource } from "@palbase/backend";
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
## Lifecycle (boot scope — not per request)
|
|
767
|
+
|
|
768
|
+
A resource is created once at process boot — NOT per request. The framework:
|
|
769
|
+
|
|
770
|
+
1. calls `init(env)` **once**, with only the secrets the resource declared;
|
|
771
|
+
2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
|
|
772
|
+
|
|
773
|
+
The instance lives for the whole process; your facade methods are called
|
|
774
|
+
per-request. This makes "reconnect on every request" structurally impossible.
|
|
775
|
+
|
|
776
|
+
## Pooled datastore — `init` + `shutdown`
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
import { Resource } from "@palbase/backend";
|
|
780
|
+
import neo4j, { type Driver, type Session } from "neo4j-driver";
|
|
781
|
+
|
|
782
|
+
export class Neo4jResource extends Resource {
|
|
783
|
+
static secrets = ["NEO4J_URL", "NEO4J_USER", "NEO4J_PASSWORD"] as const;
|
|
784
|
+
private driver!: Driver;
|
|
785
|
+
async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
|
|
786
|
+
this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
|
|
787
|
+
}
|
|
788
|
+
async shutdown() {
|
|
789
|
+
await this.driver.close();
|
|
790
|
+
}
|
|
791
|
+
session(): Session {
|
|
792
|
+
return this.driver.session();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export const graph = new Neo4jResource();
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
## Stateless API client — `init` only
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
import { Resource } from "@palbase/backend";
|
|
803
|
+
import { Client } from "@googlemaps/google-maps-services-js";
|
|
804
|
+
|
|
805
|
+
export class GoogleResource extends Resource {
|
|
806
|
+
static secrets = ["GOOGLE_MAPS_KEY"] as const;
|
|
807
|
+
private client = new Client();
|
|
808
|
+
private key = "";
|
|
809
|
+
init(env: { GOOGLE_MAPS_KEY: string }) {
|
|
810
|
+
this.key = env.GOOGLE_MAPS_KEY;
|
|
811
|
+
}
|
|
812
|
+
nearby(lat: number, lng: number) {
|
|
813
|
+
return this.client.placesNearby({ params: { location: { lat, lng }, radius: 1500, key: this.key } });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export const google = new GoogleResource();
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
A per-user (OAuth) resource adds a factory method on the base, e.g.
|
|
821
|
+
`github.forUser(token)` — the same single model covers pooled, stateless, and
|
|
822
|
+
per-user.
|
|
823
|
+
|
|
824
|
+
## Secrets
|
|
825
|
+
|
|
826
|
+
`static secrets` is the contract:
|
|
827
|
+
|
|
828
|
+
- It **types** the `env` passed to `init` — only the declared names are
|
|
829
|
+
present, each a `string`. An undeclared key is a compile error.
|
|
830
|
+
- A declared secret that is **missing at boot fails the deploy**, naming the
|
|
831
|
+
secret. Secrets are branch-scoped; set them with `palbase secret set NAME ...`
|
|
832
|
+
or in Studio. A resource is initialised once at boot, so rotating a secret
|
|
833
|
+
needs a redeploy/restart.
|
|
834
|
+
|
|
835
|
+
`secrets` is optional — a resource that needs none simply omits it and gets an
|
|
836
|
+
empty `env`.
|
|
837
|
+
|
|
838
|
+
## Using a resource
|
|
839
|
+
|
|
840
|
+
Import the singleton and call your facade — services and handlers reach
|
|
841
|
+
resources the same way they reach `Database`:
|
|
842
|
+
|
|
843
|
+
```ts
|
|
844
|
+
import { google } from "../resources/google.js";
|
|
845
|
+
|
|
846
|
+
const results = (await google.nearby(41.0, 29.0)).data.results;
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
|
|
539
851
|
<!-- ===== errors.md ===== -->
|
|
540
852
|
|
|
541
853
|
# Errors
|
|
@@ -560,15 +872,14 @@ throw new HttpError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
|
560
872
|
|
|
561
873
|
## 2. Declared errors (typed)
|
|
562
874
|
|
|
563
|
-
Declare them on the
|
|
875
|
+
Declare them on the handler; throw via `req.errors.<name>(...)`. Declared
|
|
564
876
|
errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
|
|
565
877
|
for iOS callers.
|
|
566
878
|
|
|
567
879
|
```ts
|
|
568
|
-
import {
|
|
880
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
569
881
|
|
|
570
|
-
export default
|
|
571
|
-
method: "POST",
|
|
882
|
+
export default defineHandler({
|
|
572
883
|
input: z.object({ id: z.string() }),
|
|
573
884
|
output: z.object({ ok: z.boolean() }),
|
|
574
885
|
errors: {
|
|
@@ -593,10 +904,10 @@ one without `data` takes no arguments. This is enforced by the types.
|
|
|
593
904
|
|
|
594
905
|
# Workers & Jobs
|
|
595
906
|
|
|
596
|
-
Workers and jobs use the
|
|
597
|
-
|
|
598
|
-
**not** receive a `req
|
|
599
|
-
|
|
907
|
+
Workers and jobs use the **singleton model** — the same imported service
|
|
908
|
+
singletons as endpoints (`import { Database, Log } from "@palbase/backend"`).
|
|
909
|
+
They do **not** receive a `req`. Instead, a small `meta` argument carries the
|
|
910
|
+
non-service data (`env`, `user`, correlation ids).
|
|
600
911
|
|
|
601
912
|
## Workers (queue consumers)
|
|
602
913
|
|
|
@@ -605,7 +916,7 @@ A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
|
|
|
605
916
|
|
|
606
917
|
```ts
|
|
607
918
|
// workers/process-order.ts
|
|
608
|
-
import { defineWorker } from "@palbase/backend";
|
|
919
|
+
import { defineWorker, Database, Log } from "@palbase/backend";
|
|
609
920
|
|
|
610
921
|
interface OrderPayload { orderId: string; amount: number; }
|
|
611
922
|
|
|
@@ -614,13 +925,16 @@ export default defineWorker<OrderPayload>({
|
|
|
614
925
|
retry: 5, // optional, default 3
|
|
615
926
|
timeout: 60, // optional, seconds
|
|
616
927
|
backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
|
|
617
|
-
handler: async (
|
|
618
|
-
|
|
619
|
-
await
|
|
928
|
+
handler: async (payload, meta) => {
|
|
929
|
+
Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
|
|
930
|
+
await Database.update("orders", payload.orderId, { status: "processed" });
|
|
620
931
|
},
|
|
621
932
|
});
|
|
622
933
|
```
|
|
623
934
|
|
|
935
|
+
`meta` shape: `{ env, user, requestId, projectId, environmentId }`. Branch env
|
|
936
|
+
vars are in `meta.env`; services come from the imported singletons.
|
|
937
|
+
|
|
624
938
|
Enqueue from an endpoint:
|
|
625
939
|
|
|
626
940
|
```ts
|
|
@@ -634,29 +948,34 @@ A job runs on a cron schedule. File lives under `jobs/`.
|
|
|
634
948
|
|
|
635
949
|
```ts
|
|
636
950
|
// jobs/cleanup.ts
|
|
637
|
-
import { defineJob } from "@palbase/backend";
|
|
951
|
+
import { defineJob, Database, Log } from "@palbase/backend";
|
|
638
952
|
|
|
639
953
|
export default defineJob({
|
|
640
954
|
name: "cleanup-expired",
|
|
641
955
|
schedule: "0 3 * * *", // standard cron
|
|
642
956
|
timeout: 120, // optional, seconds
|
|
643
|
-
handler: async (
|
|
644
|
-
const expired = await
|
|
645
|
-
for (const s of expired) await
|
|
646
|
-
|
|
957
|
+
handler: async (meta) => {
|
|
958
|
+
const expired = await Database.findMany("sessions", { expired: true });
|
|
959
|
+
for (const s of expired) await Database.delete("sessions", s.id as string);
|
|
960
|
+
Log.info(`cleaned ${expired.length} sessions in ${meta.projectId}`);
|
|
647
961
|
},
|
|
648
962
|
});
|
|
649
963
|
```
|
|
650
964
|
|
|
965
|
+
`meta` shape: `{ env, projectId, environmentId }`. No `user` (jobs are
|
|
966
|
+
system-initiated).
|
|
967
|
+
|
|
651
968
|
|
|
652
969
|
|
|
653
970
|
<!-- ===== events.md ===== -->
|
|
654
971
|
|
|
655
972
|
# Hooks & Webhooks
|
|
656
973
|
|
|
657
|
-
Like workers/jobs, hooks and webhooks use the
|
|
658
|
-
|
|
659
|
-
|
|
974
|
+
Like workers/jobs, hooks and webhooks use the **singleton model** — the same
|
|
975
|
+
imported service singletons as endpoints (`import { Database, Log } from
|
|
976
|
+
"@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
|
|
977
|
+
carries the non-service data (`env`, `projectId`, `environmentId`; webhooks also
|
|
978
|
+
get `requestId`).
|
|
660
979
|
|
|
661
980
|
## Hooks (platform events)
|
|
662
981
|
|
|
@@ -665,21 +984,24 @@ are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
|
|
|
665
984
|
|
|
666
985
|
```ts
|
|
667
986
|
// hooks/auth.ts
|
|
668
|
-
import { auth } from "@palbase/backend";
|
|
987
|
+
import { auth, Database, Log } from "@palbase/backend";
|
|
669
988
|
|
|
670
|
-
export const onUserCreated = auth.onUserCreated(async (
|
|
671
|
-
|
|
672
|
-
await
|
|
989
|
+
export const onUserCreated = auth.onUserCreated(async (event, meta) => {
|
|
990
|
+
Log.info(`new user: ${event.user.email}`);
|
|
991
|
+
await Database.insert("profiles", {
|
|
673
992
|
user_id: event.user.id,
|
|
674
993
|
email: event.user.email,
|
|
675
994
|
});
|
|
676
995
|
});
|
|
677
996
|
|
|
678
|
-
export const onSignIn = auth.onSignIn(async (
|
|
679
|
-
|
|
997
|
+
export const onSignIn = auth.onSignIn(async (event, meta) => {
|
|
998
|
+
Log.info(`sign in: ${event.user.email} via ${event.provider}`);
|
|
680
999
|
});
|
|
681
1000
|
```
|
|
682
1001
|
|
|
1002
|
+
`meta` shape: `{ env, projectId, environmentId }`. Branch env vars are in
|
|
1003
|
+
`meta.env`; services come from the imported singletons.
|
|
1004
|
+
|
|
683
1005
|
Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
|
|
684
1006
|
`auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
|
|
685
1007
|
`documents.onDocumentCreated`, `documents.onDocumentUpdated`,
|
|
@@ -692,22 +1014,25 @@ Receive and verify webhooks from third-party providers. Files live under
|
|
|
692
1014
|
|
|
693
1015
|
```ts
|
|
694
1016
|
// webhooks/stripe.ts
|
|
695
|
-
import { defineWebhook } from "@palbase/backend";
|
|
1017
|
+
import { defineWebhook, Database, Log } from "@palbase/backend";
|
|
696
1018
|
|
|
697
1019
|
export default defineWebhook({
|
|
698
1020
|
provider: "stripe",
|
|
699
1021
|
secret: { env: "STRIPE_WEBHOOK_SECRET" }, // signing secret resolved from env
|
|
700
1022
|
events: {
|
|
701
|
-
"checkout.session.completed": async (
|
|
702
|
-
await
|
|
1023
|
+
"checkout.session.completed": async (event, meta) => {
|
|
1024
|
+
await Database.insert("orders", { status: "paid", data: event });
|
|
703
1025
|
},
|
|
704
|
-
"payment_intent.payment_failed": async (
|
|
705
|
-
|
|
706
|
-
await
|
|
1026
|
+
"payment_intent.payment_failed": async (event, meta) => {
|
|
1027
|
+
Log.error("payment failed");
|
|
1028
|
+
await Database.insert("payment_failures", { data: event });
|
|
707
1029
|
},
|
|
708
1030
|
},
|
|
709
1031
|
});
|
|
710
1032
|
```
|
|
711
1033
|
|
|
712
|
-
The signing secret is
|
|
713
|
-
|
|
1034
|
+
The signing secret is resolved by the runtime from `secret: { env: "NAME" }`;
|
|
1035
|
+
your handlers access branch env vars via `meta.env`. The runtime verifies the
|
|
1036
|
+
signature before dispatching to your event handlers.
|
|
1037
|
+
|
|
1038
|
+
`meta` shape: `{ env, requestId, projectId, environmentId }`.
|