@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.
Files changed (51) hide show
  1. package/README.md +24 -0
  2. package/dist/{chunk-4J3F32SH.js → chunk-B7EUJP5W.js} +38 -9
  3. package/dist/chunk-B7EUJP5W.js.map +1 -0
  4. package/dist/{chunk-L36JLUPO.js → chunk-PHAFZGHN.js} +43 -46
  5. package/dist/chunk-PHAFZGHN.js.map +1 -0
  6. package/dist/db/env.cjs +19 -0
  7. package/dist/db/env.cjs.map +1 -0
  8. package/dist/db/env.d.cts +45 -0
  9. package/dist/db/env.d.ts +45 -0
  10. package/dist/db/env.js +1 -0
  11. package/dist/db/env.js.map +1 -0
  12. package/dist/db/index.cjs +28 -231
  13. package/dist/db/index.cjs.map +1 -1
  14. package/dist/db/index.d.cts +4 -20
  15. package/dist/db/index.d.ts +4 -20
  16. package/dist/db/index.js +3 -233
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-DJ98tQd6.d.cts} +30 -68
  19. package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-DJ98tQd6.d.ts} +30 -68
  20. package/dist/index-CXUs9iTQ.d.ts +294 -0
  21. package/dist/index-CZAwpQE1.d.cts +294 -0
  22. package/dist/index.cjs +229 -61
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +398 -154
  25. package/dist/index.d.ts +398 -154
  26. package/dist/index.js +147 -12
  27. package/dist/index.js.map +1 -1
  28. package/dist/test/index.cjs +41 -1
  29. package/dist/test/index.cjs.map +1 -1
  30. package/dist/test/index.d.cts +1 -2
  31. package/dist/test/index.d.ts +1 -2
  32. package/dist/test/index.js +1 -1
  33. package/docs/README.md +72 -0
  34. package/docs/background.md +62 -0
  35. package/docs/database.md +73 -0
  36. package/docs/endpoints.md +134 -0
  37. package/docs/errors.md +47 -0
  38. package/docs/events.md +67 -0
  39. package/docs/getting-started.md +65 -0
  40. package/docs/llms-full.txt +1038 -0
  41. package/docs/llms.txt +18 -0
  42. package/docs/migrations.md +98 -0
  43. package/docs/resources.md +94 -0
  44. package/docs/routing.md +67 -0
  45. package/docs/schema.md +92 -0
  46. package/docs/services.md +104 -0
  47. package/package.json +15 -3
  48. package/dist/chunk-4J3F32SH.js.map +0 -1
  49. package/dist/chunk-L36JLUPO.js.map +0 -1
  50. package/dist/schema-BqfEhIC0.d.cts +0 -133
  51. 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 }`.