@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/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.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)`, `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)`.
@@ -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>'` so concurrent instances don't double-run it.
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 | — | only one active job per key |
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