@spfn/core 0.3.0-beta.5 → 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.
@@ -2275,6 +2275,20 @@ function shouldSweepOrphanSchedules() {
2275
2275
  return getBossConfig()?.sweepOrphanSchedules ?? false;
2276
2276
  }
2277
2277
 
2278
+ // src/job/queue-policy.ts
2279
+ function resolveQueuePolicy(job2) {
2280
+ if (job2.options?.policy) {
2281
+ return job2.options.policy;
2282
+ }
2283
+ if (job2.runOnce || job2.options?.singletonKey) {
2284
+ return "exclusive";
2285
+ }
2286
+ return "standard";
2287
+ }
2288
+
2289
+ // src/job/job-builder.ts
2290
+ logger.child("@spfn/core:job");
2291
+
2278
2292
  // src/job/job-router.ts
2279
2293
  function isJobDef(value) {
2280
2294
  return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
@@ -2282,19 +2296,38 @@ function isJobDef(value) {
2282
2296
  function isJobRouter(value) {
2283
2297
  return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
2284
2298
  }
2285
- function collectJobs(router, prefix = "") {
2286
- const jobs = [];
2299
+ function defineJobRouter(jobs) {
2300
+ return {
2301
+ jobs,
2302
+ _jobs: jobs
2303
+ };
2304
+ }
2305
+ function mergeJobRouters(base, extra) {
2306
+ for (const key of Object.keys(extra.jobs)) {
2307
+ if (key in base.jobs) {
2308
+ throw new Error(
2309
+ `jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
2310
+ );
2311
+ }
2312
+ }
2313
+ return defineJobRouter({ ...base.jobs, ...extra.jobs });
2314
+ }
2315
+ function collectJobEntries(router, prefix = "") {
2316
+ const entries = [];
2287
2317
  for (const [key, value] of Object.entries(router.jobs)) {
2288
- const name = prefix ? `${prefix}.${key}` : key;
2318
+ const path = prefix ? `${prefix}.${key}` : key;
2289
2319
  if (isJobRouter(value)) {
2290
- jobs.push(...collectJobs(value, name));
2320
+ entries.push(...collectJobEntries(value, path));
2291
2321
  } else if (isJobDef(value)) {
2292
- jobs.push(value);
2322
+ entries.push({ key: path, job: value });
2293
2323
  }
2294
2324
  }
2295
- return jobs;
2325
+ return entries;
2296
2326
  }
2297
- var jobLogger2 = logger.child("@spfn/core:job");
2327
+ function collectJobs(router, prefix = "") {
2328
+ return collectJobEntries(router, prefix).map((entry) => entry.job);
2329
+ }
2330
+ var jobLogger3 = logger.child("@spfn/core:job");
2298
2331
  function getEventQueueName(eventName) {
2299
2332
  return `event:${eventName}`;
2300
2333
  }
@@ -2315,11 +2348,12 @@ async function registerJobs(router) {
2315
2348
  "pg-boss not initialized. Call initBoss() before registerJobs()"
2316
2349
  );
2317
2350
  }
2351
+ assertUniqueJobNames(router);
2318
2352
  const jobs = collectJobs(router);
2319
2353
  const clearOnStart = shouldClearOnStart();
2320
- jobLogger2.info(`Registering ${jobs.length} job(s)...`);
2354
+ jobLogger3.info(`Registering ${jobs.length} job(s)...`);
2321
2355
  if (clearOnStart) {
2322
- jobLogger2.info("Clearing existing jobs before registration...");
2356
+ jobLogger3.info("Clearing existing jobs before registration...");
2323
2357
  for (const job2 of jobs) {
2324
2358
  await boss.deleteAllJobs(job2.name);
2325
2359
  if (job2.subscribedEvent) {
@@ -2327,7 +2361,7 @@ async function registerJobs(router) {
2327
2361
  await boss.deleteAllJobs(eventQueue);
2328
2362
  }
2329
2363
  }
2330
- jobLogger2.info("Existing jobs cleared");
2364
+ jobLogger3.info("Existing jobs cleared");
2331
2365
  }
2332
2366
  await Promise.all(jobs.map((job2) => registerJob(job2)));
2333
2367
  for (const job2 of jobs) {
@@ -2338,42 +2372,77 @@ async function registerJobs(router) {
2338
2372
  if (shouldSweepOrphanSchedules()) {
2339
2373
  await sweepOrphanSchedules(boss);
2340
2374
  }
2341
- jobLogger2.info("All jobs registered successfully");
2375
+ jobLogger3.info("All jobs registered successfully");
2376
+ }
2377
+ function assertUniqueJobNames(router) {
2378
+ const keysByName = /* @__PURE__ */ new Map();
2379
+ for (const { key, job: job2 } of collectJobEntries(router)) {
2380
+ const existingKey = keysByName.get(job2.name);
2381
+ if (existingKey) {
2382
+ throw new Error(
2383
+ `registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
2384
+ );
2385
+ }
2386
+ keysByName.set(job2.name, key);
2387
+ }
2342
2388
  }
2343
2389
  var registeredCronNames = /* @__PURE__ */ new Set();
2344
2390
  async function sweepOrphanSchedules(boss) {
2345
2391
  if (registeredCronNames.size === 0) {
2346
- jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
2392
+ jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
2347
2393
  return;
2348
2394
  }
2349
2395
  try {
2350
2396
  const schedules = await boss.getSchedules();
2351
2397
  const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
2352
2398
  if (orphans.length === 0) {
2353
- jobLogger2.debug("No orphan schedules found");
2399
+ jobLogger3.debug("No orphan schedules found");
2354
2400
  return;
2355
2401
  }
2356
2402
  await Promise.all(orphans.map(async (orphan) => {
2357
2403
  try {
2358
2404
  await boss.unschedule(orphan.name, orphan.key);
2359
- jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
2405
+ jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
2360
2406
  } catch (error) {
2361
- jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
2407
+ jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
2362
2408
  error: error instanceof Error ? error.message : String(error)
2363
2409
  });
2364
2410
  }
2365
2411
  }));
2366
2412
  } catch (error) {
2367
- jobLogger2.error("Orphan schedule sweep failed", {
2413
+ jobLogger3.error("Orphan schedule sweep failed", {
2368
2414
  error: error instanceof Error ? error.message : String(error)
2369
2415
  });
2370
2416
  }
2371
2417
  }
2372
- async function ensureQueue(boss, queueName) {
2373
- await boss.createQueue(queueName);
2418
+ async function ensureQueue(boss, queueName, policy) {
2419
+ await boss.createQueue(queueName, { policy });
2420
+ const existing = await boss.getQueue(queueName);
2421
+ if (!existing || existing.policy === policy) {
2422
+ return;
2423
+ }
2424
+ if (shouldClearOnStart()) {
2425
+ await boss.deleteQueue(queueName);
2426
+ await boss.createQueue(queueName, { policy });
2427
+ jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
2428
+ return;
2429
+ }
2430
+ jobLogger3.error(
2431
+ `[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`
2432
+ );
2433
+ }
2434
+ function createQueueEnsurer(boss, policy) {
2435
+ const ensured = /* @__PURE__ */ new Set();
2436
+ return async (queueName) => {
2437
+ if (ensured.has(queueName)) {
2438
+ return;
2439
+ }
2440
+ ensured.add(queueName);
2441
+ await ensureQueue(boss, queueName, policy);
2442
+ };
2374
2443
  }
2375
2444
  async function executeJobHandler(job2, pgBossJob) {
2376
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
2445
+ jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
2377
2446
  const startTime = Date.now();
2378
2447
  try {
2379
2448
  if (job2.inputSchema) {
@@ -2382,13 +2451,13 @@ async function executeJobHandler(job2, pgBossJob) {
2382
2451
  await job2.handler();
2383
2452
  }
2384
2453
  const duration = Date.now() - startTime;
2385
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
2454
+ jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
2386
2455
  jobId: pgBossJob.id,
2387
2456
  duration
2388
2457
  });
2389
2458
  } catch (error) {
2390
2459
  const duration = Date.now() - startTime;
2391
- jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
2460
+ jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
2392
2461
  jobId: pgBossJob.id,
2393
2462
  duration,
2394
2463
  error: error instanceof Error ? error.message : String(error)
@@ -2396,8 +2465,8 @@ async function executeJobHandler(job2, pgBossJob) {
2396
2465
  throw error;
2397
2466
  }
2398
2467
  }
2399
- async function registerWorker(boss, job2, queueName) {
2400
- await ensureQueue(boss, queueName);
2468
+ async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
2469
+ await ensureQueueOnce(queueName);
2401
2470
  const batchSize = job2.options?.batchSize ?? 1;
2402
2471
  const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
2403
2472
  await boss.work(
@@ -2431,28 +2500,28 @@ function connectEventToQueue(boss, job2, queueName) {
2431
2500
  eventDef._registerJobQueue(queueName, async (queue, payload) => {
2432
2501
  await boss.send(queue, payload, getDefaultJobOptions(job2.options));
2433
2502
  });
2434
- jobLogger2.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2503
+ jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2435
2504
  }
2436
- async function registerCronSchedule(boss, job2) {
2505
+ async function registerCronSchedule(boss, job2, ensureQueueOnce) {
2437
2506
  if (!job2.cronExpression) {
2438
2507
  return;
2439
2508
  }
2440
- jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2441
- await ensureQueue(boss, job2.name);
2509
+ jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2510
+ await ensureQueueOnce(job2.name);
2442
2511
  await boss.schedule(
2443
2512
  job2.name,
2444
2513
  job2.cronExpression,
2445
2514
  {},
2446
2515
  getDefaultJobOptions(job2.options)
2447
2516
  );
2448
- jobLogger2.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2517
+ jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2449
2518
  }
2450
- async function queueRunOnceJob(boss, job2) {
2519
+ async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
2451
2520
  if (!job2.runOnce) {
2452
2521
  return;
2453
2522
  }
2454
- jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
2455
- await ensureQueue(boss, job2.name);
2523
+ jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
2524
+ await ensureQueueOnce(job2.name);
2456
2525
  await boss.send(
2457
2526
  job2.name,
2458
2527
  {},
@@ -2461,7 +2530,7 @@ async function queueRunOnceJob(boss, job2) {
2461
2530
  singletonKey: `runOnce:${job2.name}`
2462
2531
  }
2463
2532
  );
2464
- jobLogger2.info(`[Job:${job2.name}] runOnce job queued`);
2533
+ jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
2465
2534
  }
2466
2535
  async function registerJob(job2) {
2467
2536
  const boss = getBoss();
@@ -2469,15 +2538,16 @@ async function registerJob(job2) {
2469
2538
  throw new Error("pg-boss not initialized");
2470
2539
  }
2471
2540
  const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
2472
- jobLogger2.debug(`Registering job: ${job2.name}`, {
2541
+ jobLogger3.debug(`Registering job: ${job2.name}`, {
2473
2542
  queueName,
2474
2543
  subscribedEvent: job2.subscribedEvent
2475
2544
  });
2476
- await registerWorker(boss, job2, queueName);
2545
+ const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
2546
+ await registerWorker(boss, job2, queueName, ensureQueueOnce);
2477
2547
  connectEventToQueue(boss, job2, queueName);
2478
- await registerCronSchedule(boss, job2);
2479
- await queueRunOnceJob(boss, job2);
2480
- jobLogger2.debug(`Job registered: ${job2.name}`);
2548
+ await registerCronSchedule(boss, job2, ensureQueueOnce);
2549
+ await queueRunOnceJob(boss, job2, ensureQueueOnce);
2550
+ jobLogger3.debug(`Job registered: ${job2.name}`);
2481
2551
  }
2482
2552
  var wsLogger = logger.child("@spfn/core:ws");
2483
2553
  function isOriginAllowed(origin, allowList) {
@@ -3506,6 +3576,20 @@ var ServerConfigBuilder = class {
3506
3576
  /**
3507
3577
  * Register background jobs router
3508
3578
  *
3579
+ * May be called once per domain router: repeated calls **merge** into one
3580
+ * router, the way `.routes()` composes. Calling it several times used to
3581
+ * keep only the last router and register nothing else, without warning.
3582
+ *
3583
+ * The pg-boss `config` is per server, so pass it on one call only.
3584
+ *
3585
+ * Two names can collide, and both fail loudly:
3586
+ * - a router **key** present in two calls throws here, at config time;
3587
+ * - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
3588
+ * `registerJobs()` at boot, before any queue or worker is created.
3589
+ *
3590
+ * @throws if a router key was already registered by an earlier call
3591
+ * @throws if `config` is given when an earlier call already gave one
3592
+ *
3509
3593
  * @example
3510
3594
  * ```typescript
3511
3595
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -3518,12 +3602,18 @@ var ServerConfigBuilder = class {
3518
3602
  *
3519
3603
  * export default defineServerConfig()
3520
3604
  * .routes(appRouter)
3521
- * .jobs(jobRouter)
3605
+ * .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
3606
+ * .jobs(billingJobRouter) // merged, not replaced
3522
3607
  * .build();
3523
3608
  * ```
3524
3609
  */
3525
3610
  jobs(router, config) {
3526
- this.config.jobs = router;
3611
+ if (config && this.config.jobsConfig) {
3612
+ throw new Error(
3613
+ "jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
3614
+ );
3615
+ }
3616
+ this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
3527
3617
  if (config) {
3528
3618
  this.config.jobsConfig = config;
3529
3619
  }