@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.
Files changed (50) hide show
  1. package/dist/{chunk-4J3F32SH.js → chunk-B7EUJP5W.js} +38 -9
  2. package/dist/chunk-B7EUJP5W.js.map +1 -0
  3. package/dist/{chunk-L36JLUPO.js → chunk-PHAFZGHN.js} +43 -46
  4. package/dist/chunk-PHAFZGHN.js.map +1 -0
  5. package/dist/db/env.cjs +19 -0
  6. package/dist/db/env.cjs.map +1 -0
  7. package/dist/db/env.d.cts +45 -0
  8. package/dist/db/env.d.ts +45 -0
  9. package/dist/db/env.js +1 -0
  10. package/dist/db/env.js.map +1 -0
  11. package/dist/db/index.cjs +28 -231
  12. package/dist/db/index.cjs.map +1 -1
  13. package/dist/db/index.d.cts +4 -20
  14. package/dist/db/index.d.ts +4 -20
  15. package/dist/db/index.js +3 -233
  16. package/dist/db/index.js.map +1 -1
  17. package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-DJ98tQd6.d.cts} +30 -68
  18. package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-DJ98tQd6.d.ts} +30 -68
  19. package/dist/index-CXUs9iTQ.d.ts +294 -0
  20. package/dist/index-CZAwpQE1.d.cts +294 -0
  21. package/dist/index.cjs +229 -61
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +398 -154
  24. package/dist/index.d.ts +398 -154
  25. package/dist/index.js +147 -12
  26. package/dist/index.js.map +1 -1
  27. package/dist/test/index.cjs +41 -1
  28. package/dist/test/index.cjs.map +1 -1
  29. package/dist/test/index.d.cts +1 -2
  30. package/dist/test/index.d.ts +1 -2
  31. package/dist/test/index.js +1 -1
  32. package/docs/README.md +31 -10
  33. package/docs/background.md +19 -13
  34. package/docs/database.md +30 -17
  35. package/docs/endpoints.md +42 -24
  36. package/docs/errors.md +3 -4
  37. package/docs/events.md +25 -17
  38. package/docs/getting-started.md +25 -9
  39. package/docs/llms-full.txt +489 -164
  40. package/docs/llms.txt +3 -1
  41. package/docs/migrations.md +98 -0
  42. package/docs/resources.md +94 -0
  43. package/docs/routing.md +59 -26
  44. package/docs/schema.md +48 -38
  45. package/docs/services.md +5 -6
  46. package/package.json +11 -1
  47. package/dist/chunk-4J3F32SH.js.map +0 -1
  48. package/dist/chunk-L36JLUPO.js.map +0 -1
  49. package/dist/schema-BqfEhIC0.d.cts +0 -133
  50. package/dist/schema-BqfEhIC0.d.ts +0 -133
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
3
- > File-based TypeScript backend SDK. Endpoints use `req` (PBRequest) + imported service singletons (`Database`, `Cache`, …). Workers/jobs/hooks/webhooks use a `ctx` object. Not Express, not Supabase Edge Functions.
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
- ## The two mental models (important)
18
+ ## Mental model (important)
19
19
 
20
- | You are writing… | Handler receives | Services come from |
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
- Endpoints use `req` + imported singletons. Everything else uses `ctx`. Do not
26
- mix them: there is no `ctx` inside an endpoint handler, and no imported
27
- `Database` singleton call inside a worker (use `ctx.db`).
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
- ├── endpoints/ # HTTP endpoints (file-based routing)
35
- │ └── hello/get.ts # GET /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
- - `palbase push` deploy the current directory to your project's backend runtime.
92
- - `palbase push --branch <name>` deploy to a branch instead of `main`.
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
- Create `endpoints/hello/get.ts`:
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 { defineEndpoint, z } from "@palbase/backend";
125
+ import { defineHandler, z } from "@palbase/backend";
100
126
 
101
- export default defineEndpoint({
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
- # File-based routing
156
+ # Routing
120
157
 
121
- The path of a file under `endpoints/` plus its filename determine the route.
122
- The filename is the HTTP method.
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
- | File | Route |
125
- |------|-------|
126
- | `endpoints/hello/get.ts` | `GET /hello` |
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
- Rules:
167
+ ## Handlers — one endpoint per file, no route
133
168
 
134
- - The method file name is one of `get`, `post`, `put`, `patch`, `delete` (`.ts`).
135
- - A `[segment]` directory becomes a `:segment` path param, read via `req.params.segment`.
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
- // endpoints/posts/[id]/get.ts → GET /posts/:id
141
- import { defineEndpoint, z, Database, HttpError } from "@palbase/backend";
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
- export default defineEndpoint({
144
- method: "GET",
145
- output: z.object({ id: z.string(), title: z.string() }),
146
- handler: async (req) => {
147
- const post = await Database.findById("posts", req.params.id!);
148
- if (!post) throw new HttpError(404, "post_not_found", "No such post");
149
- return { id: post.id as string, title: post.title as string };
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 defineEndpoint({...})` in a method file. The
161
- handler receives **one argument**, `req` (a `PBRequest`). Services are NOT on
162
- `req` import them as singletons (see [services.md](./services.md)).
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
- ## `defineEndpoint` config
236
+ ## `defineHandler` config
165
237
 
166
238
  ```ts
167
- defineEndpoint({
168
- method: "POST", // required: GET | POST | PUT | PATCH | DELETE
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: string; role: string; metadata: Record<string, unknown> }`.
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: { required: true } // require any authenticated user → req.user is non-null User
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 } // public → req.user may be null
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 \| null` |
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 enforce authentication, set `auth: { required: true }` (or `auth: true`). An
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 { defineEndpoint, z, Database } from "@palbase/backend";
302
+ import { defineHandler, z, Database } from "@palbase/backend";
225
303
 
226
- export default defineEndpoint({
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 endpoint, import it and list it in the
261
- endpoint's `middleware` array:
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 { defineEndpoint, z } from "@palbase/backend";
353
+ import { defineHandler, z } from "@palbase/backend";
265
354
  import logger from "../../middleware/logger.js";
266
355
 
267
- export default defineEndpoint({
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
- In **endpoints**, import the `Database` singleton:
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
- In **workers, jobs, hooks, and webhooks**, use `ctx.db` the same surface,
288
- reached via the context object (see [background.md](./background.md) and
289
- [events.md](./events.md)).
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
- ## Operations
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("orders", { amount: 1000, status: "pending" });
332
- await tx.insert("order_items", { order_id: order.id, sku: "ABC" });
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
- For a typed `.tables.*` API instead of string table names, see
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 and, via `typedDatabase`, a fully-typed `.tables.*` API.
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, table,
459
+ defineSchema,
354
460
  uuid, text, integer, boolean, timestamp, jsonb, enumType,
355
461
  } from "@palbase/backend";
356
462
 
357
463
  export default defineSchema({
358
- rooms: table("rooms", {
359
- id: uuid().primaryKey().defaultRandom(),
360
- name: text().notNull(),
361
- capacity: integer().nullable(),
362
- is_active: boolean().default(true),
363
- created_at: timestamp().defaultNow(),
364
- }),
365
- sessions: table("sessions", {
366
- id: uuid().primaryKey().defaultRandom(),
367
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
368
- user_id: uuid().notNull(),
369
- data: jsonb().nullable(),
370
- started_at: timestamp().defaultNow(),
371
- }),
372
- orders: table("orders", {
373
- id: uuid().primaryKey().defaultRandom(),
374
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
375
- amount: integer().notNull(),
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
- `typedDatabase(schema)` returns a typed facade. `insert` demands the right
400
- columns; rows come back typed; nullable columns are `T | null`.
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 { defineEndpoint, z, typedDatabase } from "@palbase/backend";
404
- import schema from "../../db/schema.js";
512
+ import { defineHandler, z, Database } from "@palbase/backend";
405
513
 
406
- const Db = typedDatabase(schema);
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 Db.tables.rooms.insert({ name: req.input.name });
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
- Note the `.js` extension on `../../db/schema.js` even though the file is
420
- `db/schema.ts` this is standard ESM module resolution; you still author the
421
- file as `.ts`.
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
- `Db.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
424
- `findById(id)`, `findMany(query?)`, and `Db.transaction(fn)` mirrors the
425
- untyped transaction with typed tables.
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
- In **endpoints**, import service singletons from `@palbase/backend`. In
434
- **workers/jobs/hooks/webhooks**, the equivalents live on `ctx` (`ctx.cache`,
435
- `ctx.queue`, `ctx.log`, `ctx.db`).
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 { defineEndpoint, z, Flags } from "@palbase/backend";
737
+ import { defineHandler, z, Flags } from "@palbase/backend";
524
738
 
525
- export default defineEndpoint({
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 endpoint; throw via `req.errors.<name>(...)`. Declared
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 { defineEndpoint, z, Database } from "@palbase/backend";
880
+ import { defineHandler, z, Database } from "@palbase/backend";
569
881
 
570
- export default defineEndpoint({
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 **`ctx` model**: their handler receives a context
597
- object with `ctx.db`, `ctx.log`, `ctx.cache`, `ctx.queue`, `ctx.env`. They do
598
- **not** receive a `req`, and you do **not** import the `Database` singleton
599
- inside them use `ctx.db`.
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 (ctx, payload) => {
618
- ctx.log.info(`processing ${payload.orderId} for $${payload.amount}`);
619
- await ctx.db.update("orders", payload.orderId, { status: "processed" });
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 (ctx) => {
644
- const expired = await ctx.db.findMany("sessions", { expired: true });
645
- for (const s of expired) await ctx.db.delete("sessions", s.id as string);
646
- ctx.log.info(`cleaned ${expired.length} sessions`);
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 **`ctx` model** (`ctx.db`,
658
- `ctx.log`, `ctx.cache`, `ctx.queue`, `ctx.env`) not `req`, and not imported
659
- singletons.
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 (ctx, event) => {
671
- ctx.log.info(`new user: ${event.user.email}`);
672
- await ctx.db.insert("profiles", {
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 (ctx, event) => {
679
- ctx.log.info(`sign in: ${event.user.email} via ${event.provider}`);
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 (ctx, event) => {
702
- await ctx.db.insert("orders", { status: "paid", data: event });
1023
+ "checkout.session.completed": async (event, meta) => {
1024
+ await Database.insert("orders", { status: "paid", data: event });
703
1025
  },
704
- "payment_intent.payment_failed": async (ctx, event) => {
705
- ctx.log.error("payment failed");
706
- await ctx.db.insert("payment_failures", { data: event });
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 read from the project's env/secrets (`{ env: "NAME" }`);
713
- the runtime verifies the signature before dispatching to your event handlers.
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 }`.