@palbase/backend 1.0.0 → 2.0.2

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.
@@ -0,0 +1,713 @@
1
+ # Palbase Backend SDK (`@palbase/backend`)
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.
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
+ ## The two mental models (important)
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` |
24
+
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`).
28
+
29
+ ## Project shape
30
+
31
+ ```
32
+ my-backend/
33
+ ├── package.json # depends on @palbase/backend
34
+ ├── endpoints/ # HTTP endpoints (file-based routing)
35
+ │ └── hello/get.ts # → GET /hello
36
+ ├── db/schema.ts # table definitions (optional, enables typed DB)
37
+ ├── workers/ # background job handlers (optional)
38
+ ├── jobs/ # cron-scheduled jobs (optional)
39
+ ├── hooks/ # auth/storage/document event hooks (optional)
40
+ ├── webhooks/ # inbound provider webhooks (optional)
41
+ └── middleware/ # cross-cutting request middleware (optional)
42
+ ```
43
+
44
+ ## Documentation
45
+
46
+ | Topic | File |
47
+ |-------|------|
48
+ | Getting started | [getting-started.md](./getting-started.md) |
49
+ | File-based routing | [routing.md](./routing.md) |
50
+ | Endpoints & `req` | [endpoints.md](./endpoints.md) |
51
+ | Database & transactions | [database.md](./database.md) |
52
+ | Schema & typed DB | [schema.md](./schema.md) |
53
+ | Services (Cache, Queue, Storage, …) | [services.md](./services.md) |
54
+ | Errors | [errors.md](./errors.md) |
55
+ | Workers & Jobs | [background.md](./background.md) |
56
+ | Hooks & Webhooks | [events.md](./events.md) |
57
+
58
+ For AI coding tools: a single concatenated corpus is generated at
59
+ [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
60
+
61
+
62
+
63
+ <!-- ===== getting-started.md ===== -->
64
+
65
+ # Getting started
66
+
67
+ There is no CLI init command. A starter project is created for you when your
68
+ Palbase project is provisioned. You then edit the files locally and deploy them.
69
+
70
+ ## package.json
71
+
72
+ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
73
+
74
+ ```json
75
+ {
76
+ "name": "my-backend",
77
+ "private": true,
78
+ "scripts": {
79
+ "dev": "palbase serve",
80
+ "deploy": "palbase push",
81
+ "typecheck": "tsc --noEmit"
82
+ },
83
+ "dependencies": { "@palbase/backend": "latest" },
84
+ "devDependencies": { "@types/node": "^22", "typescript": "^5" }
85
+ }
86
+ ```
87
+
88
+ ## Local dev loop
89
+
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`.
93
+
94
+ ## Your first endpoint
95
+
96
+ Create `endpoints/hello/get.ts`:
97
+
98
+ ```ts
99
+ import { defineEndpoint, z } from "@palbase/backend";
100
+
101
+ export default defineEndpoint({
102
+ method: "GET",
103
+ input: z.object({ name: z.string().optional() }),
104
+ output: z.object({ message: z.string(), user: z.string().nullable() }),
105
+ handler: async (req) => ({
106
+ message: `hello, ${req.input.name ?? "world"}!`,
107
+ user: req.user?.id ?? null,
108
+ }),
109
+ });
110
+ ```
111
+
112
+ 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.
114
+
115
+
116
+
117
+ <!-- ===== routing.md ===== -->
118
+
119
+ # File-based routing
120
+
121
+ The path of a file under `endpoints/` plus its filename determine the route.
122
+ The filename is the HTTP method.
123
+
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` |
131
+
132
+ Rules:
133
+
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.
138
+
139
+ ```ts
140
+ // endpoints/posts/[id]/get.ts → GET /posts/:id
141
+ import { defineEndpoint, z, Database, HttpError } from "@palbase/backend";
142
+
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
+ },
151
+ });
152
+ ```
153
+
154
+
155
+
156
+ <!-- ===== endpoints.md ===== -->
157
+
158
+ # Endpoints
159
+
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)).
163
+
164
+ ## `defineEndpoint` config
165
+
166
+ ```ts
167
+ defineEndpoint({
168
+ method: "POST", // required: GET | POST | PUT | PATCH | DELETE
169
+ auth: { required: true }, // optional; see Auth below. Omitted → public.
170
+ rateLimit: { max: 100, window: 60 }, // optional: max requests per window seconds
171
+ input: z.object({ ... }), // optional Zod schema → validates & types req.input
172
+ output: z.object({ ... }), // optional Zod schema → validates the return value
173
+ errors: { ... }, // optional declared errors (see errors.md)
174
+ middleware: [ ... ], // optional Middleware[] (see below)
175
+ handler: async (req) => { ... }, // required
176
+ });
177
+ ```
178
+
179
+ ## `req` (PBRequest)
180
+
181
+ | Field | Type | Notes |
182
+ |-------|------|-------|
183
+ | `req.input` | inferred from `input` schema | request body for POST/PUT/PATCH; `{}` otherwise |
184
+ | `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
185
+ | `req.query` | `Record<string,string>` | parsed query string |
186
+ | `req.headers` | `Record<string,string>` | lowercase keys |
187
+ | `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
188
+ | `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
189
+ | `req.file` | `FileContext \| null` | uploaded file, if any |
190
+ | `req.method` | `string` | the HTTP method |
191
+ | `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
192
+ | `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
193
+
194
+ `User` is `{ id: string; email: string; role: string; metadata: Record<string, unknown> }`.
195
+
196
+ ## Auth
197
+
198
+ ```ts
199
+ auth: { required: true } // require any authenticated user → req.user is non-null User
200
+ 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
203
+ ```
204
+
205
+ Whether `req.user` is non-null is computed from the `auth` config at the type
206
+ level:
207
+
208
+ | `auth` value | `req.user` type |
209
+ |--------------|-----------------|
210
+ | omitted | `User \| null` |
211
+ | `true` | `User` |
212
+ | `false` | `User \| null` |
213
+ | `{ required: true }` | `User` |
214
+ | `{ required: false }` | `User \| null` |
215
+ | `{ role: "admin" }` (object, no `required`) | `User` |
216
+
217
+ To enforce authentication, set `auth: { required: true }` (or `auth: true`). An
218
+ object with a `role` but no `required` key is treated as authenticated. When
219
+ `auth` is omitted, the endpoint is public and `req.user` may be null.
220
+
221
+ ## Typed input/output
222
+
223
+ ```ts
224
+ import { defineEndpoint, z, Database } from "@palbase/backend";
225
+
226
+ export default defineEndpoint({
227
+ method: "POST",
228
+ auth: { required: true },
229
+ input: z.object({ name: z.string().min(1).max(100), capacity: z.number().int().positive().optional() }),
230
+ output: z.object({ id: z.string(), name: z.string(), capacity: z.number().nullable() }),
231
+ handler: async (req) => {
232
+ const room = await Database.insert("rooms", {
233
+ name: req.input.name,
234
+ capacity: req.input.capacity ?? null,
235
+ });
236
+ return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
237
+ },
238
+ });
239
+ ```
240
+
241
+ ## Middleware
242
+
243
+ A middleware wraps a request. Define one in `middleware/<name>.ts`:
244
+
245
+ ```ts
246
+ // middleware/logger.ts
247
+ import { defineMiddleware } from "@palbase/backend";
248
+
249
+ export default defineMiddleware(async (ctx, next) => {
250
+ ctx.log.info(`start ${ctx.requestId}`);
251
+ await next();
252
+ ctx.log.info(`done ${ctx.requestId}`);
253
+ });
254
+ ```
255
+
256
+ The middleware handler receives `(ctx, next)` — call `await next()` to run the
257
+ rest of the chain (other middleware, then the endpoint handler). Note this uses
258
+ the `ctx` model, not `req`.
259
+
260
+ To attach middleware to a specific endpoint, import it and list it in the
261
+ endpoint's `middleware` array:
262
+
263
+ ```ts
264
+ import { defineEndpoint, z } from "@palbase/backend";
265
+ import logger from "../../middleware/logger.js";
266
+
267
+ export default defineEndpoint({
268
+ method: "GET",
269
+ middleware: [logger],
270
+ output: z.object({ ok: z.boolean() }),
271
+ handler: async (req) => ({ ok: true }),
272
+ });
273
+ ```
274
+
275
+
276
+
277
+ <!-- ===== database.md ===== -->
278
+
279
+ # Database
280
+
281
+ In **endpoints**, import the `Database` singleton:
282
+
283
+ ```ts
284
+ import { Database } from "@palbase/backend";
285
+ ```
286
+
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)).
290
+
291
+ ## Operations
292
+
293
+ | Method | Returns |
294
+ |--------|---------|
295
+ | `Database.insert(table, data)` | the inserted row (`Record<string, unknown>`) |
296
+ | `Database.update(table, id, data)` | the updated row |
297
+ | `Database.delete(table, id)` | `void` |
298
+ | `Database.findById(table, id)` | the row or `null` |
299
+ | `Database.findMany(table, query?)` | matching rows (array) |
300
+ | `Database.query(sql, params?)` | rows from a read-only SQL query (runs in a READ ONLY transaction) |
301
+ | `Database.transaction(fn)` | runs `fn(tx)` in a transaction |
302
+
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
+ `findMany`'s `query` is an equality filter: keys are ANDed together. For
312
+ anything richer (ranges, ordering, joins) use `Database.query`.
313
+
314
+ `Database.query` is **read-only** — use it for selects/joins the helpers don't
315
+ cover. Writes must go through `insert`/`update`/`delete` or a transaction.
316
+
317
+ ```ts
318
+ const rows = await Database.query(
319
+ "SELECT id, title FROM todos WHERE done = $1 ORDER BY created_at DESC LIMIT $2",
320
+ [false, 20],
321
+ );
322
+ ```
323
+
324
+ ## Transactions
325
+
326
+ `transaction(fn)` gives you a `tx` with the same DB ops (no nested
327
+ transaction). Returning commits; throwing rolls back.
328
+
329
+ ```ts
330
+ 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" });
333
+ // throw here → both inserts roll back
334
+ });
335
+ ```
336
+
337
+ For a typed `.tables.*` API instead of string table names, see
338
+ [schema.md](./schema.md).
339
+
340
+
341
+
342
+ <!-- ===== schema.md ===== -->
343
+
344
+ # Schema & typed database access
345
+
346
+ Declare your tables in `db/schema.ts` with `defineSchema`. This drives
347
+ migrations and, via `typedDatabase`, a fully-typed `.tables.*` API.
348
+
349
+ ## Defining a schema
350
+
351
+ ```ts
352
+ import {
353
+ defineSchema, table,
354
+ uuid, text, integer, boolean, timestamp, jsonb, enumType,
355
+ } from "@palbase/backend";
356
+
357
+ 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
+ }),
377
+ });
378
+ ```
379
+
380
+ ## Column builders
381
+
382
+ | Builder | Postgres type |
383
+ |---------|---------------|
384
+ | `uuid()` | `uuid` |
385
+ | `text()` | `text` |
386
+ | `integer()` | `integer` |
387
+ | `boolean()` | `boolean` |
388
+ | `timestamp()` | `timestamptz` |
389
+ | `jsonb()` | `jsonb` |
390
+ | `enumType(name, values)` | a Postgres enum |
391
+
392
+ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
393
+ `.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
394
+ `.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
395
+ `.onDelete("cascade" | "set null" | "restrict" | "no action")`.
396
+
397
+ ## Typed DB access
398
+
399
+ `typedDatabase(schema)` returns a typed facade. `insert` demands the right
400
+ columns; rows come back typed; nullable columns are `T | null`.
401
+
402
+ ```ts
403
+ import { defineEndpoint, z, typedDatabase } from "@palbase/backend";
404
+ import schema from "../../db/schema.js";
405
+
406
+ const Db = typedDatabase(schema);
407
+
408
+ export default defineEndpoint({
409
+ method: "POST",
410
+ input: z.object({ name: z.string() }),
411
+ output: z.object({ id: z.string(), name: z.string() }),
412
+ handler: async (req) => {
413
+ const room = await Db.tables.rooms.insert({ name: req.input.name });
414
+ return { id: room.id, name: room.name };
415
+ },
416
+ });
417
+ ```
418
+
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`.
422
+
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.
426
+
427
+
428
+
429
+ <!-- ===== services.md ===== -->
430
+
431
+ # Services
432
+
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`).
436
+
437
+ Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
438
+ `Log`, `Notifications`, `Flags`.
439
+
440
+ **Not available to backend handlers** (do not import them here): Realtime,
441
+ Functions, CMS, Links, Analytics, and Auth. Auth runs on the client SDK; the
442
+ others are out of scope for backend endpoints.
443
+
444
+ ## Cache
445
+
446
+ JSON-typed key/value cache.
447
+
448
+ ```ts
449
+ import { Cache } from "@palbase/backend";
450
+
451
+ await Cache.set("k", { hits: 1 }, 60); // value + TTL seconds
452
+ const v = await Cache.get<{ hits: number }>("k"); // typed, null on miss
453
+ await Cache.incr("counter");
454
+ await Cache.del("k");
455
+
456
+ // Stampede-safe read-through: only one caller across all pods runs fn.
457
+ const profile = await Cache.getOrSet("user:42", 300, async () => {
458
+ return Database.findById("users", "42");
459
+ });
460
+ ```
461
+
462
+ `getOrSet` caches whatever `fn` returns, including `null` — return a sentinel or
463
+ guard upstream if you don't want misses cached.
464
+
465
+ ## Queue
466
+
467
+ Enqueue work for a worker (see [background.md](./background.md)).
468
+
469
+ ```ts
470
+ import { Queue } from "@palbase/backend";
471
+ const { jobId } = await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
472
+ ```
473
+
474
+ ## Log
475
+
476
+ ```ts
477
+ import { Log } from "@palbase/backend";
478
+ Log.info("created order", { orderId });
479
+ Log.warn("retrying");
480
+ Log.error("failed", err);
481
+ Log.debug("detail");
482
+ ```
483
+
484
+ ## Storage
485
+
486
+ Bucket-scoped file operations.
487
+
488
+ ```ts
489
+ import { Storage } from "@palbase/backend";
490
+ const bucket = Storage.bucket("avatars");
491
+ const { data, error } = await bucket.upload("u/42.png", file);
492
+ const { data: signed } = await bucket.createSignedUrl("u/42.png", 3600);
493
+ const pub = bucket.getPublicUrl("u/42.png"); // sync, no network
494
+ await bucket.remove(["u/42.png"]);
495
+ ```
496
+
497
+ All Storage calls return `{ data, error }` — check `error` before using `data`.
498
+
499
+ ## Documents
500
+
501
+ Firestore-like document store.
502
+
503
+ ```ts
504
+ import { Documents } from "@palbase/backend";
505
+ const col = Documents.collection("rooms");
506
+ const { data: ref } = await col.add({ name: "Lobby" });
507
+ const snap = await col.where("active", "==", true).orderBy("name").limit(10).get();
508
+ const { data: doc } = await Documents.doc("rooms/abc").get();
509
+ ```
510
+
511
+ ## Notifications
512
+
513
+ ```ts
514
+ import { Notifications } from "@palbase/backend";
515
+ await Notifications.email.send({ /* PalbaseEmailSendParams */ });
516
+ await Notifications.push.send({ /* PalbasePushSendParams */ });
517
+ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
518
+ ```
519
+
520
+ ## Flags
521
+
522
+ ```ts
523
+ import { defineEndpoint, z, Flags } from "@palbase/backend";
524
+
525
+ export default defineEndpoint({
526
+ method: "GET",
527
+ auth: { required: true }, // req.user is non-null here
528
+ output: z.object({ enabled: z.boolean() }),
529
+ handler: async (req) => {
530
+ const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
531
+ const { data: variant } = await Flags.getVariant("button-color", { userId: req.user.id });
532
+ return { enabled: enabled ?? false };
533
+ },
534
+ });
535
+ ```
536
+
537
+
538
+
539
+ <!-- ===== errors.md ===== -->
540
+
541
+ # Errors
542
+
543
+ Two ways to fail a request. Both serialize to the standard Palbase error
544
+ envelope:
545
+
546
+ ```json
547
+ { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
548
+ ```
549
+
550
+ ## 1. `HttpError` (ad-hoc)
551
+
552
+ ```ts
553
+ import { HttpError } from "@palbase/backend";
554
+ throw new HttpError(404, "todo_not_found", "No such todo");
555
+ // optional structured payload (4th arg) rides along under `data`:
556
+ throw new HttpError(423, "todo_locked", "Locked", { retryAfter: 30 });
557
+ ```
558
+
559
+ `new HttpError(status, code, description, data?)`.
560
+
561
+ ## 2. Declared errors (typed)
562
+
563
+ Declare them on the endpoint; throw via `req.errors.<name>(...)`. Declared
564
+ errors are described in the endpoint's OpenAPI and codegen'd into a typed enum
565
+ for iOS callers.
566
+
567
+ ```ts
568
+ import { defineEndpoint, z, Database } from "@palbase/backend";
569
+
570
+ export default defineEndpoint({
571
+ method: "POST",
572
+ input: z.object({ id: z.string() }),
573
+ output: z.object({ ok: z.boolean() }),
574
+ errors: {
575
+ notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
576
+ locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
577
+ },
578
+ handler: async (req) => {
579
+ const todo = await Database.findById("todos", req.input.id);
580
+ if (!todo) throw req.errors.notFound(); // no data → no args
581
+ if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
582
+ return { ok: true };
583
+ },
584
+ });
585
+ ```
586
+
587
+ A declared error with a `data` Zod schema requires that payload as an argument;
588
+ one without `data` takes no arguments. This is enforced by the types.
589
+
590
+
591
+
592
+ <!-- ===== background.md ===== -->
593
+
594
+ # Workers & Jobs
595
+
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`.
600
+
601
+ ## Workers (queue consumers)
602
+
603
+ A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
604
+ `workers/`.
605
+
606
+ ```ts
607
+ // workers/process-order.ts
608
+ import { defineWorker } from "@palbase/backend";
609
+
610
+ interface OrderPayload { orderId: string; amount: number; }
611
+
612
+ export default defineWorker<OrderPayload>({
613
+ name: "process-order", // must match the Queue.push() name
614
+ retry: 5, // optional, default 3
615
+ timeout: 60, // optional, seconds
616
+ 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" });
620
+ },
621
+ });
622
+ ```
623
+
624
+ Enqueue from an endpoint:
625
+
626
+ ```ts
627
+ import { Queue } from "@palbase/backend";
628
+ await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
629
+ ```
630
+
631
+ ## Jobs (cron-scheduled)
632
+
633
+ A job runs on a cron schedule. File lives under `jobs/`.
634
+
635
+ ```ts
636
+ // jobs/cleanup.ts
637
+ import { defineJob } from "@palbase/backend";
638
+
639
+ export default defineJob({
640
+ name: "cleanup-expired",
641
+ schedule: "0 3 * * *", // standard cron
642
+ 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`);
647
+ },
648
+ });
649
+ ```
650
+
651
+
652
+
653
+ <!-- ===== events.md ===== -->
654
+
655
+ # Hooks & Webhooks
656
+
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.
660
+
661
+ ## Hooks (platform events)
662
+
663
+ React to auth, storage, and document events. Files live under `hooks/`. Builders
664
+ are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
665
+
666
+ ```ts
667
+ // hooks/auth.ts
668
+ import { auth } from "@palbase/backend";
669
+
670
+ export const onUserCreated = auth.onUserCreated(async (ctx, event) => {
671
+ ctx.log.info(`new user: ${event.user.email}`);
672
+ await ctx.db.insert("profiles", {
673
+ user_id: event.user.id,
674
+ email: event.user.email,
675
+ });
676
+ });
677
+
678
+ export const onSignIn = auth.onSignIn(async (ctx, event) => {
679
+ ctx.log.info(`sign in: ${event.user.email} via ${event.provider}`);
680
+ });
681
+ ```
682
+
683
+ Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
684
+ `auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
685
+ `documents.onDocumentCreated`, `documents.onDocumentUpdated`,
686
+ `documents.onDocumentDeleted`.
687
+
688
+ ## Webhooks (inbound provider events)
689
+
690
+ Receive and verify webhooks from third-party providers. Files live under
691
+ `webhooks/`.
692
+
693
+ ```ts
694
+ // webhooks/stripe.ts
695
+ import { defineWebhook } from "@palbase/backend";
696
+
697
+ export default defineWebhook({
698
+ provider: "stripe",
699
+ secret: { env: "STRIPE_WEBHOOK_SECRET" }, // signing secret resolved from env
700
+ events: {
701
+ "checkout.session.completed": async (ctx, event) => {
702
+ await ctx.db.insert("orders", { status: "paid", data: event });
703
+ },
704
+ "payment_intent.payment_failed": async (ctx, event) => {
705
+ ctx.log.error("payment failed");
706
+ await ctx.db.insert("payment_failures", { data: event });
707
+ },
708
+ },
709
+ });
710
+ ```
711
+
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.