@spfn/core 0.3.0-beta.6 → 0.3.0-beta.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spfn/core",
3
- "version": "0.3.0-beta.6",
3
+ "version": "0.3.0-beta.8",
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)`, `collectJobs(router, prefix?)`, `isJobDef(v)`, `isJobRouter(v)`
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`, `JobSendOptions`,
39
- `JobHandler`, `CompensateHandler`, `InferJobInput`, `InferJobOutput`, `BossOptions`,
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)`.
@@ -138,6 +140,10 @@ const typedJob = job('typed')
138
140
  const noInput = job('simple').handler(async () => { await db.cleanup(); });
139
141
  ```
140
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
+
141
147
  ### Cron — scheduled
142
148
 
143
149
  ```typescript
@@ -167,7 +173,13 @@ const initCache = job('init-cache')
167
173
  .handler(async () => { await cache.warmup(); });
168
174
  ```
169
175
 
170
- Enqueued with `singletonKey: 'runOnce:<name>'` so concurrent instances don't double-run it.
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.
171
183
 
172
184
  ### Event-driven — `.on(event)`
173
185
 
@@ -204,7 +216,8 @@ job('important-task')
204
216
  retryDelay: 5000, // ms between retries (default 1000)
205
217
  expireInSeconds: 600, // handler timeout (default 300)
206
218
  priority: 10, // higher = first (default 0)
207
- singletonKey: 'unique', // dedupe key
219
+ singletonKey: 'unique', // dedupe key — see Queue policy below
220
+ policy: 'exclusive', // what singletonKey dedupes (default: see below)
208
221
  retentionSeconds: 86400,// keep completed jobs (default 604800 = 7d)
209
222
  batchSize: 1, // jobs per worker poll(default 1)
210
223
  })
@@ -223,10 +236,69 @@ job('quick').timeout(10000).handler(async () => { /* ... */ }); // expireInSecon
223
236
  | `retryDelay` | number | 1000 | ms between retries |
224
237
  | `expireInSeconds` | number | 300 | handler timeout (seconds) |
225
238
  | `priority` | number | 0 | higher = processed first |
226
- | `singletonKey` | string | — | only one active job per key |
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) |
227
241
  | `retentionSeconds` | number | 604800 | completed-job retention |
228
242
  | `batchSize` | number | 1 | jobs fetched per worker poll; `> 1` ⇒ parallel |
229
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
+
230
302
  ---
231
303
 
232
304
  ## Sending jobs
@@ -318,6 +390,10 @@ export const jobRouter = defineJobRouter({
318
390
  });
319
391
  ```
320
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
+
321
397
  ---
322
398
 
323
399
  ## Server wiring
@@ -325,6 +401,26 @@ export const jobRouter = defineJobRouter({
325
401
  `defineServerConfig().jobs(router, config?)` at server start: reads `env.DATABASE_URL`, calls
326
402
  `initBoss({ connectionString: DATABASE_URL, ...config })`, then `registerJobs(router)`.
327
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
+
328
424
  ```typescript
329
425
  export default defineServerConfig()
330
426
  .routes(appRouter)
@@ -395,7 +491,10 @@ so self-signed certs work.
395
491
  `.input()` and expect data.
396
492
  - **Job names must be unique across the (flattened) router.** Two jobs sharing a `name` map to
397
493
  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.
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).
399
498
  - **`clearOnStart: true` deletes pending/scheduled jobs on boot.** Development only — it wipes
400
499
  queued work (and the subscribed event queues) every restart.
401
500
  - **`expireInSeconds` is a handler timeout, not a delay.** A handler exceeding it is treated as
@@ -493,7 +592,7 @@ it('processes an order', async () =>
493
592
  ```typescript
494
593
  import type {
495
594
  JobDef, JobRouter, JobRouterEntry,
496
- JobOptions, JobSendOptions, JobHandler, CompensateHandler,
595
+ JobOptions, JobQueuePolicy, JobSendOptions, JobHandler, CompensateHandler,
497
596
  InferJobInput, InferJobOutput, BossOptions,
498
597
  } from '@spfn/core/job';
499
598