@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.
@@ -7,6 +7,23 @@ import PgBoss from 'pg-boss';
7
7
  * Type definitions for the pg-boss based job system
8
8
  */
9
9
 
10
+ /**
11
+ * pg-boss queue policy
12
+ *
13
+ * The policy decides which unique index pg-boss puts on
14
+ * `(queue name, singletonKey)`, and therefore what `singletonKey` actually
15
+ * deduplicates. A `standard` queue has no such index: the key is stored and
16
+ * ignored.
17
+ *
18
+ * | policy | one job per `(queue, singletonKey)` among states… |
19
+ * |---|---|
20
+ * | `standard` | none — the key is ignored |
21
+ * | `short` | `created` (queued, not yet picked up) |
22
+ * | `singleton` | `active` (running) — many may queue, one runs at a time |
23
+ * | `stately` | one per state among `created`, `retry`, `active` |
24
+ * | `exclusive` | `created` + `retry` + `active` together |
25
+ */
26
+ type JobQueuePolicy = 'standard' | 'short' | 'singleton' | 'stately' | 'exclusive';
10
27
  /**
11
28
  * Job options passed to pg-boss
12
29
  */
@@ -39,9 +56,32 @@ interface JobOptions {
39
56
  */
40
57
  priority?: number;
41
58
  /**
42
- * Singleton key - only one job with this key can exist
59
+ * Deduplication key. What it deduplicates depends entirely on `policy`:
60
+ * on a `standard` queue (the default for a plain job) pg-boss stores the
61
+ * key and never conflicts on it, so nothing is deduplicated.
62
+ *
63
+ * Setting this without an explicit `policy` makes the queue `exclusive`.
43
64
  */
44
65
  singletonKey?: string;
66
+ /**
67
+ * pg-boss queue policy — what `singletonKey` deduplicates on this queue.
68
+ *
69
+ * | policy | one job per `(queue, singletonKey)` among states… |
70
+ * |---|---|
71
+ * | `standard` | none — the key is ignored |
72
+ * | `short` | `created` |
73
+ * | `singleton` | `active` |
74
+ * | `stately` | one per state among `created`, `retry`, `active` |
75
+ * | `exclusive` | `created` + `retry` + `active` together |
76
+ *
77
+ * Deduplication is scoped by queue name: different queues never
78
+ * deduplicate against each other. `exclusive` with no `singletonKey`
79
+ * collapses the whole queue to one pending job, which is why plain jobs
80
+ * stay `standard`.
81
+ *
82
+ * @default 'exclusive' when the job is `.runOnce()` or sets `singletonKey`, otherwise 'standard'
83
+ */
84
+ policy?: JobQueuePolicy;
45
85
  /**
46
86
  * Keep completed jobs for this many seconds
47
87
  * @default 604800 (7 days)
@@ -71,7 +111,11 @@ interface JobSendOptions {
71
111
  */
72
112
  startAfter?: number | Date;
73
113
  /**
74
- * Singleton key for this specific job instance
114
+ * Deduplication key for this specific job instance.
115
+ *
116
+ * Only takes effect when the job's queue has a non-standard `policy`: see
117
+ * {@link JobOptions.policy}. On a `standard` queue pg-boss stores the key
118
+ * and never conflicts on it, and the first such send logs a warning.
75
119
  */
76
120
  singletonKey?: string;
77
121
  /**
@@ -281,4 +325,4 @@ declare function isBossRunning(): boolean;
281
325
  */
282
326
  declare function shouldClearOnStart(): boolean;
283
327
 
284
- export { type BossConfig as B, type CompensateHandler as C, type InferJobInput as I, type JobOptions as J, type JobHandler as a, type JobDef as b, type JobRouter as c, type JobRouterEntry as d, type BossOptions as e, type InferJobOutput as f, type JobSendOptions as g, getBoss as h, initBoss as i, isBossRunning as j, stopBoss as k, shouldClearOnStart as s };
328
+ export { type BossConfig as B, type CompensateHandler as C, type InferJobInput as I, type JobOptions as J, type JobHandler as a, type JobDef as b, type JobRouter as c, type JobRouterEntry as d, type JobQueuePolicy as e, type BossOptions as f, type InferJobOutput as g, type JobSendOptions as h, getBoss as i, initBoss as j, isBossRunning as k, stopBoss as l, shouldClearOnStart as s };
@@ -1,5 +1,5 @@
1
- import { J as JobOptions, C as CompensateHandler, a as JobHandler, b as JobDef, c as JobRouter, d as JobRouterEntry } from '../boss-D16fO2oG.js';
2
- export { B as BossConfig, e as BossOptions, I as InferJobInput, f as InferJobOutput, g as JobSendOptions, h as getBoss, i as initBoss, j as isBossRunning, s as shouldClearOnStart, k as stopBoss } from '../boss-D16fO2oG.js';
1
+ import { J as JobOptions, C as CompensateHandler, a as JobHandler, b as JobDef, c as JobRouter, d as JobRouterEntry, e as JobQueuePolicy } from '../boss-Caj5dGMm.js';
2
+ export { B as BossConfig, f as BossOptions, I as InferJobInput, g as InferJobOutput, h as JobSendOptions, i as getBoss, j as initBoss, k as isBossRunning, s as shouldClearOnStart, l as stopBoss } from '../boss-Caj5dGMm.js';
3
3
  import * as _sinclair_typebox from '@sinclair/typebox';
4
4
  import { Static } from '@sinclair/typebox';
5
5
  import { EventDef, InferEventPayload } from '@spfn/core/event';
@@ -168,11 +168,56 @@ declare function isJobRouter(value: unknown): value is JobRouter<any>;
168
168
  * ```
169
169
  */
170
170
  declare function defineJobRouter<TJobs extends Record<string, JobRouterEntry>>(jobs: TJobs): JobRouter<TJobs>;
171
+ /**
172
+ * Merge two job routers into a new one
173
+ *
174
+ * `defineServerConfig().jobs()` may be called once per domain router, the way
175
+ * `.routes()` is; each call merges into what is already registered instead of
176
+ * replacing it. Keys are the router's own namespace, so a key present in both
177
+ * routers is a mistake the app cannot recover from silently — one of the two
178
+ * would disappear.
179
+ *
180
+ * Neither input is mutated: a fresh router is returned.
181
+ *
182
+ * @throws if a top-level key exists in both routers
183
+ */
184
+ declare function mergeJobRouters(base: JobRouter<any>, extra: JobRouter<any>): JobRouter<any>;
171
185
  /**
172
186
  * Collect all JobDefs from a JobRouter (including nested)
173
187
  */
174
188
  declare function collectJobs(router: JobRouter<any>, prefix?: string): JobDef<any>[];
175
189
 
190
+ /**
191
+ * Queue Policy Resolution
192
+ *
193
+ * Lives apart from register-jobs.ts and job-builder.ts so both can read the
194
+ * policy of a job definition without importing each other.
195
+ */
196
+
197
+ /**
198
+ * The part of a job definition that decides its queue policy
199
+ *
200
+ * `JobDef` satisfies this; the builder can also pass its own fields before the
201
+ * definition object exists.
202
+ */
203
+ interface QueuePolicySource {
204
+ readonly runOnce?: boolean;
205
+ readonly options?: JobOptions;
206
+ }
207
+ /**
208
+ * Resolve the pg-boss queue policy for a job definition
209
+ *
210
+ * An explicit `.options({ policy })` always wins. Otherwise a job that asks
211
+ * for deduplication — `.runOnce()` or an `options.singletonKey` — gets
212
+ * `exclusive`, the only policy under which a second enqueue is dropped while
213
+ * the first is still pending or running.
214
+ *
215
+ * Everything else stays `standard`: `exclusive` without a `singletonKey`
216
+ * deduplicates on `COALESCE(singleton_key, '')` and would collapse a plain
217
+ * queue to a single pending job.
218
+ */
219
+ declare function resolveQueuePolicy(job: QueuePolicySource): JobQueuePolicy;
220
+
176
221
  /**
177
222
  * Job Registration
178
223
  *
@@ -215,4 +260,4 @@ declare function collectJobs(router: JobRouter<any>, prefix?: string): JobDef<an
215
260
  */
216
261
  declare function registerJobs(router: JobRouter<any>): Promise<void>;
217
262
 
218
- export { CompensateHandler, JobDef, JobHandler, JobOptions, JobRouter, JobRouterEntry, collectJobs, defineJobRouter, isJobDef, isJobRouter, job, registerJobs };
263
+ export { CompensateHandler, JobDef, JobHandler, JobOptions, JobQueuePolicy, JobRouter, JobRouterEntry, collectJobs, defineJobRouter, isJobDef, isJobRouter, job, mergeJobRouters, registerJobs, resolveQueuePolicy };
package/dist/job/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import PgBoss from 'pg-boss';
2
1
  import { logger } from '@spfn/core/logger';
2
+ import PgBoss from 'pg-boss';
3
3
  import { env } from '@spfn/core/config';
4
4
 
5
- // src/job/boss.ts
5
+ // src/job/job-builder.ts
6
6
  var jobLogger = logger.child("@spfn/core:job");
7
7
  function requiresSSLWithoutVerification(connectionString) {
8
8
  try {
@@ -94,7 +94,29 @@ function shouldSweepOrphanSchedules() {
94
94
  return getBossConfig()?.sweepOrphanSchedules ?? false;
95
95
  }
96
96
 
97
+ // src/job/queue-policy.ts
98
+ function resolveQueuePolicy(job2) {
99
+ if (job2.options?.policy) {
100
+ return job2.options.policy;
101
+ }
102
+ if (job2.runOnce || job2.options?.singletonKey) {
103
+ return "exclusive";
104
+ }
105
+ return "standard";
106
+ }
107
+
97
108
  // src/job/job-builder.ts
109
+ var jobLogger2 = logger.child("@spfn/core:job");
110
+ var warnedIgnoredSingletonKeys = /* @__PURE__ */ new Set();
111
+ function warnIfSingletonKeyIgnored(name, source) {
112
+ if (resolveQueuePolicy(source) !== "standard" || warnedIgnoredSingletonKeys.has(name)) {
113
+ return;
114
+ }
115
+ warnedIgnoredSingletonKeys.add(name);
116
+ jobLogger2.warn(
117
+ `[Job:${name}] singletonKey ignored: queue policy is "standard"; set .options({ policy: 'exclusive' }) (or another non-standard policy) to deduplicate`
118
+ );
119
+ }
98
120
  function buildPgBossOptions(defaults, sendOptions) {
99
121
  const options = {};
100
122
  if (sendOptions?.startAfter) {
@@ -255,6 +277,9 @@ var JobBuilder = class _JobBuilder {
255
277
  );
256
278
  }
257
279
  const [input, sendOptions] = inputSchema ? [inputOrOptions, maybeOptions] : [void 0, inputOrOptions];
280
+ if (sendOptions?.singletonKey) {
281
+ warnIfSingletonKeyIgnored(name, { runOnce, options });
282
+ }
258
283
  return await boss.send(
259
284
  name,
260
285
  input ?? {},
@@ -276,6 +301,9 @@ var JobBuilder = class _JobBuilder {
276
301
  );
277
302
  }
278
303
  const [inputs, sendOptions] = inputSchema ? [inputsOrOptions, maybeOptions] : [void 0, inputsOrOptions];
304
+ if (sendOptions?.singletonKey) {
305
+ warnIfSingletonKeyIgnored(name, { runOnce, options });
306
+ }
279
307
  const pgBossOptions = buildPgBossOptions(options, sendOptions);
280
308
  const jobs = (inputs ?? [{}]).map((data) => ({
281
309
  name,
@@ -320,19 +348,32 @@ function defineJobRouter(jobs) {
320
348
  _jobs: jobs
321
349
  };
322
350
  }
323
- function collectJobs(router, prefix = "") {
324
- const jobs = [];
351
+ function mergeJobRouters(base, extra) {
352
+ for (const key of Object.keys(extra.jobs)) {
353
+ if (key in base.jobs) {
354
+ throw new Error(
355
+ `jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
356
+ );
357
+ }
358
+ }
359
+ return defineJobRouter({ ...base.jobs, ...extra.jobs });
360
+ }
361
+ function collectJobEntries(router, prefix = "") {
362
+ const entries = [];
325
363
  for (const [key, value] of Object.entries(router.jobs)) {
326
- const name = prefix ? `${prefix}.${key}` : key;
364
+ const path = prefix ? `${prefix}.${key}` : key;
327
365
  if (isJobRouter(value)) {
328
- jobs.push(...collectJobs(value, name));
366
+ entries.push(...collectJobEntries(value, path));
329
367
  } else if (isJobDef(value)) {
330
- jobs.push(value);
368
+ entries.push({ key: path, job: value });
331
369
  }
332
370
  }
333
- return jobs;
371
+ return entries;
334
372
  }
335
- var jobLogger2 = logger.child("@spfn/core:job");
373
+ function collectJobs(router, prefix = "") {
374
+ return collectJobEntries(router, prefix).map((entry) => entry.job);
375
+ }
376
+ var jobLogger3 = logger.child("@spfn/core:job");
336
377
  function getEventQueueName(eventName) {
337
378
  return `event:${eventName}`;
338
379
  }
@@ -353,11 +394,12 @@ async function registerJobs(router) {
353
394
  "pg-boss not initialized. Call initBoss() before registerJobs()"
354
395
  );
355
396
  }
397
+ assertUniqueJobNames(router);
356
398
  const jobs = collectJobs(router);
357
399
  const clearOnStart = shouldClearOnStart();
358
- jobLogger2.info(`Registering ${jobs.length} job(s)...`);
400
+ jobLogger3.info(`Registering ${jobs.length} job(s)...`);
359
401
  if (clearOnStart) {
360
- jobLogger2.info("Clearing existing jobs before registration...");
402
+ jobLogger3.info("Clearing existing jobs before registration...");
361
403
  for (const job2 of jobs) {
362
404
  await boss.deleteAllJobs(job2.name);
363
405
  if (job2.subscribedEvent) {
@@ -365,7 +407,7 @@ async function registerJobs(router) {
365
407
  await boss.deleteAllJobs(eventQueue);
366
408
  }
367
409
  }
368
- jobLogger2.info("Existing jobs cleared");
410
+ jobLogger3.info("Existing jobs cleared");
369
411
  }
370
412
  await Promise.all(jobs.map((job2) => registerJob(job2)));
371
413
  for (const job2 of jobs) {
@@ -376,42 +418,77 @@ async function registerJobs(router) {
376
418
  if (shouldSweepOrphanSchedules()) {
377
419
  await sweepOrphanSchedules(boss);
378
420
  }
379
- jobLogger2.info("All jobs registered successfully");
421
+ jobLogger3.info("All jobs registered successfully");
422
+ }
423
+ function assertUniqueJobNames(router) {
424
+ const keysByName = /* @__PURE__ */ new Map();
425
+ for (const { key, job: job2 } of collectJobEntries(router)) {
426
+ const existingKey = keysByName.get(job2.name);
427
+ if (existingKey) {
428
+ throw new Error(
429
+ `registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
430
+ );
431
+ }
432
+ keysByName.set(job2.name, key);
433
+ }
380
434
  }
381
435
  var registeredCronNames = /* @__PURE__ */ new Set();
382
436
  async function sweepOrphanSchedules(boss) {
383
437
  if (registeredCronNames.size === 0) {
384
- jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
438
+ jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
385
439
  return;
386
440
  }
387
441
  try {
388
442
  const schedules = await boss.getSchedules();
389
443
  const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
390
444
  if (orphans.length === 0) {
391
- jobLogger2.debug("No orphan schedules found");
445
+ jobLogger3.debug("No orphan schedules found");
392
446
  return;
393
447
  }
394
448
  await Promise.all(orphans.map(async (orphan) => {
395
449
  try {
396
450
  await boss.unschedule(orphan.name, orphan.key);
397
- jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
451
+ jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
398
452
  } catch (error) {
399
- jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
453
+ jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
400
454
  error: error instanceof Error ? error.message : String(error)
401
455
  });
402
456
  }
403
457
  }));
404
458
  } catch (error) {
405
- jobLogger2.error("Orphan schedule sweep failed", {
459
+ jobLogger3.error("Orphan schedule sweep failed", {
406
460
  error: error instanceof Error ? error.message : String(error)
407
461
  });
408
462
  }
409
463
  }
410
- async function ensureQueue(boss, queueName) {
411
- await boss.createQueue(queueName);
464
+ async function ensureQueue(boss, queueName, policy) {
465
+ await boss.createQueue(queueName, { policy });
466
+ const existing = await boss.getQueue(queueName);
467
+ if (!existing || existing.policy === policy) {
468
+ return;
469
+ }
470
+ if (shouldClearOnStart()) {
471
+ await boss.deleteQueue(queueName);
472
+ await boss.createQueue(queueName, { policy });
473
+ jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
474
+ return;
475
+ }
476
+ jobLogger3.error(
477
+ `[Queue:${queueName}] policy mismatch: the queue exists as "${existing.policy}" but its jobs require "${policy}". Delete the queue in a quiet window (getBoss().deleteQueue('${queueName}') drops its job rows) or set clearOnStart in development; until then singletonKey/runOnce on this queue do not deduplicate`
478
+ );
479
+ }
480
+ function createQueueEnsurer(boss, policy) {
481
+ const ensured = /* @__PURE__ */ new Set();
482
+ return async (queueName) => {
483
+ if (ensured.has(queueName)) {
484
+ return;
485
+ }
486
+ ensured.add(queueName);
487
+ await ensureQueue(boss, queueName, policy);
488
+ };
412
489
  }
413
490
  async function executeJobHandler(job2, pgBossJob) {
414
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
491
+ jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
415
492
  const startTime = Date.now();
416
493
  try {
417
494
  if (job2.inputSchema) {
@@ -420,13 +497,13 @@ async function executeJobHandler(job2, pgBossJob) {
420
497
  await job2.handler();
421
498
  }
422
499
  const duration = Date.now() - startTime;
423
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
500
+ jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
424
501
  jobId: pgBossJob.id,
425
502
  duration
426
503
  });
427
504
  } catch (error) {
428
505
  const duration = Date.now() - startTime;
429
- jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
506
+ jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
430
507
  jobId: pgBossJob.id,
431
508
  duration,
432
509
  error: error instanceof Error ? error.message : String(error)
@@ -434,8 +511,8 @@ async function executeJobHandler(job2, pgBossJob) {
434
511
  throw error;
435
512
  }
436
513
  }
437
- async function registerWorker(boss, job2, queueName) {
438
- await ensureQueue(boss, queueName);
514
+ async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
515
+ await ensureQueueOnce(queueName);
439
516
  const batchSize = job2.options?.batchSize ?? 1;
440
517
  const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
441
518
  await boss.work(
@@ -469,28 +546,28 @@ function connectEventToQueue(boss, job2, queueName) {
469
546
  eventDef._registerJobQueue(queueName, async (queue, payload) => {
470
547
  await boss.send(queue, payload, getDefaultJobOptions(job2.options));
471
548
  });
472
- jobLogger2.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
549
+ jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
473
550
  }
474
- async function registerCronSchedule(boss, job2) {
551
+ async function registerCronSchedule(boss, job2, ensureQueueOnce) {
475
552
  if (!job2.cronExpression) {
476
553
  return;
477
554
  }
478
- jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
479
- await ensureQueue(boss, job2.name);
555
+ jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
556
+ await ensureQueueOnce(job2.name);
480
557
  await boss.schedule(
481
558
  job2.name,
482
559
  job2.cronExpression,
483
560
  {},
484
561
  getDefaultJobOptions(job2.options)
485
562
  );
486
- jobLogger2.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
563
+ jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
487
564
  }
488
- async function queueRunOnceJob(boss, job2) {
565
+ async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
489
566
  if (!job2.runOnce) {
490
567
  return;
491
568
  }
492
- jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
493
- await ensureQueue(boss, job2.name);
569
+ jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
570
+ await ensureQueueOnce(job2.name);
494
571
  await boss.send(
495
572
  job2.name,
496
573
  {},
@@ -499,7 +576,7 @@ async function queueRunOnceJob(boss, job2) {
499
576
  singletonKey: `runOnce:${job2.name}`
500
577
  }
501
578
  );
502
- jobLogger2.info(`[Job:${job2.name}] runOnce job queued`);
579
+ jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
503
580
  }
504
581
  async function registerJob(job2) {
505
582
  const boss = getBoss();
@@ -507,17 +584,18 @@ async function registerJob(job2) {
507
584
  throw new Error("pg-boss not initialized");
508
585
  }
509
586
  const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
510
- jobLogger2.debug(`Registering job: ${job2.name}`, {
587
+ jobLogger3.debug(`Registering job: ${job2.name}`, {
511
588
  queueName,
512
589
  subscribedEvent: job2.subscribedEvent
513
590
  });
514
- await registerWorker(boss, job2, queueName);
591
+ const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
592
+ await registerWorker(boss, job2, queueName, ensureQueueOnce);
515
593
  connectEventToQueue(boss, job2, queueName);
516
- await registerCronSchedule(boss, job2);
517
- await queueRunOnceJob(boss, job2);
518
- jobLogger2.debug(`Job registered: ${job2.name}`);
594
+ await registerCronSchedule(boss, job2, ensureQueueOnce);
595
+ await queueRunOnceJob(boss, job2, ensureQueueOnce);
596
+ jobLogger3.debug(`Job registered: ${job2.name}`);
519
597
  }
520
598
 
521
- export { collectJobs, defineJobRouter, getBoss, initBoss, isBossRunning, isJobDef, isJobRouter, job, registerJobs, shouldClearOnStart, stopBoss };
599
+ export { collectJobs, defineJobRouter, getBoss, initBoss, isBossRunning, isJobDef, isJobRouter, job, mergeJobRouters, registerJobs, resolveQueuePolicy, shouldClearOnStart, stopBoss };
522
600
  //# sourceMappingURL=index.js.map
523
601
  //# sourceMappingURL=index.js.map