@spfn/core 0.3.0-beta.5 → 0.3.0-beta.7

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,609 @@
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)`, `mergeJobRouters(base, extra)`, `collectJobs(router, prefix?)`,
35
+ `isJobDef(v)`, `isJobRouter(v)`
36
+ - Queue policy: `resolveQueuePolicy(job)`
37
+ - pg-boss lifecycle: `initBoss(options)`, `getBoss()`, `stopBoss()`, `isBossRunning()`,
38
+ `shouldClearOnStart()`
39
+ - Registration: `registerJobs(router)` — **takes a `JobRouter`, not an array**
40
+ - Types: `JobDef`, `JobRouter`, `JobRouterEntry`, `JobOptions`, `JobQueuePolicy`,
41
+ `JobSendOptions`, `JobHandler`, `CompensateHandler`, `InferJobInput`, `InferJobOutput`,
42
+ `BossOptions`, `BossConfig` (deprecated alias of `BossOptions`)
43
+
44
+ `JobBuilder` methods: `.input(schema)`, `.output(schema)`, `.on(event)`, `.cron(expr)`,
45
+ `.runOnce()`, `.options(opts)`, `.timeout(ms)`, `.compensate(fn)`, `.handler(fn)`.
46
+
47
+ `JobDef` methods (returned by `.handler()`): `.send(input?, opts?)`,
48
+ `.sendBatch(inputs?, opts?)`, `.run(input?)`.
49
+
50
+ ### Removed API — do not use
51
+
52
+ The old `core/docs/job.md` documents an API that **no longer exists**. None of these are
53
+ exported; using them will not compile:
54
+
55
+ | Removed | Use instead |
56
+ |---------|-------------|
57
+ | `defineJob({ name, handler })` | `job(name).input(...).handler(...)` builder |
58
+ | `enqueue(jobDef, payload, opts)` | `jobDef.send(input, opts)` |
59
+ | `schedule(name, cron, fn)` | `job(name).cron(expr).handler(fn)` |
60
+ | `registerJobs([jobA, jobB])` (array) | `registerJobs(defineJobRouter({ jobA, jobB }))` |
61
+ | `concurrency`, `attempts`, `backoff`, `delay`, `priority: 'high'` options | `batchSize`, `retryLimit`, `retryDelay`, `startAfter`, numeric `priority` |
62
+
63
+ `registerJobs` exists but its signature changed: it accepts a **`JobRouter`**, not an array
64
+ of jobs.
65
+
66
+ ---
67
+
68
+ ## Quick Start
69
+
70
+ ### 1. Define jobs
71
+
72
+ ```typescript
73
+ // src/server/jobs/send-email.job.ts
74
+ import { job } from '@spfn/core/job';
75
+ import { Type } from '@sinclair/typebox';
76
+
77
+ export const sendEmailJob = job('send-email')
78
+ .input(Type.Object({
79
+ to: Type.String(),
80
+ subject: Type.String(),
81
+ body: Type.String(),
82
+ }))
83
+ .options({ retryLimit: 3 })
84
+ .handler(async (input) =>
85
+ {
86
+ await emailService.send(input.to, input.subject, input.body);
87
+ });
88
+ ```
89
+
90
+ ### 2. Group into a router
91
+
92
+ ```typescript
93
+ // src/server/jobs/index.ts
94
+ import { defineJobRouter } from '@spfn/core/job';
95
+ import { sendEmailJob } from './send-email.job';
96
+
97
+ export const jobRouter = defineJobRouter({ sendEmailJob });
98
+ ```
99
+
100
+ ### 3. Register with the server (does initBoss + registerJobs)
101
+
102
+ ```typescript
103
+ // server.config.ts
104
+ import { defineServerConfig } from '@spfn/core/server';
105
+ import { appRouter } from './routes';
106
+ import { jobRouter } from './jobs';
107
+
108
+ export default defineServerConfig()
109
+ .routes(appRouter)
110
+ .jobs(jobRouter) // connectionString comes from env.DATABASE_URL automatically
111
+ .build();
112
+ ```
113
+
114
+ ### 4. Trigger jobs
115
+
116
+ ```typescript
117
+ await sendEmailJob.send({ to: 'user@example.com', subject: 'Welcome', body: 'Hi!' });
118
+ await sendEmailJob.run({ to: 'test@example.com', subject: 'Test', body: 'x' }); // sync, for tests
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Job types
124
+
125
+ `job(name)` starts a builder. The terminal `.handler(fn)` finalizes and returns a `JobDef`.
126
+ The builder kind is selected by which modifier you chain — they are not mutually exclusive in
127
+ type, but a job should be *one* of these:
128
+
129
+ ### Standard — triggered via `.send()`
130
+
131
+ ```typescript
132
+ const typedJob = job('typed')
133
+ .input(Type.Object({ userId: Type.String(), action: Type.String() }))
134
+ .handler(async (input) =>
135
+ {
136
+ // input: { userId: string; action: string }
137
+ await processAction(input.userId, input.action);
138
+ });
139
+
140
+ const noInput = job('simple').handler(async () => { await db.cleanup(); });
141
+ ```
142
+
143
+ ### Cron — scheduled
144
+
145
+ ```typescript
146
+ const dailyReport = job('daily-report')
147
+ .cron('0 9 * * *') // every day 09:00
148
+ .handler(async () => { await reportService.generateDaily(); });
149
+ ```
150
+
151
+ | Cron | Meaning |
152
+ |------|---------|
153
+ | `*/5 * * * *` | every 5 minutes |
154
+ | `0 * * * *` | every hour |
155
+ | `0 9 * * *` | every day 09:00 |
156
+ | `0 0 * * 0` | every Sunday 00:00 |
157
+ | `0 0 1 * *` | first day of month |
158
+
159
+ Cron jobs take **no input** — pg-boss invokes the handler with `{}`.
160
+
161
+ Removing a cron job from the router leaves its schedule behind in the DB; the opt-in
162
+ [orphan schedule sweep](#orphan-schedule-sweep) unschedules it on the next boot.
163
+
164
+ ### RunOnce — once per server start
165
+
166
+ ```typescript
167
+ const initCache = job('init-cache')
168
+ .runOnce()
169
+ .handler(async () => { await cache.warmup(); });
170
+ ```
171
+
172
+ Enqueued with `singletonKey: 'runOnce:<name>'` on an `exclusive` queue, so concurrent
173
+ instances don't double-run it.
174
+
175
+ `exclusive` deduplicates against the `created`, `retry` and `active` states, so a boot while
176
+ the previous run is still queued or running is dropped. Once that run *completes*, the next
177
+ boot enqueues it again — the guarantee is **once per server start**, not once ever. For
178
+ once-ever semantics, keep a marker of your own in the database and return early.
179
+
180
+ ### Event-driven — `.on(event)`
181
+
182
+ Subscribe to an event; the input type is **inferred from the event payload**. Emitting the
183
+ event enqueues the job (decoupled — one event can drive many jobs).
184
+
185
+ ```typescript
186
+ import { defineEvent } from '@spfn/core/event';
187
+
188
+ export const userCreated = defineEvent('user.created', Type.Object({
189
+ userId: Type.String(),
190
+ email: Type.String(),
191
+ }));
192
+
193
+ export const sendWelcome = job('send-welcome')
194
+ .on(userCreated) // input typed as { userId: string; email: string }
195
+ .handler(async (payload) => { await emailService.sendWelcome(payload.email); });
196
+
197
+ await userCreated.emit({ userId: '123', email: 'user@example.com' }); // triggers sendWelcome
198
+ ```
199
+
200
+ `.on()` consumes the event's queue (`event:<name>`), not `<job.name>`. You do **not** call
201
+ `.send()` on an event-driven job — emit the event instead.
202
+
203
+ ---
204
+
205
+ ## Job options (`.options()`)
206
+
207
+ ```typescript
208
+ job('important-task')
209
+ .input(Type.Object({ id: Type.String() }))
210
+ .options({
211
+ retryLimit: 5, // max retries (default 3)
212
+ retryDelay: 5000, // ms between retries (default 1000)
213
+ expireInSeconds: 600, // handler timeout (default 300)
214
+ priority: 10, // higher = first (default 0)
215
+ singletonKey: 'unique', // dedupe key — see Queue policy below
216
+ policy: 'exclusive', // what singletonKey dedupes (default: see below)
217
+ retentionSeconds: 86400,// keep completed jobs (default 604800 = 7d)
218
+ batchSize: 1, // jobs per worker poll(default 1)
219
+ })
220
+ .handler(async (input) => { await processImportant(input.id); });
221
+ ```
222
+
223
+ `.timeout(ms)` is sugar for `expireInSeconds` (`Math.ceil(ms / 1000)`):
224
+
225
+ ```typescript
226
+ job('quick').timeout(10000).handler(async () => { /* ... */ }); // expireInSeconds: 10
227
+ ```
228
+
229
+ | Option | Type | Default | Notes |
230
+ |--------|------|---------|-------|
231
+ | `retryLimit` | number | 3 | max retry attempts |
232
+ | `retryDelay` | number | 1000 | ms between retries |
233
+ | `expireInSeconds` | number | 300 | handler timeout (seconds) |
234
+ | `priority` | number | 0 | higher = processed first |
235
+ | `singletonKey` | string | — | dedup key; effect depends on [`policy`](#queue-policy-and-deduplication) |
236
+ | `policy` | `JobQueuePolicy` | see below | [what `singletonKey` deduplicates](#queue-policy-and-deduplication) |
237
+ | `retentionSeconds` | number | 604800 | completed-job retention |
238
+ | `batchSize` | number | 1 | jobs fetched per worker poll; `> 1` ⇒ parallel |
239
+
240
+ ### Queue policy and deduplication
241
+
242
+ `singletonKey` on its own deduplicates nothing. pg-boss puts a unique index on
243
+ `(queue name, COALESCE(singleton_key, ''))` only for non-`standard` queues; on a `standard`
244
+ queue the insert's `ON CONFLICT DO NOTHING` never conflicts, so the key is stored and
245
+ ignored. The policy is a property of the **queue**, set from the job definition at
246
+ registration.
247
+
248
+ | policy | one job per `(queue, singletonKey)` among states… |
249
+ |---|---|
250
+ | `standard` | none — the key is ignored |
251
+ | `short` | `created` (queued, not yet picked up) |
252
+ | `singleton` | `active` (running) — many may queue, one runs at a time |
253
+ | `stately` | one per state among `created`, `retry`, `active` |
254
+ | `exclusive` | `created` + `retry` + `active` together — a second enqueue while one is pending or running is dropped |
255
+
256
+ **Default rule:** an explicit `.options({ policy })` always wins; otherwise a job that is
257
+ `.runOnce()` or sets `options.singletonKey` gets `exclusive`; everything else stays
258
+ `standard`. Plain jobs must stay `standard` — `exclusive` with no key deduplicates on
259
+ `COALESCE(singleton_key, '')` and would collapse the whole queue to one pending job.
260
+
261
+ **Deduplication is scoped by queue name.** The queue name is part of every index, so two
262
+ different queues never deduplicate against each other even with the same key. Event-driven
263
+ jobs dedupe on their `event:<name>` queue, not on `<job.name>`.
264
+
265
+ **Event-driven jobs share `event:<name>`**, so the policy resolved from one subscriber
266
+ applies to every subscriber of that event, and `emit()` carries no `singletonKey` — a
267
+ non-standard policy there deduplicates on the empty key, i.e. one un-started job per event
268
+ queue. Give `.on(event)` jobs an explicit `policy: 'standard'` unless collapsing pending
269
+ emits is exactly what you want, and don't put `options.singletonKey` on one.
270
+
271
+ **A send-time `singletonKey` needs a policy on the definition.** `.send(input, {
272
+ singletonKey })` cannot change the queue's policy. On a `standard` queue the send is
273
+ accepted and the key silently does nothing; the first such send logs
274
+
275
+ ```
276
+ [Job:<name>] singletonKey ignored: queue policy is "standard"; set .options({ policy: 'exclusive' }) (or another non-standard policy) to deduplicate
277
+ ```
278
+
279
+ once per job name. Put `policy` on `.options()` to make the key take effect.
280
+
281
+ ```typescript
282
+ const reindex = job('reindex')
283
+ .options({ policy: 'exclusive' }) // now send-time keys deduplicate
284
+ .handler(async () => { await search.reindex(); });
285
+ ```
286
+
287
+ #### Upgrading
288
+
289
+ Queues created by earlier versions of this package exist as `standard`, and pg-boss
290
+ `create_queue` is `INSERT … ON CONFLICT DO NOTHING` — an existing queue keeps its policy and
291
+ `updateQueue` cannot change one. Registration therefore compares the wanted policy against
292
+ `getQueue()` and, on a mismatch, logs an error naming the queue, both policies and the
293
+ remedy; boot continues with a queue that does not deduplicate. The remedy is to
294
+ `getBoss().deleteQueue('<name>')` in a quiet window — **it drops that queue's job rows** —
295
+ and restart. In development, `clearOnStart` recreates the queue with the new policy
296
+ automatically.
297
+
298
+ ---
299
+
300
+ ## Sending jobs
301
+
302
+ ### `.send(input?, options?)` → `Promise<string | null>`
303
+
304
+ Enqueue one job (returns the pg-boss job id, or `null` if deduped). Jobs with no input
305
+ schema take only the options arg.
306
+
307
+ ```typescript
308
+ await sendEmailJob.send({ to: 'u@x.com', subject: 'Hi', body: '...' });
309
+
310
+ await sendEmailJob.send(
311
+ { to: 'u@x.com', subject: 'Hi', body: '...' },
312
+ {
313
+ startAfter: 60, // delay seconds — or a Date
314
+ priority: 10, // override default priority
315
+ singletonKey: 'welcome-u@x.com', // dedupe this invocation
316
+ },
317
+ );
318
+ ```
319
+
320
+ `JobSendOptions`: `startAfter?: number | Date`, `priority?: number`, `singletonKey?: string`.
321
+ Send-time options override the job's defaults.
322
+
323
+ ### `.sendBatch(inputs?, options?)` → `Promise<void>`
324
+
325
+ Bulk-insert via `pg-boss.insert()` — a single query, far faster than a `.send()` loop.
326
+
327
+ ```typescript
328
+ await sendEmailJob.sendBatch(
329
+ users.map(u => ({ to: u.email, subject: 'Welcome', body: render(u) })),
330
+ );
331
+ ```
332
+
333
+ Combine with `batchSize` for distributed parallel processing: each worker fetches `batchSize`
334
+ jobs and runs them with `Promise.allSettled`; failed jobs are individually marked
335
+ (`boss.fail`) and retried — the whole batch does not fail together. pg-boss advisory locks
336
+ prevent duplicate processing across instances.
337
+
338
+ ### `.run(input?)` → `Promise<TOutput>`
339
+
340
+ Invoke the handler **synchronously, bypassing pg-boss** — for unit tests / debugging only.
341
+ No queue, no retries, no boss required.
342
+
343
+ ```typescript
344
+ await sendEmailJob.run({ to: 't@x.com', subject: 'Test', body: 'x' });
345
+ ```
346
+
347
+ ---
348
+
349
+ ## Compensation & output (workflow/saga)
350
+
351
+ `.output(schema)` types the handler's return; `.compensate(fn)` defines rollback. These are
352
+ hooks for workflow/saga orchestration — they do not run automatically on plain `.send()`.
353
+
354
+ ```typescript
355
+ export const chargePayment = job('charge-payment')
356
+ .input(Type.Object({ orderId: Type.String(), amount: Type.Number() }))
357
+ .output(Type.Object({ chargeId: Type.String() }))
358
+ .compensate(async (input, output) =>
359
+ {
360
+ await paymentService.refund(input.orderId, input.amount); // rollback
361
+ })
362
+ .handler(async (input) =>
363
+ {
364
+ return await paymentService.charge(input.orderId, input.amount);
365
+ });
366
+ ```
367
+
368
+ ---
369
+
370
+ ## Job router
371
+
372
+ `defineJobRouter` groups jobs (flat, nested, or mixed). `collectJobs` flattens nested
373
+ routers — nested keys become dotted names (`email.sendWelcome`).
374
+
375
+ ```typescript
376
+ // flat
377
+ export const jobRouter = defineJobRouter({ sendWelcome, dailyReport, initCache });
378
+
379
+ // nested + mixed
380
+ export const jobRouter = defineJobRouter({
381
+ initCache, // flat
382
+ email: defineJobRouter({ // nested → 'email.sendWelcome', ...
383
+ sendWelcome,
384
+ sendReset,
385
+ }),
386
+ });
387
+ ```
388
+
389
+ `mergeJobRouters(base, extra)` combines two routers into a new one (neither input is
390
+ mutated) and throws when a top-level key exists in both. `.jobs()` uses it — you rarely call
391
+ it directly.
392
+
393
+ ---
394
+
395
+ ## Server wiring
396
+
397
+ `defineServerConfig().jobs(router, config?)` at server start: reads `env.DATABASE_URL`, calls
398
+ `initBoss({ connectionString: DATABASE_URL, ...config })`, then `registerJobs(router)`.
399
+
400
+ **`.jobs()` may be called once per domain router.** Repeated calls **merge**, the way
401
+ `.routes()` composes — they do not replace one another:
402
+
403
+ ```typescript
404
+ export default defineServerConfig()
405
+ .routes(appRouter)
406
+ .jobs(emailJobRouter, { clearOnStart: isDev }) // pg-boss config on one call only
407
+ .jobs(billingJobRouter)
408
+ .jobs(reportingJobRouter)
409
+ .build();
410
+ ```
411
+
412
+ Two collisions are possible and both fail loudly:
413
+
414
+ - a **router key** present in two calls throws at config time
415
+ (`jobs(): key "x" is already registered by an earlier .jobs() call`);
416
+ - two jobs sharing a **`job.name`** — the pg-boss queue name — throw in `registerJobs()` at
417
+ boot, before any queue or worker is created;
418
+ - passing the pg-boss `config` on more than one call throws too; there is one boss per server.
419
+
420
+ ```typescript
421
+ export default defineServerConfig()
422
+ .routes(appRouter)
423
+ .jobs(jobRouter, {
424
+ schema: 'spfn_queue', // default 'spfn_queue'
425
+ clearOnStart: process.env.NODE_ENV === 'development',
426
+ })
427
+ .build();
428
+ ```
429
+
430
+ The second arg is `Omit<BossOptions, 'connectionString'>` — **do not pass
431
+ `connectionString`** here; it is taken from `env.DATABASE_URL`. If `DATABASE_URL` is unset,
432
+ server start throws `"Jobs require database connection."`.
433
+
434
+ ### `BossOptions` (for manual `initBoss`)
435
+
436
+ | Option | Type | Default | Notes |
437
+ |--------|------|---------|-------|
438
+ | `connectionString` | string | (required) | PostgreSQL URL (auto-supplied by `.jobs()`) |
439
+ | `schema` | string | `'spfn_queue'` | pg-boss tables live here |
440
+ | `maintenanceIntervalSeconds` | number | 120 | cleanup/archive interval |
441
+ | `monitorIntervalSeconds` | number | — | state-change events; must be `>= 1` |
442
+ | `clearOnStart` | boolean | false | delete pending jobs on boot (dev only) |
443
+ | `sweepOrphanSchedules` | boolean | false | unschedule crons no longer declared on any registered router (see below) |
444
+
445
+ ### Orphan schedule sweep
446
+
447
+ `boss.schedule` rows persist in the DB, so removing a cron job from the router leaves its
448
+ schedule behind — pg-boss keeps creating jobs on every cron tick with no worker to consume
449
+ them, and they pile up forever. With `sweepOrphanSchedules: true`, `registerJobs` runs a
450
+ sweep once after registration: any schedule whose name is not a cron job declared on a
451
+ router registered in this process is unscheduled. Queues and existing job rows are never
452
+ deleted — the sweep only stops the pile-up. Cron names accumulate across `registerJobs`
453
+ calls, so registering several routers is safe. When no cron job has been declared at all,
454
+ the sweep is skipped. Failures only log an error; startup is never blocked.
455
+
456
+ Leave the sweep disabled (the default) when any of these apply, because the sweep only
457
+ knows the routers registered in this process and would unschedule everything else:
458
+
459
+ - multiple apps share the same pg-boss schema,
460
+ - schedules are created directly via `getBoss().schedule(...)`,
461
+ - rolling deploys can boot an older router version while a newer one is live.
462
+
463
+ `sslmode=require`/`prefer` in the URL is rewritten to `ssl: { rejectUnauthorized: false }`
464
+ so self-signed certs work.
465
+
466
+ ---
467
+
468
+ ## Pitfalls & anti-patterns
469
+
470
+ - **`registerJobs` takes a `JobRouter`, not an array.** `registerJobs([a, b])` is the removed
471
+ API. Use `registerJobs(defineJobRouter({ a, b }))` — or just `.jobs(router)`, which is the
472
+ normal path and also runs `initBoss`.
473
+ - **Don't call `initBoss` / `registerJobs` yourself in an SPFN app.** `defineServerConfig().jobs()`
474
+ does both. Calling `initBoss` twice logs `"pg-boss already initialized"` and returns the
475
+ existing instance (the second config is ignored).
476
+ - **`.send()` before the boss is up throws** `"pg-boss not initialized"`. Only call `.send()`
477
+ after the server has started (e.g. inside route/job handlers), never at module top-level.
478
+ - **Don't pass `connectionString` to `.jobs()`.** Its config is `Omit<BossOptions,
479
+ 'connectionString'>`; the connection comes from `env.DATABASE_URL`. (Old docs showing
480
+ `.jobs(router, { connectionString })` are wrong.)
481
+ - **Event-driven jobs are triggered by `emit()`, not `.send()`.** `.on(event)` binds the job
482
+ to the `event:<name>` queue; emitting the event enqueues it. Calling `.send()` on such a job
483
+ targets the wrong queue.
484
+ - **`.run()` is not `.send()`.** `.run()` executes the handler inline with no queue, retries,
485
+ or timeout — tests/debugging only. Production dispatch is `.send()` / `.sendBatch()`.
486
+ - **Cron and runOnce jobs take no input.** They are invoked with `{}`. Don't give them
487
+ `.input()` and expect data.
488
+ - **Job names must be unique across the (flattened) router.** Two jobs sharing a `name` map to
489
+ the same pg-boss queue and collide. Nested router keys are namespaced into the *router* path
490
+ but the pg-boss queue is `job.name` — keep `name` strings unique. `registerJobs()` throws on
491
+ a duplicate name at boot, quoting both router keys.
492
+ - **`singletonKey` does nothing on a `standard` queue.** Set `policy` on the definition — see
493
+ [Queue policy and deduplication](#queue-policy-and-deduplication).
494
+ - **`clearOnStart: true` deletes pending/scheduled jobs on boot.** Development only — it wipes
495
+ queued work (and the subscribed event queues) every restart.
496
+ - **`expireInSeconds` is a handler timeout, not a delay.** A handler exceeding it is treated as
497
+ failed and retried (up to `retryLimit`). Make handlers idempotent.
498
+ - **`batchSize > 1` runs jobs concurrently.** Handlers must be safe to run in parallel;
499
+ failures are isolated per job, not per batch.
500
+
501
+ ---
502
+
503
+ ## Complete example
504
+
505
+ ```typescript
506
+ // src/server/jobs/index.ts
507
+ import { job, defineJobRouter } from '@spfn/core/job';
508
+ import { defineEvent } from '@spfn/core/event';
509
+ import { Type } from '@sinclair/typebox';
510
+
511
+ // event-driven
512
+ export const userCreated = defineEvent('user.created', Type.Object({
513
+ userId: Type.String(),
514
+ email: Type.String(),
515
+ }));
516
+
517
+ export const sendWelcome = job('send-welcome')
518
+ .on(userCreated)
519
+ .options({ retryLimit: 3, retryDelay: 5000 })
520
+ .handler(async (p) => { await emailService.sendWelcome(p.email); });
521
+
522
+ // standard + idempotent
523
+ export const processOrder = job('process-order')
524
+ .input(Type.Object({ orderId: Type.String() }))
525
+ .options({ retryLimit: 3 })
526
+ .handler(async (input) =>
527
+ {
528
+ const order = await orderRepo.findById(input.orderId);
529
+ if (order.status === 'processed') return; // idempotent guard
530
+ await processOrderLogic(order);
531
+ });
532
+
533
+ // batch
534
+ export const sendBulkEmail = job('send-bulk-email')
535
+ .input(Type.Object({ to: Type.String(), subject: Type.String(), html: Type.String() }))
536
+ .options({ retryLimit: 3, batchSize: 50 })
537
+ .handler(async (input) => { await emailProvider.send(input); });
538
+
539
+ // cron
540
+ export const dailyReport = job('daily-report')
541
+ .cron('0 9 * * *')
542
+ .handler(async () => { await reportService.generateDaily(); });
543
+
544
+ // runOnce
545
+ export const initCache = job('init-cache')
546
+ .runOnce()
547
+ .handler(async () => { await cache.warmup(); });
548
+
549
+ export const jobRouter = defineJobRouter({
550
+ sendWelcome, processOrder, sendBulkEmail, dailyReport, initCache,
551
+ });
552
+ ```
553
+
554
+ ```typescript
555
+ // server.config.ts
556
+ import { defineServerConfig } from '@spfn/core/server';
557
+ import { appRouter } from './routes';
558
+ import { jobRouter } from './jobs';
559
+
560
+ export default defineServerConfig()
561
+ .routes(appRouter)
562
+ .jobs(jobRouter, { clearOnStart: process.env.NODE_ENV === 'development' })
563
+ .build();
564
+ ```
565
+
566
+ ```typescript
567
+ // dispatch (inside a handler, after server start)
568
+ await processOrder.send({ orderId: 'o-1' });
569
+ await sendBulkEmail.sendBatch(users.map(u => ({ to: u.email, subject: 'Hi', html: render(u) })));
570
+ await userCreated.emit({ userId: '123', email: 'u@x.com' }); // → sendWelcome
571
+ ```
572
+
573
+ ```typescript
574
+ // test
575
+ import { processOrder } from './jobs';
576
+
577
+ it('processes an order', async () =>
578
+ {
579
+ await processOrder.run({ orderId: 'o-1' }); // sync, no pg-boss
580
+ expect(orderRepo.findById).toHaveBeenCalled();
581
+ });
582
+ ```
583
+
584
+ ---
585
+
586
+ ## Types reference
587
+
588
+ ```typescript
589
+ import type {
590
+ JobDef, JobRouter, JobRouterEntry,
591
+ JobOptions, JobQueuePolicy, JobSendOptions, JobHandler, CompensateHandler,
592
+ InferJobInput, InferJobOutput, BossOptions,
593
+ } from '@spfn/core/job';
594
+
595
+ type SendInput = InferJobInput<typeof processOrder>; // { orderId: string }
596
+ type ChargeOut = InferJobOutput<typeof chargePayment>; // { chargeId: string }
597
+
598
+ // JobHandler<TInput, TOutput> = TInput extends void
599
+ // ? () => Promise<TOutput>
600
+ // : (input: TInput) => Promise<TOutput>;
601
+ // CompensateHandler<TInput, TOutput> = (input: TInput, output: TOutput) => Promise<void>;
602
+ ```
603
+
604
+ ## Related
605
+
606
+ - [@spfn/core/event](../event/README.md) — events drive `.on(event)` jobs; emit triggers them
607
+ - [@spfn/core/server](../server/README.md) — `defineServerConfig().jobs()` wiring
608
+ - [@spfn/core/env](../env/README.md) — `DATABASE_URL` powers the job connection
609
+ - [pg-boss](https://github.com/timgit/pg-boss) — underlying queue engine