@palbase/backend 2.0.0 → 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/README.md +24 -0
- 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 +72 -0
- package/docs/background.md +62 -0
- package/docs/database.md +73 -0
- package/docs/endpoints.md +134 -0
- package/docs/errors.md +47 -0
- package/docs/events.md +67 -0
- package/docs/getting-started.md +65 -0
- package/docs/llms-full.txt +1038 -0
- package/docs/llms.txt +18 -0
- package/docs/migrations.md +98 -0
- package/docs/resources.md +94 -0
- package/docs/routing.md +67 -0
- package/docs/schema.md +92 -0
- package/docs/services.md +104 -0
- package/package.json +15 -3
- 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
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
# Palbase Backend SDK (`@palbase/backend`)
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
<!-- ===== README.md ===== -->
|
|
8
|
+
|
|
9
|
+
# Palbase Backend SDK (`@palbase/backend`)
|
|
10
|
+
|
|
11
|
+
Write your backend as TypeScript files. Palbase discovers them by path, runs
|
|
12
|
+
them inside the managed backend runtime, and exposes them as a typed HTTP API.
|
|
13
|
+
|
|
14
|
+
This is **not** Express, Fastify, or a Supabase Edge Function. There is no
|
|
15
|
+
`app.get(...)`, no manual route registration, no `import express`. You export
|
|
16
|
+
definitions; the runtime wires them up.
|
|
17
|
+
|
|
18
|
+
## Mental model (important)
|
|
19
|
+
|
|
20
|
+
Every handler type imports service singletons the same way:
|
|
21
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## Project shape
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
my-backend/
|
|
45
|
+
├── package.json # depends on @palbase/backend
|
|
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
|
|
51
|
+
├── db/schema.ts # table definitions (optional, enables typed DB)
|
|
52
|
+
├── db/migrations/ # explicit SQL migrations for type changes (optional)
|
|
53
|
+
├── workers/ # background job handlers (optional)
|
|
54
|
+
├── jobs/ # cron-scheduled jobs (optional)
|
|
55
|
+
├── hooks/ # auth/storage/document event hooks (optional)
|
|
56
|
+
├── webhooks/ # inbound provider webhooks (optional)
|
|
57
|
+
└── middleware/ # cross-cutting request middleware (optional)
|
|
58
|
+
```
|
|
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
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
| Topic | File |
|
|
67
|
+
|-------|------|
|
|
68
|
+
| Getting started | [getting-started.md](./getting-started.md) |
|
|
69
|
+
| File-based routing | [routing.md](./routing.md) |
|
|
70
|
+
| Endpoints & `req` | [endpoints.md](./endpoints.md) |
|
|
71
|
+
| Database & transactions | [database.md](./database.md) |
|
|
72
|
+
| Schema & typed DB | [schema.md](./schema.md) |
|
|
73
|
+
| Migrations (additive vs type-change + drift-gate) | [migrations.md](./migrations.md) |
|
|
74
|
+
| Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
|
|
75
|
+
| Errors | [errors.md](./errors.md) |
|
|
76
|
+
| Workers & Jobs | [background.md](./background.md) |
|
|
77
|
+
| Hooks & Webhooks | [events.md](./events.md) |
|
|
78
|
+
|
|
79
|
+
For AI coding tools: a single concatenated corpus is generated at
|
|
80
|
+
[`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
<!-- ===== getting-started.md ===== -->
|
|
85
|
+
|
|
86
|
+
# Getting started
|
|
87
|
+
|
|
88
|
+
There is no CLI init command. A starter project is created for you when your
|
|
89
|
+
Palbase project is provisioned. You then edit the files locally and deploy them.
|
|
90
|
+
|
|
91
|
+
## package.json
|
|
92
|
+
|
|
93
|
+
Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"name": "my-backend",
|
|
98
|
+
"private": true,
|
|
99
|
+
"scripts": {
|
|
100
|
+
"dev": "palbase serve",
|
|
101
|
+
"typecheck": "tsc --noEmit"
|
|
102
|
+
},
|
|
103
|
+
"dependencies": { "@palbase/backend": "latest" },
|
|
104
|
+
"devDependencies": { "@types/node": "^22", "typescript": "^5" }
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Local dev loop
|
|
109
|
+
|
|
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`.
|
|
118
|
+
|
|
119
|
+
## Your first endpoint
|
|
120
|
+
|
|
121
|
+
A handler is one endpoint unit; a controller maps method+path to it. Create
|
|
122
|
+
`handlers/hello.ts`:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { defineHandler, z } from "@palbase/backend";
|
|
126
|
+
|
|
127
|
+
export default defineHandler({
|
|
128
|
+
input: z.object({ name: z.string().optional() }),
|
|
129
|
+
output: z.object({ message: z.string(), user: z.string().nullable() }),
|
|
130
|
+
handler: async (req) => ({
|
|
131
|
+
message: `hello, ${req.input.name ?? "world"}!`,
|
|
132
|
+
user: req.user?.id ?? null,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
```
|
|
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
|
+
|
|
148
|
+
This is served at `GET /hello`. The Zod `input` schema validates the request and
|
|
149
|
+
flows into `req.input`; the `output` schema validates your return value. See
|
|
150
|
+
[routing.md](./routing.md) for the handler + controller model.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
<!-- ===== routing.md ===== -->
|
|
155
|
+
|
|
156
|
+
# Routing
|
|
157
|
+
|
|
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.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { defineController, route } from "@palbase/backend";
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Handlers — one endpoint per file, no route
|
|
168
|
+
|
|
169
|
+
A handler declares everything that types `req` (`auth`/`input`/`output`/
|
|
170
|
+
`errors`) and the logic; it has no method or path.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// handlers/places/import-nearby.ts
|
|
174
|
+
import { defineHandler, z } from "@palbase/backend";
|
|
175
|
+
import { placeService } from "../../services/place.service.js";
|
|
176
|
+
|
|
177
|
+
export default defineHandler({
|
|
178
|
+
auth: { required: true },
|
|
179
|
+
input: z.object({ lat: z.number(), lng: z.number() }),
|
|
180
|
+
output: z.object({ imported: z.number() }),
|
|
181
|
+
errors: { quotaExceeded: { status: 429, code: "quota_exceeded" } },
|
|
182
|
+
handler: (req) => placeService.importNearby(req.input.lat, req.input.lng),
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Controllers — the route map
|
|
187
|
+
|
|
188
|
+
A controller maps method+path to handlers with `route.get|post|put|patch|delete`.
|
|
189
|
+
The route-map KEY is authoring sugar only (it is NOT the operationId).
|
|
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),
|
|
202
|
+
});
|
|
203
|
+
```
|
|
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
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
<!-- ===== endpoints.md ===== -->
|
|
227
|
+
|
|
228
|
+
# Endpoints
|
|
229
|
+
|
|
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)).
|
|
235
|
+
|
|
236
|
+
## `defineHandler` config
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
defineHandler({
|
|
240
|
+
auth: { required: true }, // see Auth below. Omitted → AUTH REQUIRED (secure-by-default).
|
|
241
|
+
rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
|
|
242
|
+
input: z.object({ ... }), // optional Zod schema → validates & types req.input
|
|
243
|
+
output: z.object({ ... }), // optional Zod schema → validates the return value
|
|
244
|
+
errors: { ... }, // optional declared errors (see errors.md)
|
|
245
|
+
middleware: [ ... ], // optional Middleware[] (see below)
|
|
246
|
+
handler: async (req) => { ... }, // required
|
|
247
|
+
});
|
|
248
|
+
```
|
|
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
|
+
|
|
253
|
+
## `req` (PBRequest)
|
|
254
|
+
|
|
255
|
+
| Field | Type | Notes |
|
|
256
|
+
|-------|------|-------|
|
|
257
|
+
| `req.input` | inferred from `input` schema | request body for POST/PUT/PATCH; `{}` otherwise |
|
|
258
|
+
| `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
|
|
259
|
+
| `req.query` | `Record<string,string>` | parsed query string |
|
|
260
|
+
| `req.headers` | `Record<string,string>` | lowercase keys |
|
|
261
|
+
| `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
|
|
262
|
+
| `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
|
|
263
|
+
| `req.file` | `FileContext \| null` | uploaded file, if any |
|
|
264
|
+
| `req.method` | `string` | the HTTP method |
|
|
265
|
+
| `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
|
|
266
|
+
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
267
|
+
|
|
268
|
+
`User` is `{ id: string; email?: string; role: string; metadata: Record<string, unknown> }`.
|
|
269
|
+
|
|
270
|
+
## Auth
|
|
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
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// auth omitted entirely // AUTH REQUIRED → req.user is non-null User
|
|
278
|
+
auth: { required: true } // same: require any authenticated user
|
|
279
|
+
auth: { required: true, role: "admin" } // require a specific role
|
|
280
|
+
auth: { required: false } // PUBLIC → req.user may be null (anon key only)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Whether `req.user` is non-null is computed from the `auth` config at the type
|
|
284
|
+
level (and matches the runtime exactly):
|
|
285
|
+
|
|
286
|
+
| `auth` value | `req.user` type |
|
|
287
|
+
|--------------|-----------------|
|
|
288
|
+
| omitted | `User` (secure-by-default) |
|
|
289
|
+
| `true` | `User` |
|
|
290
|
+
| `false` | `User \| null` (public) |
|
|
291
|
+
| `{ required: true }` | `User` |
|
|
292
|
+
| `{ required: false }` | `User \| null` (public) |
|
|
293
|
+
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
294
|
+
|
|
295
|
+
To make a route PUBLIC, set `auth: { required: false }` (or `auth: false`). An
|
|
296
|
+
object with a `role` but no `required` key is treated as authenticated. When
|
|
297
|
+
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
298
|
+
|
|
299
|
+
## Typed input/output
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
303
|
+
|
|
304
|
+
export default defineHandler({
|
|
305
|
+
auth: { required: true },
|
|
306
|
+
input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
|
|
307
|
+
output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
|
|
308
|
+
handler: async (req) => {
|
|
309
|
+
const room = await Database.insert("rooms", {
|
|
310
|
+
name: req.input.name,
|
|
311
|
+
capacity: req.input.capacity ?? null,
|
|
312
|
+
});
|
|
313
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
A controller then mounts it with a method + path:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
// controllers/rooms.controller.ts
|
|
322
|
+
import { defineController, route } from "@palbase/backend";
|
|
323
|
+
import create from "../handlers/rooms/create.js";
|
|
324
|
+
|
|
325
|
+
export default defineController("/rooms", {
|
|
326
|
+
create: route.post("/", create),
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Middleware
|
|
331
|
+
|
|
332
|
+
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
// middleware/logger.ts
|
|
336
|
+
import { defineMiddleware } from "@palbase/backend";
|
|
337
|
+
|
|
338
|
+
export default defineMiddleware(async (ctx, next) => {
|
|
339
|
+
ctx.log.info(`start ${ctx.requestId}`);
|
|
340
|
+
await next();
|
|
341
|
+
ctx.log.info(`done ${ctx.requestId}`);
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
346
|
+
rest of the chain (other middleware, then the endpoint handler). Note this uses
|
|
347
|
+
the `ctx` model, not `req`.
|
|
348
|
+
|
|
349
|
+
To attach middleware to a specific handler, import it and list it in the
|
|
350
|
+
handler's `middleware` array:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import { defineHandler, z } from "@palbase/backend";
|
|
354
|
+
import logger from "../../middleware/logger.js";
|
|
355
|
+
|
|
356
|
+
export default defineHandler({
|
|
357
|
+
middleware: [logger],
|
|
358
|
+
output: z.object({ ok: z.boolean() }),
|
|
359
|
+
handler: async (req) => ({ ok: true }),
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
<!-- ===== database.md ===== -->
|
|
366
|
+
|
|
367
|
+
# Database
|
|
368
|
+
|
|
369
|
+
Import the `Database` singleton in every handler type — endpoints, workers,
|
|
370
|
+
jobs, hooks, and webhooks all use the same import:
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
import { Database } from "@palbase/backend";
|
|
374
|
+
```
|
|
375
|
+
|
|
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.
|
|
396
|
+
|
|
397
|
+
## Raw string-keyed operations
|
|
398
|
+
|
|
399
|
+
For dynamic table names or read-only SQL, the string-keyed ops are still
|
|
400
|
+
available:
|
|
401
|
+
|
|
402
|
+
| Method | Returns |
|
|
403
|
+
|--------|---------|
|
|
404
|
+
| `Database.insert(table, data)` | the inserted row (`Record<string, unknown>`) |
|
|
405
|
+
| `Database.update(table, id, data)` | the updated row |
|
|
406
|
+
| `Database.delete(table, id)` | `void` |
|
|
407
|
+
| `Database.findById(table, id)` | the row or `null` |
|
|
408
|
+
| `Database.findMany(table, query?)` | matching rows (array) |
|
|
409
|
+
| `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
|
|
410
|
+
| `Database.transaction(fn)` | runs `fn(tx)` in a transaction |
|
|
411
|
+
|
|
412
|
+
`findMany`'s `query` is an equality filter: keys are ANDed together. For
|
|
413
|
+
anything richer (ranges, ordering, joins) use `Database.query`.
|
|
414
|
+
|
|
415
|
+
`Database.query` is **read-only** — use it for selects/joins the helpers don't
|
|
416
|
+
cover. Writes must go through `insert`/`update`/`delete` or a transaction.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
const rows = await Database.query(
|
|
420
|
+
"SELECT id, title FROM todos WHERE done = $1 ORDER BY created_at DESC LIMIT $2",
|
|
421
|
+
[false, 20],
|
|
422
|
+
);
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Transactions
|
|
426
|
+
|
|
427
|
+
`transaction(fn)` gives you a `tx` with the same DB ops (no nested
|
|
428
|
+
transaction). Returning commits; throwing rolls back.
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
await Database.transaction(async (tx) => {
|
|
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" });
|
|
434
|
+
// throw here → both inserts roll back
|
|
435
|
+
});
|
|
436
|
+
```
|
|
437
|
+
|
|
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.
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
<!-- ===== schema.md ===== -->
|
|
444
|
+
|
|
445
|
+
# Schema & typed database access
|
|
446
|
+
|
|
447
|
+
Declare your tables in `db/schema.ts` with `defineSchema`. This drives
|
|
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.
|
|
451
|
+
|
|
452
|
+
## Defining a schema
|
|
453
|
+
|
|
454
|
+
The table NAME comes from the object key under `tables` — there is one
|
|
455
|
+
canonical form.
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
import {
|
|
459
|
+
defineSchema,
|
|
460
|
+
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
461
|
+
} from "@palbase/backend";
|
|
462
|
+
|
|
463
|
+
export default defineSchema({
|
|
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
|
+
},
|
|
485
|
+
});
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
## Column builders
|
|
489
|
+
|
|
490
|
+
| Builder | Postgres type |
|
|
491
|
+
|---------|---------------|
|
|
492
|
+
| `uuid()` | `uuid` |
|
|
493
|
+
| `text()` | `text` |
|
|
494
|
+
| `integer()` | `integer` |
|
|
495
|
+
| `boolean()` | `boolean` |
|
|
496
|
+
| `timestamp()` | `timestamptz` |
|
|
497
|
+
| `jsonb()` | `jsonb` |
|
|
498
|
+
| `enumType(name, values)` | a Postgres enum |
|
|
499
|
+
|
|
500
|
+
Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
501
|
+
`.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
|
|
502
|
+
`.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
|
|
503
|
+
`.onDelete("cascade" | "set null" | "restrict" | "no action")`.
|
|
504
|
+
|
|
505
|
+
## Typed DB access — by default
|
|
506
|
+
|
|
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:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
513
|
+
|
|
514
|
+
export default defineHandler({
|
|
515
|
+
input: z.object({ name: z.string() }),
|
|
516
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
517
|
+
handler: async (req) => {
|
|
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
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
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
|
+
```
|
|
600
|
+
|
|
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.
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
<!-- ===== services.md ===== -->
|
|
644
|
+
|
|
645
|
+
# Services
|
|
646
|
+
|
|
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.).
|
|
650
|
+
|
|
651
|
+
Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
|
|
652
|
+
`Log`, `Notifications`, `Flags`.
|
|
653
|
+
|
|
654
|
+
**Not available to backend handlers** (do not import them here): Realtime,
|
|
655
|
+
Functions, CMS, Links, Analytics, and Auth. Auth runs on the client SDK; the
|
|
656
|
+
others are out of scope for backend endpoints.
|
|
657
|
+
|
|
658
|
+
## Cache
|
|
659
|
+
|
|
660
|
+
JSON-typed key/value cache.
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
import { Cache } from "@palbase/backend";
|
|
664
|
+
|
|
665
|
+
await Cache.set("k", { hits: 1 }, 60); // value + TTL seconds
|
|
666
|
+
const v = await Cache.get<{ hits: number }>("k"); // typed, null on miss
|
|
667
|
+
await Cache.incr("counter");
|
|
668
|
+
await Cache.del("k");
|
|
669
|
+
|
|
670
|
+
// Stampede-safe read-through: only one caller across all pods runs fn.
|
|
671
|
+
const profile = await Cache.getOrSet("user:42", 300, async () => {
|
|
672
|
+
return Database.findById("users", "42");
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
`getOrSet` caches whatever `fn` returns, including `null` — return a sentinel or
|
|
677
|
+
guard upstream if you don't want misses cached.
|
|
678
|
+
|
|
679
|
+
## Queue
|
|
680
|
+
|
|
681
|
+
Enqueue work for a worker (see [background.md](./background.md)).
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
import { Queue } from "@palbase/backend";
|
|
685
|
+
const { jobId } = await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
## Log
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
import { Log } from "@palbase/backend";
|
|
692
|
+
Log.info("created order", { orderId });
|
|
693
|
+
Log.warn("retrying");
|
|
694
|
+
Log.error("failed", err);
|
|
695
|
+
Log.debug("detail");
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
## Storage
|
|
699
|
+
|
|
700
|
+
Bucket-scoped file operations.
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { Storage } from "@palbase/backend";
|
|
704
|
+
const bucket = Storage.bucket("avatars");
|
|
705
|
+
const { data, error } = await bucket.upload("u/42.png", file);
|
|
706
|
+
const { data: signed } = await bucket.createSignedUrl("u/42.png", 3600);
|
|
707
|
+
const pub = bucket.getPublicUrl("u/42.png"); // sync, no network
|
|
708
|
+
await bucket.remove(["u/42.png"]);
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
All Storage calls return `{ data, error }` — check `error` before using `data`.
|
|
712
|
+
|
|
713
|
+
## Documents
|
|
714
|
+
|
|
715
|
+
Firestore-like document store.
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
import { Documents } from "@palbase/backend";
|
|
719
|
+
const col = Documents.collection("rooms");
|
|
720
|
+
const { data: ref } = await col.add({ name: "Lobby" });
|
|
721
|
+
const snap = await col.where("active", "==", true).orderBy("name").limit(10).get();
|
|
722
|
+
const { data: doc } = await Documents.doc("rooms/abc").get();
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
## Notifications
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
import { Notifications } from "@palbase/backend";
|
|
729
|
+
await Notifications.email.send({ /* PalbaseEmailSendParams */ });
|
|
730
|
+
await Notifications.push.send({ /* PalbasePushSendParams */ });
|
|
731
|
+
await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
## Flags
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import { defineHandler, z, Flags } from "@palbase/backend";
|
|
738
|
+
|
|
739
|
+
export default defineHandler({
|
|
740
|
+
auth: { required: true }, // req.user is non-null here
|
|
741
|
+
output: z.object({ enabled: z.boolean() }),
|
|
742
|
+
handler: async (req) => {
|
|
743
|
+
const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
|
|
744
|
+
const { data: variant } = await Flags.getVariant("button-color", { userId: req.user.id });
|
|
745
|
+
return { enabled: enabled ?? false };
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
|
|
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
|
+
|
|
851
|
+
<!-- ===== errors.md ===== -->
|
|
852
|
+
|
|
853
|
+
# Errors
|
|
854
|
+
|
|
855
|
+
Two ways to fail a request. Both serialize to the standard Palbase error
|
|
856
|
+
envelope:
|
|
857
|
+
|
|
858
|
+
```json
|
|
859
|
+
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
## 1. `HttpError` (ad-hoc)
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { HttpError } from "@palbase/backend";
|
|
866
|
+
throw new HttpError(404, "todo_not_found", "No such todo");
|
|
867
|
+
// optional structured payload (4th arg) rides along under `data`:
|
|
868
|
+
throw new HttpError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
`new HttpError(status, code, description, data?)`.
|
|
872
|
+
|
|
873
|
+
## 2. Declared errors (typed)
|
|
874
|
+
|
|
875
|
+
Declare them on the handler; throw via `req.errors.<name>(...)`. Declared
|
|
876
|
+
errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
|
|
877
|
+
for iOS callers.
|
|
878
|
+
|
|
879
|
+
```ts
|
|
880
|
+
import { defineHandler, z, Database } from "@palbase/backend";
|
|
881
|
+
|
|
882
|
+
export default defineHandler({
|
|
883
|
+
input: z.object({ id: z.string() }),
|
|
884
|
+
output: z.object({ ok: z.boolean() }),
|
|
885
|
+
errors: {
|
|
886
|
+
notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
|
|
887
|
+
locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
|
|
888
|
+
},
|
|
889
|
+
handler: async (req) => {
|
|
890
|
+
const todo = await Database.findById("todos", req.input.id);
|
|
891
|
+
if (!todo) throw req.errors.notFound(); // no data → no args
|
|
892
|
+
if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
|
|
893
|
+
return { ok: true };
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
A declared error with a `data` Zod schema requires that payload as an argument;
|
|
899
|
+
one without `data` takes no arguments. This is enforced by the types.
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
<!-- ===== background.md ===== -->
|
|
904
|
+
|
|
905
|
+
# Workers & Jobs
|
|
906
|
+
|
|
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).
|
|
911
|
+
|
|
912
|
+
## Workers (queue consumers)
|
|
913
|
+
|
|
914
|
+
A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
|
|
915
|
+
`workers/`.
|
|
916
|
+
|
|
917
|
+
```ts
|
|
918
|
+
// workers/process-order.ts
|
|
919
|
+
import { defineWorker, Database, Log } from "@palbase/backend";
|
|
920
|
+
|
|
921
|
+
interface OrderPayload { orderId: string; amount: number; }
|
|
922
|
+
|
|
923
|
+
export default defineWorker<OrderPayload>({
|
|
924
|
+
name: "process-order", // must match the Queue.push() name
|
|
925
|
+
retry: 5, // optional, default 3
|
|
926
|
+
timeout: 60, // optional, seconds
|
|
927
|
+
backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
|
|
928
|
+
handler: async (payload, meta) => {
|
|
929
|
+
Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
|
|
930
|
+
await Database.update("orders", payload.orderId, { status: "processed" });
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
`meta` shape: `{ env, user, requestId, projectId, environmentId }`. Branch env
|
|
936
|
+
vars are in `meta.env`; services come from the imported singletons.
|
|
937
|
+
|
|
938
|
+
Enqueue from an endpoint:
|
|
939
|
+
|
|
940
|
+
```ts
|
|
941
|
+
import { Queue } from "@palbase/backend";
|
|
942
|
+
await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
## Jobs (cron-scheduled)
|
|
946
|
+
|
|
947
|
+
A job runs on a cron schedule. File lives under `jobs/`.
|
|
948
|
+
|
|
949
|
+
```ts
|
|
950
|
+
// jobs/cleanup.ts
|
|
951
|
+
import { defineJob, Database, Log } from "@palbase/backend";
|
|
952
|
+
|
|
953
|
+
export default defineJob({
|
|
954
|
+
name: "cleanup-expired",
|
|
955
|
+
schedule: "0 3 * * *", // standard cron
|
|
956
|
+
timeout: 120, // optional, seconds
|
|
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}`);
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
`meta` shape: `{ env, projectId, environmentId }`. No `user` (jobs are
|
|
966
|
+
system-initiated).
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
<!-- ===== events.md ===== -->
|
|
971
|
+
|
|
972
|
+
# Hooks & Webhooks
|
|
973
|
+
|
|
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`).
|
|
979
|
+
|
|
980
|
+
## Hooks (platform events)
|
|
981
|
+
|
|
982
|
+
React to auth, storage, and document events. Files live under `hooks/`. Builders
|
|
983
|
+
are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
|
|
984
|
+
|
|
985
|
+
```ts
|
|
986
|
+
// hooks/auth.ts
|
|
987
|
+
import { auth, Database, Log } from "@palbase/backend";
|
|
988
|
+
|
|
989
|
+
export const onUserCreated = auth.onUserCreated(async (event, meta) => {
|
|
990
|
+
Log.info(`new user: ${event.user.email}`);
|
|
991
|
+
await Database.insert("profiles", {
|
|
992
|
+
user_id: event.user.id,
|
|
993
|
+
email: event.user.email,
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
export const onSignIn = auth.onSignIn(async (event, meta) => {
|
|
998
|
+
Log.info(`sign in: ${event.user.email} via ${event.provider}`);
|
|
999
|
+
});
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
`meta` shape: `{ env, projectId, environmentId }`. Branch env vars are in
|
|
1003
|
+
`meta.env`; services come from the imported singletons.
|
|
1004
|
+
|
|
1005
|
+
Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
|
|
1006
|
+
`auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
|
|
1007
|
+
`documents.onDocumentCreated`, `documents.onDocumentUpdated`,
|
|
1008
|
+
`documents.onDocumentDeleted`.
|
|
1009
|
+
|
|
1010
|
+
## Webhooks (inbound provider events)
|
|
1011
|
+
|
|
1012
|
+
Receive and verify webhooks from third-party providers. Files live under
|
|
1013
|
+
`webhooks/`.
|
|
1014
|
+
|
|
1015
|
+
```ts
|
|
1016
|
+
// webhooks/stripe.ts
|
|
1017
|
+
import { defineWebhook, Database, Log } from "@palbase/backend";
|
|
1018
|
+
|
|
1019
|
+
export default defineWebhook({
|
|
1020
|
+
provider: "stripe",
|
|
1021
|
+
secret: { env: "STRIPE_WEBHOOK_SECRET" }, // signing secret resolved from env
|
|
1022
|
+
events: {
|
|
1023
|
+
"checkout.session.completed": async (event, meta) => {
|
|
1024
|
+
await Database.insert("orders", { status: "paid", data: event });
|
|
1025
|
+
},
|
|
1026
|
+
"payment_intent.payment_failed": async (event, meta) => {
|
|
1027
|
+
Log.error("payment failed");
|
|
1028
|
+
await Database.insert("payment_failures", { data: event });
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
```
|
|
1033
|
+
|
|
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 }`.
|