@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21
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 +343 -16
- package/dist/app-config/index.d.ts +134 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +93 -15
- package/dist/codegen/index.js +626 -165
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +0 -63
- package/dist/config/index.js +15 -15
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +40 -17
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +201 -34
- package/dist/db/index.js +294 -158
- package/dist/db/index.js.map +1 -1
- package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-TsUIjp_x.d.ts +157 -0
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +32 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +38 -2
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +5 -4
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -2
- package/dist/middleware/index.js +7 -388
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +88 -8
- package/dist/nextjs/index.js +138 -62
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +45 -7
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +62 -43
- package/dist/ops/index.js +638 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +6 -3
- package/dist/route/index.js +97 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
- package/dist/router-4YoIy5fN.d.ts +172 -0
- package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
- package/dist/server/index.d.ts +185 -14
- package/dist/server/index.js +883 -78
- package/dist/server/index.js.map +1 -1
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1003 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
- package/dist/types-CxhPH4s_.d.ts +375 -0
- package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +20 -9
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +634 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +440 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +789 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +688 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- 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
|