@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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