@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -4
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# @spfn/core/job — pg-boss background jobs
|
|
2
|
+
|
|
3
|
+
Type-safe background jobs on PostgreSQL (pg-boss): a fluent `job()` builder with typed
|
|
4
|
+
input/output, cron scheduling, run-once, event-driven triggers, batch processing, and
|
|
5
|
+
compensation — grouped in a `JobRouter` and registered through `defineServerConfig()`.
|
|
6
|
+
|
|
7
|
+
## Import paths
|
|
8
|
+
|
|
9
|
+
One entry point. pg-boss is a peer dependency you install yourself.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
job, defineJobRouter,
|
|
14
|
+
initBoss, getBoss, stopBoss, isBossRunning, registerJobs,
|
|
15
|
+
} from '@spfn/core/job';
|
|
16
|
+
import { defineEvent } from '@spfn/core/event'; // for .on(event) jobs
|
|
17
|
+
import { Type } from '@sinclair/typebox'; // for .input()/.output() schemas
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add pg-boss # required peer dependency
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
In a normal SPFN app you do **not** call `initBoss` / `registerJobs` yourself — wiring a
|
|
25
|
+
`JobRouter` into `defineServerConfig().jobs(...)` does both at server start (see Quick Start).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Public API (complete)
|
|
30
|
+
|
|
31
|
+
From `@spfn/core/job`:
|
|
32
|
+
|
|
33
|
+
- Builder: `job(name)` → `JobBuilder`
|
|
34
|
+
- Router: `defineJobRouter(jobs)`, `collectJobs(router, prefix?)`, `isJobDef(v)`, `isJobRouter(v)`
|
|
35
|
+
- pg-boss lifecycle: `initBoss(options)`, `getBoss()`, `stopBoss()`, `isBossRunning()`,
|
|
36
|
+
`shouldClearOnStart()`
|
|
37
|
+
- Registration: `registerJobs(router)` — **takes a `JobRouter`, not an array**
|
|
38
|
+
- Types: `JobDef`, `JobRouter`, `JobRouterEntry`, `JobOptions`, `JobSendOptions`,
|
|
39
|
+
`JobHandler`, `CompensateHandler`, `InferJobInput`, `InferJobOutput`, `BossOptions`,
|
|
40
|
+
`BossConfig` (deprecated alias of `BossOptions`)
|
|
41
|
+
|
|
42
|
+
`JobBuilder` methods: `.input(schema)`, `.output(schema)`, `.on(event)`, `.cron(expr)`,
|
|
43
|
+
`.runOnce()`, `.options(opts)`, `.timeout(ms)`, `.compensate(fn)`, `.handler(fn)`.
|
|
44
|
+
|
|
45
|
+
`JobDef` methods (returned by `.handler()`): `.send(input?, opts?)`,
|
|
46
|
+
`.sendBatch(inputs?, opts?)`, `.run(input?)`.
|
|
47
|
+
|
|
48
|
+
### Removed API — do not use
|
|
49
|
+
|
|
50
|
+
The old `core/docs/job.md` documents an API that **no longer exists**. None of these are
|
|
51
|
+
exported; using them will not compile:
|
|
52
|
+
|
|
53
|
+
| Removed | Use instead |
|
|
54
|
+
|---------|-------------|
|
|
55
|
+
| `defineJob({ name, handler })` | `job(name).input(...).handler(...)` builder |
|
|
56
|
+
| `enqueue(jobDef, payload, opts)` | `jobDef.send(input, opts)` |
|
|
57
|
+
| `schedule(name, cron, fn)` | `job(name).cron(expr).handler(fn)` |
|
|
58
|
+
| `registerJobs([jobA, jobB])` (array) | `registerJobs(defineJobRouter({ jobA, jobB }))` |
|
|
59
|
+
| `concurrency`, `attempts`, `backoff`, `delay`, `priority: 'high'` options | `batchSize`, `retryLimit`, `retryDelay`, `startAfter`, numeric `priority` |
|
|
60
|
+
|
|
61
|
+
`registerJobs` exists but its signature changed: it accepts a **`JobRouter`**, not an array
|
|
62
|
+
of jobs.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Quick Start
|
|
67
|
+
|
|
68
|
+
### 1. Define jobs
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// src/server/jobs/send-email.job.ts
|
|
72
|
+
import { job } from '@spfn/core/job';
|
|
73
|
+
import { Type } from '@sinclair/typebox';
|
|
74
|
+
|
|
75
|
+
export const sendEmailJob = job('send-email')
|
|
76
|
+
.input(Type.Object({
|
|
77
|
+
to: Type.String(),
|
|
78
|
+
subject: Type.String(),
|
|
79
|
+
body: Type.String(),
|
|
80
|
+
}))
|
|
81
|
+
.options({ retryLimit: 3 })
|
|
82
|
+
.handler(async (input) =>
|
|
83
|
+
{
|
|
84
|
+
await emailService.send(input.to, input.subject, input.body);
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Group into a router
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// src/server/jobs/index.ts
|
|
92
|
+
import { defineJobRouter } from '@spfn/core/job';
|
|
93
|
+
import { sendEmailJob } from './send-email.job';
|
|
94
|
+
|
|
95
|
+
export const jobRouter = defineJobRouter({ sendEmailJob });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Register with the server (does initBoss + registerJobs)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// server.config.ts
|
|
102
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
103
|
+
import { appRouter } from './routes';
|
|
104
|
+
import { jobRouter } from './jobs';
|
|
105
|
+
|
|
106
|
+
export default defineServerConfig()
|
|
107
|
+
.routes(appRouter)
|
|
108
|
+
.jobs(jobRouter) // connectionString comes from env.DATABASE_URL automatically
|
|
109
|
+
.build();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 4. Trigger jobs
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
await sendEmailJob.send({ to: 'user@example.com', subject: 'Welcome', body: 'Hi!' });
|
|
116
|
+
await sendEmailJob.run({ to: 'test@example.com', subject: 'Test', body: 'x' }); // sync, for tests
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Job types
|
|
122
|
+
|
|
123
|
+
`job(name)` starts a builder. The terminal `.handler(fn)` finalizes and returns a `JobDef`.
|
|
124
|
+
The builder kind is selected by which modifier you chain — they are not mutually exclusive in
|
|
125
|
+
type, but a job should be *one* of these:
|
|
126
|
+
|
|
127
|
+
### Standard — triggered via `.send()`
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const typedJob = job('typed')
|
|
131
|
+
.input(Type.Object({ userId: Type.String(), action: Type.String() }))
|
|
132
|
+
.handler(async (input) =>
|
|
133
|
+
{
|
|
134
|
+
// input: { userId: string; action: string }
|
|
135
|
+
await processAction(input.userId, input.action);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const noInput = job('simple').handler(async () => { await db.cleanup(); });
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Cron — scheduled
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const dailyReport = job('daily-report')
|
|
145
|
+
.cron('0 9 * * *') // every day 09:00
|
|
146
|
+
.handler(async () => { await reportService.generateDaily(); });
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Cron | Meaning |
|
|
150
|
+
|------|---------|
|
|
151
|
+
| `*/5 * * * *` | every 5 minutes |
|
|
152
|
+
| `0 * * * *` | every hour |
|
|
153
|
+
| `0 9 * * *` | every day 09:00 |
|
|
154
|
+
| `0 0 * * 0` | every Sunday 00:00 |
|
|
155
|
+
| `0 0 1 * *` | first day of month |
|
|
156
|
+
|
|
157
|
+
Cron jobs take **no input** — pg-boss invokes the handler with `{}`.
|
|
158
|
+
|
|
159
|
+
Removing a cron job from the router leaves its schedule behind in the DB; the opt-in
|
|
160
|
+
[orphan schedule sweep](#orphan-schedule-sweep) unschedules it on the next boot.
|
|
161
|
+
|
|
162
|
+
### RunOnce — once per server start
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const initCache = job('init-cache')
|
|
166
|
+
.runOnce()
|
|
167
|
+
.handler(async () => { await cache.warmup(); });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Enqueued with `singletonKey: 'runOnce:<name>'` so concurrent instances don't double-run it.
|
|
171
|
+
|
|
172
|
+
### Event-driven — `.on(event)`
|
|
173
|
+
|
|
174
|
+
Subscribe to an event; the input type is **inferred from the event payload**. Emitting the
|
|
175
|
+
event enqueues the job (decoupled — one event can drive many jobs).
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { defineEvent } from '@spfn/core/event';
|
|
179
|
+
|
|
180
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
181
|
+
userId: Type.String(),
|
|
182
|
+
email: Type.String(),
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
export const sendWelcome = job('send-welcome')
|
|
186
|
+
.on(userCreated) // input typed as { userId: string; email: string }
|
|
187
|
+
.handler(async (payload) => { await emailService.sendWelcome(payload.email); });
|
|
188
|
+
|
|
189
|
+
await userCreated.emit({ userId: '123', email: 'user@example.com' }); // triggers sendWelcome
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`.on()` consumes the event's queue (`event:<name>`), not `<job.name>`. You do **not** call
|
|
193
|
+
`.send()` on an event-driven job — emit the event instead.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Job options (`.options()`)
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
job('important-task')
|
|
201
|
+
.input(Type.Object({ id: Type.String() }))
|
|
202
|
+
.options({
|
|
203
|
+
retryLimit: 5, // max retries (default 3)
|
|
204
|
+
retryDelay: 5000, // ms between retries (default 1000)
|
|
205
|
+
expireInSeconds: 600, // handler timeout (default 300)
|
|
206
|
+
priority: 10, // higher = first (default 0)
|
|
207
|
+
singletonKey: 'unique', // dedupe key
|
|
208
|
+
retentionSeconds: 86400,// keep completed jobs (default 604800 = 7d)
|
|
209
|
+
batchSize: 1, // jobs per worker poll(default 1)
|
|
210
|
+
})
|
|
211
|
+
.handler(async (input) => { await processImportant(input.id); });
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`.timeout(ms)` is sugar for `expireInSeconds` (`Math.ceil(ms / 1000)`):
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
job('quick').timeout(10000).handler(async () => { /* ... */ }); // expireInSeconds: 10
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| Option | Type | Default | Notes |
|
|
221
|
+
|--------|------|---------|-------|
|
|
222
|
+
| `retryLimit` | number | 3 | max retry attempts |
|
|
223
|
+
| `retryDelay` | number | 1000 | ms between retries |
|
|
224
|
+
| `expireInSeconds` | number | 300 | handler timeout (seconds) |
|
|
225
|
+
| `priority` | number | 0 | higher = processed first |
|
|
226
|
+
| `singletonKey` | string | — | only one active job per key |
|
|
227
|
+
| `retentionSeconds` | number | 604800 | completed-job retention |
|
|
228
|
+
| `batchSize` | number | 1 | jobs fetched per worker poll; `> 1` ⇒ parallel |
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Sending jobs
|
|
233
|
+
|
|
234
|
+
### `.send(input?, options?)` → `Promise<string | null>`
|
|
235
|
+
|
|
236
|
+
Enqueue one job (returns the pg-boss job id, or `null` if deduped). Jobs with no input
|
|
237
|
+
schema take only the options arg.
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
await sendEmailJob.send({ to: 'u@x.com', subject: 'Hi', body: '...' });
|
|
241
|
+
|
|
242
|
+
await sendEmailJob.send(
|
|
243
|
+
{ to: 'u@x.com', subject: 'Hi', body: '...' },
|
|
244
|
+
{
|
|
245
|
+
startAfter: 60, // delay seconds — or a Date
|
|
246
|
+
priority: 10, // override default priority
|
|
247
|
+
singletonKey: 'welcome-u@x.com', // dedupe this invocation
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`JobSendOptions`: `startAfter?: number | Date`, `priority?: number`, `singletonKey?: string`.
|
|
253
|
+
Send-time options override the job's defaults.
|
|
254
|
+
|
|
255
|
+
### `.sendBatch(inputs?, options?)` → `Promise<void>`
|
|
256
|
+
|
|
257
|
+
Bulk-insert via `pg-boss.insert()` — a single query, far faster than a `.send()` loop.
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
await sendEmailJob.sendBatch(
|
|
261
|
+
users.map(u => ({ to: u.email, subject: 'Welcome', body: render(u) })),
|
|
262
|
+
);
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Combine with `batchSize` for distributed parallel processing: each worker fetches `batchSize`
|
|
266
|
+
jobs and runs them with `Promise.allSettled`; failed jobs are individually marked
|
|
267
|
+
(`boss.fail`) and retried — the whole batch does not fail together. pg-boss advisory locks
|
|
268
|
+
prevent duplicate processing across instances.
|
|
269
|
+
|
|
270
|
+
### `.run(input?)` → `Promise<TOutput>`
|
|
271
|
+
|
|
272
|
+
Invoke the handler **synchronously, bypassing pg-boss** — for unit tests / debugging only.
|
|
273
|
+
No queue, no retries, no boss required.
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
await sendEmailJob.run({ to: 't@x.com', subject: 'Test', body: 'x' });
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Compensation & output (workflow/saga)
|
|
282
|
+
|
|
283
|
+
`.output(schema)` types the handler's return; `.compensate(fn)` defines rollback. These are
|
|
284
|
+
hooks for workflow/saga orchestration — they do not run automatically on plain `.send()`.
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
export const chargePayment = job('charge-payment')
|
|
288
|
+
.input(Type.Object({ orderId: Type.String(), amount: Type.Number() }))
|
|
289
|
+
.output(Type.Object({ chargeId: Type.String() }))
|
|
290
|
+
.compensate(async (input, output) =>
|
|
291
|
+
{
|
|
292
|
+
await paymentService.refund(input.orderId, input.amount); // rollback
|
|
293
|
+
})
|
|
294
|
+
.handler(async (input) =>
|
|
295
|
+
{
|
|
296
|
+
return await paymentService.charge(input.orderId, input.amount);
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Job router
|
|
303
|
+
|
|
304
|
+
`defineJobRouter` groups jobs (flat, nested, or mixed). `collectJobs` flattens nested
|
|
305
|
+
routers — nested keys become dotted names (`email.sendWelcome`).
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// flat
|
|
309
|
+
export const jobRouter = defineJobRouter({ sendWelcome, dailyReport, initCache });
|
|
310
|
+
|
|
311
|
+
// nested + mixed
|
|
312
|
+
export const jobRouter = defineJobRouter({
|
|
313
|
+
initCache, // flat
|
|
314
|
+
email: defineJobRouter({ // nested → 'email.sendWelcome', ...
|
|
315
|
+
sendWelcome,
|
|
316
|
+
sendReset,
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Server wiring
|
|
324
|
+
|
|
325
|
+
`defineServerConfig().jobs(router, config?)` at server start: reads `env.DATABASE_URL`, calls
|
|
326
|
+
`initBoss({ connectionString: DATABASE_URL, ...config })`, then `registerJobs(router)`.
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
export default defineServerConfig()
|
|
330
|
+
.routes(appRouter)
|
|
331
|
+
.jobs(jobRouter, {
|
|
332
|
+
schema: 'spfn_queue', // default 'spfn_queue'
|
|
333
|
+
clearOnStart: process.env.NODE_ENV === 'development',
|
|
334
|
+
})
|
|
335
|
+
.build();
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The second arg is `Omit<BossOptions, 'connectionString'>` — **do not pass
|
|
339
|
+
`connectionString`** here; it is taken from `env.DATABASE_URL`. If `DATABASE_URL` is unset,
|
|
340
|
+
server start throws `"Jobs require database connection."`.
|
|
341
|
+
|
|
342
|
+
### `BossOptions` (for manual `initBoss`)
|
|
343
|
+
|
|
344
|
+
| Option | Type | Default | Notes |
|
|
345
|
+
|--------|------|---------|-------|
|
|
346
|
+
| `connectionString` | string | (required) | PostgreSQL URL (auto-supplied by `.jobs()`) |
|
|
347
|
+
| `schema` | string | `'spfn_queue'` | pg-boss tables live here |
|
|
348
|
+
| `maintenanceIntervalSeconds` | number | 120 | cleanup/archive interval |
|
|
349
|
+
| `monitorIntervalSeconds` | number | — | state-change events; must be `>= 1` |
|
|
350
|
+
| `clearOnStart` | boolean | false | delete pending jobs on boot (dev only) |
|
|
351
|
+
| `sweepOrphanSchedules` | boolean | false | unschedule crons no longer declared on any registered router (see below) |
|
|
352
|
+
|
|
353
|
+
### Orphan schedule sweep
|
|
354
|
+
|
|
355
|
+
`boss.schedule` rows persist in the DB, so removing a cron job from the router leaves its
|
|
356
|
+
schedule behind — pg-boss keeps creating jobs on every cron tick with no worker to consume
|
|
357
|
+
them, and they pile up forever. With `sweepOrphanSchedules: true`, `registerJobs` runs a
|
|
358
|
+
sweep once after registration: any schedule whose name is not a cron job declared on a
|
|
359
|
+
router registered in this process is unscheduled. Queues and existing job rows are never
|
|
360
|
+
deleted — the sweep only stops the pile-up. Cron names accumulate across `registerJobs`
|
|
361
|
+
calls, so registering several routers is safe. When no cron job has been declared at all,
|
|
362
|
+
the sweep is skipped. Failures only log an error; startup is never blocked.
|
|
363
|
+
|
|
364
|
+
Leave the sweep disabled (the default) when any of these apply, because the sweep only
|
|
365
|
+
knows the routers registered in this process and would unschedule everything else:
|
|
366
|
+
|
|
367
|
+
- multiple apps share the same pg-boss schema,
|
|
368
|
+
- schedules are created directly via `getBoss().schedule(...)`,
|
|
369
|
+
- rolling deploys can boot an older router version while a newer one is live.
|
|
370
|
+
|
|
371
|
+
`sslmode=require`/`prefer` in the URL is rewritten to `ssl: { rejectUnauthorized: false }`
|
|
372
|
+
so self-signed certs work.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Pitfalls & anti-patterns
|
|
377
|
+
|
|
378
|
+
- **`registerJobs` takes a `JobRouter`, not an array.** `registerJobs([a, b])` is the removed
|
|
379
|
+
API. Use `registerJobs(defineJobRouter({ a, b }))` — or just `.jobs(router)`, which is the
|
|
380
|
+
normal path and also runs `initBoss`.
|
|
381
|
+
- **Don't call `initBoss` / `registerJobs` yourself in an SPFN app.** `defineServerConfig().jobs()`
|
|
382
|
+
does both. Calling `initBoss` twice logs `"pg-boss already initialized"` and returns the
|
|
383
|
+
existing instance (the second config is ignored).
|
|
384
|
+
- **`.send()` before the boss is up throws** `"pg-boss not initialized"`. Only call `.send()`
|
|
385
|
+
after the server has started (e.g. inside route/job handlers), never at module top-level.
|
|
386
|
+
- **Don't pass `connectionString` to `.jobs()`.** Its config is `Omit<BossOptions,
|
|
387
|
+
'connectionString'>`; the connection comes from `env.DATABASE_URL`. (Old docs showing
|
|
388
|
+
`.jobs(router, { connectionString })` are wrong.)
|
|
389
|
+
- **Event-driven jobs are triggered by `emit()`, not `.send()`.** `.on(event)` binds the job
|
|
390
|
+
to the `event:<name>` queue; emitting the event enqueues it. Calling `.send()` on such a job
|
|
391
|
+
targets the wrong queue.
|
|
392
|
+
- **`.run()` is not `.send()`.** `.run()` executes the handler inline with no queue, retries,
|
|
393
|
+
or timeout — tests/debugging only. Production dispatch is `.send()` / `.sendBatch()`.
|
|
394
|
+
- **Cron and runOnce jobs take no input.** They are invoked with `{}`. Don't give them
|
|
395
|
+
`.input()` and expect data.
|
|
396
|
+
- **Job names must be unique across the (flattened) router.** Two jobs sharing a `name` map to
|
|
397
|
+
the same pg-boss queue and collide. Nested router keys are namespaced into the *router* path
|
|
398
|
+
but the pg-boss queue is `job.name` — keep `name` strings unique.
|
|
399
|
+
- **`clearOnStart: true` deletes pending/scheduled jobs on boot.** Development only — it wipes
|
|
400
|
+
queued work (and the subscribed event queues) every restart.
|
|
401
|
+
- **`expireInSeconds` is a handler timeout, not a delay.** A handler exceeding it is treated as
|
|
402
|
+
failed and retried (up to `retryLimit`). Make handlers idempotent.
|
|
403
|
+
- **`batchSize > 1` runs jobs concurrently.** Handlers must be safe to run in parallel;
|
|
404
|
+
failures are isolated per job, not per batch.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Complete example
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
// src/server/jobs/index.ts
|
|
412
|
+
import { job, defineJobRouter } from '@spfn/core/job';
|
|
413
|
+
import { defineEvent } from '@spfn/core/event';
|
|
414
|
+
import { Type } from '@sinclair/typebox';
|
|
415
|
+
|
|
416
|
+
// event-driven
|
|
417
|
+
export const userCreated = defineEvent('user.created', Type.Object({
|
|
418
|
+
userId: Type.String(),
|
|
419
|
+
email: Type.String(),
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
export const sendWelcome = job('send-welcome')
|
|
423
|
+
.on(userCreated)
|
|
424
|
+
.options({ retryLimit: 3, retryDelay: 5000 })
|
|
425
|
+
.handler(async (p) => { await emailService.sendWelcome(p.email); });
|
|
426
|
+
|
|
427
|
+
// standard + idempotent
|
|
428
|
+
export const processOrder = job('process-order')
|
|
429
|
+
.input(Type.Object({ orderId: Type.String() }))
|
|
430
|
+
.options({ retryLimit: 3 })
|
|
431
|
+
.handler(async (input) =>
|
|
432
|
+
{
|
|
433
|
+
const order = await orderRepo.findById(input.orderId);
|
|
434
|
+
if (order.status === 'processed') return; // idempotent guard
|
|
435
|
+
await processOrderLogic(order);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// batch
|
|
439
|
+
export const sendBulkEmail = job('send-bulk-email')
|
|
440
|
+
.input(Type.Object({ to: Type.String(), subject: Type.String(), html: Type.String() }))
|
|
441
|
+
.options({ retryLimit: 3, batchSize: 50 })
|
|
442
|
+
.handler(async (input) => { await emailProvider.send(input); });
|
|
443
|
+
|
|
444
|
+
// cron
|
|
445
|
+
export const dailyReport = job('daily-report')
|
|
446
|
+
.cron('0 9 * * *')
|
|
447
|
+
.handler(async () => { await reportService.generateDaily(); });
|
|
448
|
+
|
|
449
|
+
// runOnce
|
|
450
|
+
export const initCache = job('init-cache')
|
|
451
|
+
.runOnce()
|
|
452
|
+
.handler(async () => { await cache.warmup(); });
|
|
453
|
+
|
|
454
|
+
export const jobRouter = defineJobRouter({
|
|
455
|
+
sendWelcome, processOrder, sendBulkEmail, dailyReport, initCache,
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
// server.config.ts
|
|
461
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
462
|
+
import { appRouter } from './routes';
|
|
463
|
+
import { jobRouter } from './jobs';
|
|
464
|
+
|
|
465
|
+
export default defineServerConfig()
|
|
466
|
+
.routes(appRouter)
|
|
467
|
+
.jobs(jobRouter, { clearOnStart: process.env.NODE_ENV === 'development' })
|
|
468
|
+
.build();
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// dispatch (inside a handler, after server start)
|
|
473
|
+
await processOrder.send({ orderId: 'o-1' });
|
|
474
|
+
await sendBulkEmail.sendBatch(users.map(u => ({ to: u.email, subject: 'Hi', html: render(u) })));
|
|
475
|
+
await userCreated.emit({ userId: '123', email: 'u@x.com' }); // → sendWelcome
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// test
|
|
480
|
+
import { processOrder } from './jobs';
|
|
481
|
+
|
|
482
|
+
it('processes an order', async () =>
|
|
483
|
+
{
|
|
484
|
+
await processOrder.run({ orderId: 'o-1' }); // sync, no pg-boss
|
|
485
|
+
expect(orderRepo.findById).toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Types reference
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
import type {
|
|
495
|
+
JobDef, JobRouter, JobRouterEntry,
|
|
496
|
+
JobOptions, JobSendOptions, JobHandler, CompensateHandler,
|
|
497
|
+
InferJobInput, InferJobOutput, BossOptions,
|
|
498
|
+
} from '@spfn/core/job';
|
|
499
|
+
|
|
500
|
+
type SendInput = InferJobInput<typeof processOrder>; // { orderId: string }
|
|
501
|
+
type ChargeOut = InferJobOutput<typeof chargePayment>; // { chargeId: string }
|
|
502
|
+
|
|
503
|
+
// JobHandler<TInput, TOutput> = TInput extends void
|
|
504
|
+
// ? () => Promise<TOutput>
|
|
505
|
+
// : (input: TInput) => Promise<TOutput>;
|
|
506
|
+
// CompensateHandler<TInput, TOutput> = (input: TInput, output: TOutput) => Promise<void>;
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Related
|
|
510
|
+
|
|
511
|
+
- [@spfn/core/event](../event/README.md) — events drive `.on(event)` jobs; emit triggers them
|
|
512
|
+
- [@spfn/core/server](../server/README.md) — `defineServerConfig().jobs()` wiring
|
|
513
|
+
- [@spfn/core/env](../env/README.md) — `DATABASE_URL` powers the job connection
|
|
514
|
+
- [pg-boss](https://github.com/timgit/pg-boss) — underlying queue engine
|