@getstrata/core 2.1.0 → 2.2.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/core/cache/createCacheStore.d.ts +3 -1
  3. package/dist/core/cache/redisCacheStore.d.ts +21 -8
  4. package/dist/core/cache/repository.d.ts +3 -2
  5. package/dist/core/cache/store.d.ts +4 -1
  6. package/dist/core/cache/taggedCache.d.ts +1 -1
  7. package/dist/core/contracts/di.d.ts +4 -2
  8. package/dist/core/contracts/serviceTokens.d.ts +6 -1
  9. package/dist/core/database/baseRepository.d.ts +0 -1
  10. package/dist/core/database/index.d.ts +1 -1
  11. package/dist/core/database/model.d.ts +51 -22
  12. package/dist/core/database/repositoryQuery.d.ts +16 -1
  13. package/dist/core/database/types.d.ts +25 -2
  14. package/dist/core/database/whereBuilder.d.ts +5 -5
  15. package/dist/core/events/deferredModelEvents.d.ts +2 -1
  16. package/dist/core/http/loginThrottleMiddleware.d.ts +6 -5
  17. package/dist/core/http/memoryThrottleMiddleware.d.ts +11 -3
  18. package/dist/core/http/scimThrottleMiddleware.d.ts +12 -3
  19. package/dist/core/http/throttleMiddleware.d.ts +24 -6
  20. package/dist/core/lifecycle/gracefulShutdown.d.ts +33 -5
  21. package/dist/core/queue/index.d.ts +7 -0
  22. package/dist/core/queue/redisQueue.d.ts +1 -0
  23. package/dist/core/queue/resilientQueue.d.ts +2 -2
  24. package/dist/core/runtime/applicationRegistry.d.ts +2 -1
  25. package/dist/core/runtime/boundedThrottleStore.d.ts +29 -0
  26. package/dist/core/scheduler/leases.d.ts +40 -0
  27. package/dist/core/scheduler/schedule.d.ts +29 -5
  28. package/dist/core/tenant/tenantContext.d.ts +5 -4
  29. package/dist/core/tenant/tenantMiddleware.d.ts +7 -1
  30. package/dist/core/tracing/otel.d.ts +28 -26
  31. package/dist/core/tracing/tracingMiddleware.d.ts +4 -1
  32. package/dist/entries/audit/exportAuditLogs.js +14 -2
  33. package/dist/entries/auth/basicAuthGuard.js +80 -0
  34. package/dist/entries/auth/jwtGuard.js +80 -0
  35. package/dist/entries/auth/scimAuthMiddleware.js +17 -7
  36. package/dist/entries/auth/sessionGuard.js +80 -0
  37. package/dist/entries/cache/createCacheStore.js +365 -119
  38. package/dist/entries/cache/repository.js +5 -0
  39. package/dist/entries/contracts/serviceTokens.js +83 -0
  40. package/dist/entries/database/factory.js +0 -3
  41. package/dist/entries/database/model.js +1 -2486
  42. package/dist/entries/database/repositoryQuery.js +67 -9
  43. package/dist/entries/http/bodySizeLimitMiddleware.js +76 -10
  44. package/dist/entries/http/memoryThrottleMiddleware.js +276 -21
  45. package/dist/entries/http/scimThrottleMiddleware.js +576 -54
  46. package/dist/entries/http/throttleMiddleware.js +65 -16
  47. package/dist/entries/jobs/exportAuditLogsJob.js +44 -7
  48. package/dist/entries/jobs/invalidateCacheTagsJob.js +30 -5
  49. package/dist/entries/queue/createAppQueue.js +67 -6
  50. package/dist/entries/queue/publicQueue.js +67 -6
  51. package/dist/entries/queue/queueMetrics.js +67 -6
  52. package/dist/entries/queue/redisQueue.js +3 -0
  53. package/dist/entries/queue.js +30 -5
  54. package/dist/entries/scheduler/schedule.js +1 -72
  55. package/dist/entries/tenant/databaseTenantContext.js +14 -2
  56. package/dist/entries/tenant/tenantDatabaseScope.js +14 -2
  57. package/dist/entries/tracing/tracingMiddleware.js +186 -454
  58. package/dist/framework/public-api.d.ts +13 -9
  59. package/dist/index.js +1745 -633
  60. package/dist/types/services.d.ts +3 -3
  61. package/package.json +17 -8
@@ -29,7 +29,7 @@ var init_asyncContextStore = () => {};
29
29
  // ../../src/core/http/throttleMiddleware.ts
30
30
  import { createHash } from "crypto";
31
31
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
32
- import { currentTenant, rateLimitMultiplierForPlan } from "@getstrata/core/tenant/tenantContext";
32
+ import { currentTenant } from "@getstrata/core/tenant/tenantContext";
33
33
  var {RedisClient } = globalThis.Bun;
34
34
 
35
35
  // ../../src/core/runtime/appEnv.ts
@@ -397,28 +397,32 @@ if ttl < 0 then
397
397
  end
398
398
  return count
399
399
  `;
400
- function redisThrottleKey(request, prefix, identity) {
400
+ function redisThrottleKey(request, prefix, identity, tenantId = currentTenant()?.id ?? null) {
401
401
  const bucket = JSON.stringify([
402
- currentTenant()?.id ?? null,
402
+ tenantId,
403
403
  request.method,
404
404
  currentRequestMeta2().routeTemplate ?? "__unmatched__",
405
405
  identity
406
406
  ]);
407
407
  return namespacedRedisKey(`${prefix}:${createHash("sha256").update(bucket).digest("hex")}`);
408
408
  }
409
- async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000) {
410
- if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
409
+ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000, signal) {
410
+ const expiryMs = Math.max(1, Math.ceil(decaySeconds * 1000));
411
+ if (!Number.isFinite(decaySeconds) || decaySeconds < 0 || !Number.isSafeInteger(expiryMs) || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
411
412
  throw new Error("Invalid throttle expiry or command timeout.");
412
413
  }
414
+ if (signal?.aborted)
415
+ throw new Error("Throttle consumer is disposed.");
416
+ let onAbort;
413
417
  let timer;
414
418
  try {
419
+ const cancelled = new Promise((_resolve, reject) => {
420
+ onAbort = () => reject(new Error("Throttle consumer is disposed."));
421
+ signal?.addEventListener("abort", onAbort, { once: true });
422
+ });
415
423
  const result = await Promise.race([
416
- client.send("EVAL", [
417
- CONSUME_THROTTLE,
418
- "1",
419
- key,
420
- String(Math.max(1, Math.ceil(decaySeconds * 1000)))
421
- ]),
424
+ cancelled,
425
+ client.send("EVAL", [CONSUME_THROTTLE, "1", key, String(expiryMs)]),
422
426
  new Promise((_resolve, reject) => {
423
427
  timer = setTimeout(() => reject(new Error("Throttle store deadline exceeded.")), timeoutMs);
424
428
  })
@@ -429,11 +433,16 @@ async function consumeRedisThrottle(client, key, decaySeconds, timeoutMs = 1000)
429
433
  return count;
430
434
  } finally {
431
435
  clearTimeout(timer);
436
+ if (onAbort)
437
+ signal?.removeEventListener("abort", onAbort);
432
438
  }
433
439
  }
434
440
  function createRedisThrottleConsumer(options) {
435
441
  let ownedClient;
436
- return async (key, decaySeconds) => {
442
+ const controller = new AbortController;
443
+ const consume = async (key, decaySeconds) => {
444
+ if (controller.signal.aborted)
445
+ throw new Error("Throttle consumer is disposed.");
437
446
  if (!options.redisClient && !ownedClient) {
438
447
  ownedClient = new RedisClient(options.redisUrl, { autoReconnect: false, maxRetries: 0 });
439
448
  }
@@ -441,7 +450,10 @@ function createRedisThrottleConsumer(options) {
441
450
  if (!client)
442
451
  throw new Error("Throttle client is required.");
443
452
  try {
444
- return await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs);
453
+ const attempts = await consumeRedisThrottle(client, key, decaySeconds, options.commandTimeoutMs, controller.signal);
454
+ if (controller.signal.aborted)
455
+ throw new Error("Throttle consumer is disposed.");
456
+ return attempts;
445
457
  } catch (error) {
446
458
  if (client === ownedClient) {
447
459
  ownedClient.close();
@@ -450,6 +462,17 @@ function createRedisThrottleConsumer(options) {
450
462
  throw error;
451
463
  }
452
464
  };
465
+ return Object.assign(consume, {
466
+ isDisposed: () => controller.signal.aborted,
467
+ dispose() {
468
+ if (controller.signal.aborted)
469
+ return;
470
+ controller.abort();
471
+ const client = ownedClient;
472
+ ownedClient = undefined;
473
+ client?.close();
474
+ }
475
+ });
453
476
  }
454
477
  function throttleUnavailableResponse() {
455
478
  return Response.json({ error: "Rate limiting is temporarily unavailable." }, {
@@ -457,23 +480,48 @@ function throttleUnavailableResponse() {
457
480
  headers: { "retry-after": "1" }
458
481
  });
459
482
  }
483
+ function resolveThrottleQuota(request, options, identity = resolveThrottleIdentity(request)) {
484
+ const limit = options.quotaPolicy ? options.quotaPolicy({
485
+ request,
486
+ tenant: currentTenant(),
487
+ identity,
488
+ routeTemplate: currentRequestMeta2().routeTemplate ?? "__unmatched__",
489
+ maxAttempts: options.maxAttempts
490
+ }) : options.maxAttempts;
491
+ if (!Number.isSafeInteger(limit) || limit < 0) {
492
+ throw new Error("Invalid application quota.");
493
+ }
494
+ return limit;
495
+ }
460
496
  function createThrottleMiddleware(options) {
497
+ if (!Number.isSafeInteger(options.maxAttempts) || options.maxAttempts < 0) {
498
+ throw new Error("Throttle maxAttempts must be a non-negative safe integer.");
499
+ }
461
500
  const consume = createRedisThrottleConsumer(options);
462
501
  const prefix = options.keyPrefix ?? "throttle";
463
- return async (request, next) => {
464
- const key = redisThrottleKey(request, prefix, resolveThrottleIdentity(request));
502
+ const middleware = async (request, next) => {
503
+ const identity = resolveThrottleIdentity(request);
504
+ let maxAttempts;
505
+ try {
506
+ maxAttempts = resolveThrottleQuota(request, options, identity);
507
+ } catch {
508
+ return throttleUnavailableResponse();
509
+ }
510
+ const key = redisThrottleKey(request, prefix, identity);
465
511
  let attempts;
466
512
  try {
467
513
  attempts = await consume(key, options.decaySeconds);
468
514
  } catch {
469
515
  return throttleUnavailableResponse();
470
516
  }
471
- const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
517
+ if (consume.isDisposed())
518
+ return throttleUnavailableResponse();
472
519
  if (attempts > maxAttempts) {
473
520
  return await tooManyRequestsResponse(request, "Too many requests.", options.decaySeconds);
474
521
  }
475
522
  return await next();
476
523
  };
524
+ return Object.assign(middleware, { dispose: () => consume.dispose() });
477
525
  }
478
526
  export {
479
527
  consumeRedisThrottle,
@@ -481,5 +529,6 @@ export {
481
529
  createThrottleMiddleware,
482
530
  redisThrottleKey,
483
531
  resolveThrottleIdentity,
532
+ resolveThrottleQuota,
484
533
  throttleUnavailableResponse
485
534
  };
@@ -306,9 +306,12 @@ async function runWithDeferredModelEvents(callback) {
306
306
  const existing = deferredModelEvents.getStore();
307
307
  if (existing) {
308
308
  existing.frames.push([]);
309
+ existing.rollbackFrames.push([]);
309
310
  try {
310
311
  const result = await callback();
311
312
  const nested = existing.frames.pop() ?? [];
313
+ const rollback = existing.rollbackFrames.pop() ?? [];
314
+ existing.rollbackFrames.at(-1)?.push(...rollback);
312
315
  const parent = existing.frames[existing.frames.length - 1];
313
316
  if (parent) {
314
317
  parent.push(...nested);
@@ -316,11 +319,20 @@ async function runWithDeferredModelEvents(callback) {
316
319
  return result;
317
320
  } catch (error) {
318
321
  existing.frames.pop();
322
+ for (const rollback of (existing.rollbackFrames.pop() ?? []).reverse())
323
+ rollback();
319
324
  throw error;
320
325
  }
321
326
  }
322
- const state = { frames: [[]] };
323
- const result = await deferredModelEvents.run(state, callback);
327
+ const state = { frames: [[]], rollbackFrames: [[]] };
328
+ let result;
329
+ try {
330
+ result = await deferredModelEvents.run(state, callback);
331
+ } catch (error) {
332
+ for (const rollback of (state.rollbackFrames[0] ?? []).reverse())
333
+ rollback();
334
+ throw error;
335
+ }
324
336
  await dispatchQueuedModelEvents(state.frames[0] ?? []);
325
337
  return result;
326
338
  }
@@ -1450,12 +1462,37 @@ class SyncQueue {
1450
1462
  }
1451
1463
 
1452
1464
  class AsyncQueue {
1465
+ active = new Set;
1466
+ closing;
1467
+ closed = false;
1468
+ assertOpen() {
1469
+ if (this.closed)
1470
+ throw new Error("Async queue is closed.");
1471
+ }
1472
+ enqueue(task, onError) {
1473
+ this.assertOpen();
1474
+ const running = new Promise((resolve) => {
1475
+ setTimeout(() => {
1476
+ Promise.resolve().then(task).catch(onError).finally(resolve);
1477
+ }, 0);
1478
+ });
1479
+ this.active.add(running);
1480
+ running.then(() => {
1481
+ this.active.delete(running);
1482
+ });
1483
+ }
1453
1484
  async dispatch(job, payload) {
1454
- setTimeout(() => {
1455
- job.handle(payload).catch((error) => {
1456
- console.error("[AsyncQueue] Job failed:", error);
1457
- });
1458
- }, 0);
1485
+ this.enqueue(() => job.handle(payload), (error) => {
1486
+ console.error("[AsyncQueue] Job failed:", error);
1487
+ });
1488
+ }
1489
+ close() {
1490
+ this.closing ??= (async () => {
1491
+ while (this.active.size)
1492
+ await Promise.all([...this.active]);
1493
+ this.closed = true;
1494
+ })();
1495
+ return this.closing;
1459
1496
  }
1460
1497
  }
1461
1498
  function createQueue(driver) {
@@ -57,12 +57,37 @@ class SyncQueue {
57
57
  }
58
58
 
59
59
  class AsyncQueue {
60
+ active = new Set;
61
+ closing;
62
+ closed = false;
63
+ assertOpen() {
64
+ if (this.closed)
65
+ throw new Error("Async queue is closed.");
66
+ }
67
+ enqueue(task, onError) {
68
+ this.assertOpen();
69
+ const running = new Promise((resolve) => {
70
+ setTimeout(() => {
71
+ Promise.resolve().then(task).catch(onError).finally(resolve);
72
+ }, 0);
73
+ });
74
+ this.active.add(running);
75
+ running.then(() => {
76
+ this.active.delete(running);
77
+ });
78
+ }
60
79
  async dispatch(job, payload) {
61
- setTimeout(() => {
62
- job.handle(payload).catch((error) => {
63
- console.error("[AsyncQueue] Job failed:", error);
64
- });
65
- }, 0);
80
+ this.enqueue(() => job.handle(payload), (error) => {
81
+ console.error("[AsyncQueue] Job failed:", error);
82
+ });
83
+ }
84
+ close() {
85
+ this.closing ??= (async () => {
86
+ while (this.active.size)
87
+ await Promise.all([...this.active]);
88
+ this.closed = true;
89
+ })();
90
+ return this.closing;
66
91
  }
67
92
  }
68
93
  function createQueue(driver) {
@@ -451,6 +451,9 @@ class RedisQueue {
451
451
  constructor(redisUrl) {
452
452
  this.client = new RedisClient(redisUrl);
453
453
  }
454
+ close() {
455
+ this.client.close();
456
+ }
454
457
  async dispatch(job, payload) {
455
458
  const name = jobRegistry.resolveName(job);
456
459
  if (!name) {
@@ -579,15 +582,75 @@ async function countPendingQueueJobs(redisUrl) {
579
582
  }
580
583
  }
581
584
 
585
+ // ../../src/core/queue/index.ts
586
+ class Job {
587
+ static jobName;
588
+ maxAttempts;
589
+ backoffMs;
590
+ priority;
591
+ constructor() {
592
+ const jobName = this.constructor.jobName;
593
+ if (jobName) {
594
+ jobRegistry.track(jobName, this);
595
+ }
596
+ }
597
+ }
598
+
599
+ class SyncQueue {
600
+ async dispatch(job, payload) {
601
+ await job.handle(payload);
602
+ }
603
+ }
604
+
605
+ class AsyncQueue {
606
+ active = new Set;
607
+ closing;
608
+ closed = false;
609
+ assertOpen() {
610
+ if (this.closed)
611
+ throw new Error("Async queue is closed.");
612
+ }
613
+ enqueue(task, onError) {
614
+ this.assertOpen();
615
+ const running = new Promise((resolve) => {
616
+ setTimeout(() => {
617
+ Promise.resolve().then(task).catch(onError).finally(resolve);
618
+ }, 0);
619
+ });
620
+ this.active.add(running);
621
+ running.then(() => {
622
+ this.active.delete(running);
623
+ });
624
+ }
625
+ async dispatch(job, payload) {
626
+ this.enqueue(() => job.handle(payload), (error) => {
627
+ console.error("[AsyncQueue] Job failed:", error);
628
+ });
629
+ }
630
+ close() {
631
+ this.closing ??= (async () => {
632
+ while (this.active.size)
633
+ await Promise.all([...this.active]);
634
+ this.closed = true;
635
+ })();
636
+ return this.closing;
637
+ }
638
+ }
639
+ function createQueue(driver) {
640
+ return driver === "async" ? new AsyncQueue : new SyncQueue;
641
+ }
642
+
582
643
  // ../../src/core/queue/resilientQueue.ts
583
- class ResilientQueue {
644
+ class ResilientQueue extends AsyncQueue {
584
645
  failedJobs;
585
646
  asyncDispatch;
586
647
  constructor(failedJobs, asyncDispatch = false) {
648
+ super();
587
649
  this.failedJobs = failedJobs;
588
650
  this.asyncDispatch = asyncDispatch;
589
651
  }
590
652
  async dispatch(job, payload) {
653
+ this.assertOpen();
591
654
  const name = jobRegistry.resolveName(job);
592
655
  if (!name) {
593
656
  throw new Error("Job is not registered with the queue worker registry.");
@@ -598,11 +661,9 @@ class ResilientQueue {
598
661
  attempts: 0
599
662
  };
600
663
  if (this.asyncDispatch) {
601
- setTimeout(() => {
602
- runQueueJob(envelope, this.failedJobs).catch((error) => {
603
- console.error("[ResilientQueue] Job failed:", error);
604
- });
605
- }, 0);
664
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
665
+ console.error("[ResilientQueue] Job failed:", error);
666
+ });
606
667
  return;
607
668
  }
608
669
  await runQueueJob(envelope, this.failedJobs);
@@ -451,6 +451,9 @@ class RedisQueue {
451
451
  constructor(redisUrl) {
452
452
  this.client = new RedisClient(redisUrl);
453
453
  }
454
+ close() {
455
+ this.client.close();
456
+ }
454
457
  async dispatch(job, payload) {
455
458
  const name = jobRegistry.resolveName(job);
456
459
  if (!name) {
@@ -579,15 +582,75 @@ async function countPendingQueueJobs(redisUrl) {
579
582
  }
580
583
  }
581
584
 
585
+ // ../../src/core/queue/index.ts
586
+ class Job {
587
+ static jobName;
588
+ maxAttempts;
589
+ backoffMs;
590
+ priority;
591
+ constructor() {
592
+ const jobName = this.constructor.jobName;
593
+ if (jobName) {
594
+ jobRegistry.track(jobName, this);
595
+ }
596
+ }
597
+ }
598
+
599
+ class SyncQueue {
600
+ async dispatch(job, payload) {
601
+ await job.handle(payload);
602
+ }
603
+ }
604
+
605
+ class AsyncQueue {
606
+ active = new Set;
607
+ closing;
608
+ closed = false;
609
+ assertOpen() {
610
+ if (this.closed)
611
+ throw new Error("Async queue is closed.");
612
+ }
613
+ enqueue(task, onError) {
614
+ this.assertOpen();
615
+ const running = new Promise((resolve) => {
616
+ setTimeout(() => {
617
+ Promise.resolve().then(task).catch(onError).finally(resolve);
618
+ }, 0);
619
+ });
620
+ this.active.add(running);
621
+ running.then(() => {
622
+ this.active.delete(running);
623
+ });
624
+ }
625
+ async dispatch(job, payload) {
626
+ this.enqueue(() => job.handle(payload), (error) => {
627
+ console.error("[AsyncQueue] Job failed:", error);
628
+ });
629
+ }
630
+ close() {
631
+ this.closing ??= (async () => {
632
+ while (this.active.size)
633
+ await Promise.all([...this.active]);
634
+ this.closed = true;
635
+ })();
636
+ return this.closing;
637
+ }
638
+ }
639
+ function createQueue(driver) {
640
+ return driver === "async" ? new AsyncQueue : new SyncQueue;
641
+ }
642
+
582
643
  // ../../src/core/queue/resilientQueue.ts
583
- class ResilientQueue {
644
+ class ResilientQueue extends AsyncQueue {
584
645
  failedJobs;
585
646
  asyncDispatch;
586
647
  constructor(failedJobs, asyncDispatch = false) {
648
+ super();
587
649
  this.failedJobs = failedJobs;
588
650
  this.asyncDispatch = asyncDispatch;
589
651
  }
590
652
  async dispatch(job, payload) {
653
+ this.assertOpen();
591
654
  const name = jobRegistry.resolveName(job);
592
655
  if (!name) {
593
656
  throw new Error("Job is not registered with the queue worker registry.");
@@ -598,11 +661,9 @@ class ResilientQueue {
598
661
  attempts: 0
599
662
  };
600
663
  if (this.asyncDispatch) {
601
- setTimeout(() => {
602
- runQueueJob(envelope, this.failedJobs).catch((error) => {
603
- console.error("[ResilientQueue] Job failed:", error);
604
- });
605
- }, 0);
664
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
665
+ console.error("[ResilientQueue] Job failed:", error);
666
+ });
606
667
  return;
607
668
  }
608
669
  await runQueueJob(envelope, this.failedJobs);
@@ -454,6 +454,9 @@ class RedisQueue {
454
454
  constructor(redisUrl) {
455
455
  this.client = new RedisClient(redisUrl);
456
456
  }
457
+ close() {
458
+ this.client.close();
459
+ }
457
460
  async dispatch(job, payload) {
458
461
  const name = jobRegistry.resolveName(job);
459
462
  if (!name) {
@@ -582,15 +585,75 @@ async function countPendingQueueJobs(redisUrl) {
582
585
  }
583
586
  }
584
587
 
588
+ // ../../src/core/queue/index.ts
589
+ class Job {
590
+ static jobName;
591
+ maxAttempts;
592
+ backoffMs;
593
+ priority;
594
+ constructor() {
595
+ const jobName = this.constructor.jobName;
596
+ if (jobName) {
597
+ jobRegistry.track(jobName, this);
598
+ }
599
+ }
600
+ }
601
+
602
+ class SyncQueue {
603
+ async dispatch(job, payload) {
604
+ await job.handle(payload);
605
+ }
606
+ }
607
+
608
+ class AsyncQueue {
609
+ active = new Set;
610
+ closing;
611
+ closed = false;
612
+ assertOpen() {
613
+ if (this.closed)
614
+ throw new Error("Async queue is closed.");
615
+ }
616
+ enqueue(task, onError) {
617
+ this.assertOpen();
618
+ const running = new Promise((resolve) => {
619
+ setTimeout(() => {
620
+ Promise.resolve().then(task).catch(onError).finally(resolve);
621
+ }, 0);
622
+ });
623
+ this.active.add(running);
624
+ running.then(() => {
625
+ this.active.delete(running);
626
+ });
627
+ }
628
+ async dispatch(job, payload) {
629
+ this.enqueue(() => job.handle(payload), (error) => {
630
+ console.error("[AsyncQueue] Job failed:", error);
631
+ });
632
+ }
633
+ close() {
634
+ this.closing ??= (async () => {
635
+ while (this.active.size)
636
+ await Promise.all([...this.active]);
637
+ this.closed = true;
638
+ })();
639
+ return this.closing;
640
+ }
641
+ }
642
+ function createQueue(driver) {
643
+ return driver === "async" ? new AsyncQueue : new SyncQueue;
644
+ }
645
+
585
646
  // ../../src/core/queue/resilientQueue.ts
586
- class ResilientQueue {
647
+ class ResilientQueue extends AsyncQueue {
587
648
  failedJobs;
588
649
  asyncDispatch;
589
650
  constructor(failedJobs, asyncDispatch = false) {
651
+ super();
590
652
  this.failedJobs = failedJobs;
591
653
  this.asyncDispatch = asyncDispatch;
592
654
  }
593
655
  async dispatch(job, payload) {
656
+ this.assertOpen();
594
657
  const name = jobRegistry.resolveName(job);
595
658
  if (!name) {
596
659
  throw new Error("Job is not registered with the queue worker registry.");
@@ -601,11 +664,9 @@ class ResilientQueue {
601
664
  attempts: 0
602
665
  };
603
666
  if (this.asyncDispatch) {
604
- setTimeout(() => {
605
- runQueueJob(envelope, this.failedJobs).catch((error) => {
606
- console.error("[ResilientQueue] Job failed:", error);
607
- });
608
- }, 0);
667
+ this.enqueue(() => runQueueJob(envelope, this.failedJobs), (error) => {
668
+ console.error("[ResilientQueue] Job failed:", error);
669
+ });
609
670
  return;
610
671
  }
611
672
  await runQueueJob(envelope, this.failedJobs);
@@ -387,6 +387,9 @@ class RedisQueue {
387
387
  constructor(redisUrl) {
388
388
  this.client = new RedisClient(redisUrl);
389
389
  }
390
+ close() {
391
+ this.client.close();
392
+ }
390
393
  async dispatch(job, payload) {
391
394
  const name = jobRegistry.resolveName(job);
392
395
  if (!name) {
@@ -57,12 +57,37 @@ class SyncQueue {
57
57
  }
58
58
 
59
59
  class AsyncQueue {
60
+ active = new Set;
61
+ closing;
62
+ closed = false;
63
+ assertOpen() {
64
+ if (this.closed)
65
+ throw new Error("Async queue is closed.");
66
+ }
67
+ enqueue(task, onError) {
68
+ this.assertOpen();
69
+ const running = new Promise((resolve) => {
70
+ setTimeout(() => {
71
+ Promise.resolve().then(task).catch(onError).finally(resolve);
72
+ }, 0);
73
+ });
74
+ this.active.add(running);
75
+ running.then(() => {
76
+ this.active.delete(running);
77
+ });
78
+ }
60
79
  async dispatch(job, payload) {
61
- setTimeout(() => {
62
- job.handle(payload).catch((error) => {
63
- console.error("[AsyncQueue] Job failed:", error);
64
- });
65
- }, 0);
80
+ this.enqueue(() => job.handle(payload), (error) => {
81
+ console.error("[AsyncQueue] Job failed:", error);
82
+ });
83
+ }
84
+ close() {
85
+ this.closing ??= (async () => {
86
+ while (this.active.size)
87
+ await Promise.all([...this.active]);
88
+ this.closed = true;
89
+ })();
90
+ return this.closing;
66
91
  }
67
92
  }
68
93
  function createQueue(driver) {