@spfn/core 0.3.0-beta.6 → 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.
- package/dist/{boss-D16fO2oG.d.ts → boss-Caj5dGMm.d.ts} +47 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +118 -40
- package/dist/job/index.js.map +1 -1
- package/dist/server/index.d.ts +19 -2
- package/dist/server/index.js +129 -39
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/src/job/README.md +104 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spfn/core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.7",
|
|
4
4
|
"description": "Full-stack TypeScript backend for Next.js: file-based typed routes, Drizzle entities and repositories, PostgreSQL transactions and a generated end-to-end client, in one fixed vertical slice per feature",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/job/README.md
CHANGED
|
@@ -31,13 +31,15 @@ In a normal SPFN app you do **not** call `initBoss` / `registerJobs` yourself
|
|
|
31
31
|
From `@spfn/core/job`:
|
|
32
32
|
|
|
33
33
|
- Builder: `job(name)` → `JobBuilder`
|
|
34
|
-
- Router: `defineJobRouter(jobs)`, `
|
|
34
|
+
- Router: `defineJobRouter(jobs)`, `mergeJobRouters(base, extra)`, `collectJobs(router, prefix?)`,
|
|
35
|
+
`isJobDef(v)`, `isJobRouter(v)`
|
|
36
|
+
- Queue policy: `resolveQueuePolicy(job)`
|
|
35
37
|
- pg-boss lifecycle: `initBoss(options)`, `getBoss()`, `stopBoss()`, `isBossRunning()`,
|
|
36
38
|
`shouldClearOnStart()`
|
|
37
39
|
- Registration: `registerJobs(router)` — **takes a `JobRouter`, not an array**
|
|
38
|
-
- Types: `JobDef`, `JobRouter`, `JobRouterEntry`, `JobOptions`, `
|
|
39
|
-
`JobHandler`, `CompensateHandler`, `InferJobInput`, `InferJobOutput`,
|
|
40
|
-
`BossConfig` (deprecated alias of `BossOptions`)
|
|
40
|
+
- Types: `JobDef`, `JobRouter`, `JobRouterEntry`, `JobOptions`, `JobQueuePolicy`,
|
|
41
|
+
`JobSendOptions`, `JobHandler`, `CompensateHandler`, `InferJobInput`, `InferJobOutput`,
|
|
42
|
+
`BossOptions`, `BossConfig` (deprecated alias of `BossOptions`)
|
|
41
43
|
|
|
42
44
|
`JobBuilder` methods: `.input(schema)`, `.output(schema)`, `.on(event)`, `.cron(expr)`,
|
|
43
45
|
`.runOnce()`, `.options(opts)`, `.timeout(ms)`, `.compensate(fn)`, `.handler(fn)`.
|
|
@@ -167,7 +169,13 @@ const initCache = job('init-cache')
|
|
|
167
169
|
.handler(async () => { await cache.warmup(); });
|
|
168
170
|
```
|
|
169
171
|
|
|
170
|
-
Enqueued with `singletonKey: 'runOnce:<name>'`
|
|
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.
|
|
171
179
|
|
|
172
180
|
### Event-driven — `.on(event)`
|
|
173
181
|
|
|
@@ -204,7 +212,8 @@ job('important-task')
|
|
|
204
212
|
retryDelay: 5000, // ms between retries (default 1000)
|
|
205
213
|
expireInSeconds: 600, // handler timeout (default 300)
|
|
206
214
|
priority: 10, // higher = first (default 0)
|
|
207
|
-
singletonKey: 'unique', // dedupe key
|
|
215
|
+
singletonKey: 'unique', // dedupe key — see Queue policy below
|
|
216
|
+
policy: 'exclusive', // what singletonKey dedupes (default: see below)
|
|
208
217
|
retentionSeconds: 86400,// keep completed jobs (default 604800 = 7d)
|
|
209
218
|
batchSize: 1, // jobs per worker poll(default 1)
|
|
210
219
|
})
|
|
@@ -223,10 +232,69 @@ job('quick').timeout(10000).handler(async () => { /* ... */ }); // expireInSecon
|
|
|
223
232
|
| `retryDelay` | number | 1000 | ms between retries |
|
|
224
233
|
| `expireInSeconds` | number | 300 | handler timeout (seconds) |
|
|
225
234
|
| `priority` | number | 0 | higher = processed first |
|
|
226
|
-
| `singletonKey` | string | — |
|
|
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) |
|
|
227
237
|
| `retentionSeconds` | number | 604800 | completed-job retention |
|
|
228
238
|
| `batchSize` | number | 1 | jobs fetched per worker poll; `> 1` ⇒ parallel |
|
|
229
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
|
+
|
|
230
298
|
---
|
|
231
299
|
|
|
232
300
|
## Sending jobs
|
|
@@ -318,6 +386,10 @@ export const jobRouter = defineJobRouter({
|
|
|
318
386
|
});
|
|
319
387
|
```
|
|
320
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
|
+
|
|
321
393
|
---
|
|
322
394
|
|
|
323
395
|
## Server wiring
|
|
@@ -325,6 +397,26 @@ export const jobRouter = defineJobRouter({
|
|
|
325
397
|
`defineServerConfig().jobs(router, config?)` at server start: reads `env.DATABASE_URL`, calls
|
|
326
398
|
`initBoss({ connectionString: DATABASE_URL, ...config })`, then `registerJobs(router)`.
|
|
327
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
|
+
|
|
328
420
|
```typescript
|
|
329
421
|
export default defineServerConfig()
|
|
330
422
|
.routes(appRouter)
|
|
@@ -395,7 +487,10 @@ so self-signed certs work.
|
|
|
395
487
|
`.input()` and expect data.
|
|
396
488
|
- **Job names must be unique across the (flattened) router.** Two jobs sharing a `name` map to
|
|
397
489
|
the same pg-boss queue and collide. Nested router keys are namespaced into the *router* path
|
|
398
|
-
but the pg-boss queue is `job.name` — keep `name` strings unique.
|
|
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).
|
|
399
494
|
- **`clearOnStart: true` deletes pending/scheduled jobs on boot.** Development only — it wipes
|
|
400
495
|
queued work (and the subscribed event queues) every restart.
|
|
401
496
|
- **`expireInSeconds` is a handler timeout, not a delay.** A handler exceeding it is treated as
|
|
@@ -493,7 +588,7 @@ it('processes an order', async () =>
|
|
|
493
588
|
```typescript
|
|
494
589
|
import type {
|
|
495
590
|
JobDef, JobRouter, JobRouterEntry,
|
|
496
|
-
JobOptions, JobSendOptions, JobHandler, CompensateHandler,
|
|
591
|
+
JobOptions, JobQueuePolicy, JobSendOptions, JobHandler, CompensateHandler,
|
|
497
592
|
InferJobInput, InferJobOutput, BossOptions,
|
|
498
593
|
} from '@spfn/core/job';
|
|
499
594
|
|