@open-mercato/queue 0.4.11-develop.2235.49f509443f → 0.4.11-develop.2245.a1059680ca

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/dist/factory.js CHANGED
@@ -1,12 +1,28 @@
1
1
  import { createLocalQueue } from "./strategies/local.js";
2
2
  import { createAsyncQueue } from "./strategies/async.js";
3
+ import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
3
4
  function createQueue(name, strategy, options) {
4
5
  if (strategy === "async") {
5
6
  return createAsyncQueue(name, options);
6
7
  }
7
8
  return createLocalQueue(name, options);
8
9
  }
10
+ function resolveQueueStrategy() {
11
+ return process.env.QUEUE_STRATEGY === "async" ? "async" : "local";
12
+ }
13
+ function createModuleQueue(name, options) {
14
+ const strategy = resolveQueueStrategy();
15
+ if (strategy === "async") {
16
+ return createAsyncQueue(name, {
17
+ connection: { url: getRedisUrlOrThrow("QUEUE") },
18
+ concurrency: options?.concurrency
19
+ });
20
+ }
21
+ return createLocalQueue(name, { concurrency: options?.concurrency });
22
+ }
9
23
  export {
10
- createQueue
24
+ createModuleQueue,
25
+ createQueue,
26
+ resolveQueueStrategy
11
27
  };
12
28
  //# sourceMappingURL=factory.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/factory.ts"],
4
- "sourcesContent": ["import type { Queue, LocalQueueOptions, AsyncQueueOptions } from './types'\nimport { createLocalQueue } from './strategies/local'\nimport { createAsyncQueue } from './strategies/async'\n\n/**\n * Creates a queue instance with the specified strategy.\n *\n * @template T - The payload type for jobs in this queue\n * @param name - Unique name for the queue\n * @param strategy - Queue strategy: 'local' for file-based, 'async' for BullMQ\n * @param options - Strategy-specific options\n * @returns A Queue instance\n *\n * @example\n * ```typescript\n * // Local file-based queue\n * const localQueue = createQueue<MyJobData>('my-queue', 'local')\n *\n * // BullMQ-based queue\n * const asyncQueue = createQueue<MyJobData>('my-queue', 'async', {\n * connection: { url: 'redis://localhost:6379' },\n * concurrency: 5\n * })\n * ```\n */\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local',\n options?: LocalQueueOptions\n): Queue<T>\n\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'async',\n options?: AsyncQueueOptions\n): Queue<T>\n\n// General overload for dynamic strategy (union type)\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local' | 'async',\n options?: LocalQueueOptions | AsyncQueueOptions\n): Queue<T>\n\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local' | 'async',\n options?: LocalQueueOptions | AsyncQueueOptions\n): Queue<T> {\n if (strategy === 'async') {\n return createAsyncQueue<T>(name, options as AsyncQueueOptions)\n }\n\n return createLocalQueue<T>(name, options as LocalQueueOptions)\n}\n"],
5
- "mappings": "AACA,SAAS,wBAAwB;AACjC,SAAS,wBAAwB;AA0C1B,SAAS,YACd,MACA,UACA,SACU;AACV,MAAI,aAAa,SAAS;AACxB,WAAO,iBAAoB,MAAM,OAA4B;AAAA,EAC/D;AAEA,SAAO,iBAAoB,MAAM,OAA4B;AAC/D;",
4
+ "sourcesContent": ["import type { Queue, LocalQueueOptions, AsyncQueueOptions, QueueStrategyType } from './types'\nimport { createLocalQueue } from './strategies/local'\nimport { createAsyncQueue } from './strategies/async'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\n\n/**\n * Creates a queue instance with the specified strategy.\n *\n * @template T - The payload type for jobs in this queue\n * @param name - Unique name for the queue\n * @param strategy - Queue strategy: 'local' for file-based, 'async' for BullMQ\n * @param options - Strategy-specific options\n * @returns A Queue instance\n *\n * @example\n * ```typescript\n * // Local file-based queue\n * const localQueue = createQueue<MyJobData>('my-queue', 'local')\n *\n * // BullMQ-based queue\n * const asyncQueue = createQueue<MyJobData>('my-queue', 'async', {\n * connection: { url: 'redis://localhost:6379' },\n * concurrency: 5\n * })\n * ```\n */\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local',\n options?: LocalQueueOptions\n): Queue<T>\n\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'async',\n options?: AsyncQueueOptions\n): Queue<T>\n\n// General overload for dynamic strategy (union type)\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local' | 'async',\n options?: LocalQueueOptions | AsyncQueueOptions\n): Queue<T>\n\nexport function createQueue<T = unknown>(\n name: string,\n strategy: 'local' | 'async',\n options?: LocalQueueOptions | AsyncQueueOptions\n): Queue<T> {\n if (strategy === 'async') {\n return createAsyncQueue<T>(name, options as AsyncQueueOptions)\n }\n\n return createLocalQueue<T>(name, options as LocalQueueOptions)\n}\n\n/**\n * Resolve the queue strategy from `QUEUE_STRATEGY`. Defaults to `'local'`.\n */\nexport function resolveQueueStrategy(): QueueStrategyType {\n return process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local'\n}\n\n/**\n * Create a module-owned queue using the strategy declared in `QUEUE_STRATEGY`.\n *\n * - When `QUEUE_STRATEGY=async`, builds a BullMQ queue and resolves the\n * Redis URL via `getRedisUrlOrThrow('QUEUE')` so missing config fails loudly.\n * - Otherwise builds a local file-based queue.\n *\n * Replaces the boilerplate `process.env.QUEUE_STRATEGY === 'async' ? ... : ...`\n * pattern that every module queue helper used to repeat. Concurrency applies\n * to both strategies so the same number means the same thing in dev and prod.\n *\n * @example\n * ```typescript\n * export function getDataSyncQueue(name: string) {\n * return createModuleQueue<MyJob>(name, { concurrency: 5 })\n * }\n * ```\n */\nexport function createModuleQueue<T = unknown>(\n name: string,\n options?: { concurrency?: number },\n): Queue<T> {\n const strategy = resolveQueueStrategy()\n if (strategy === 'async') {\n return createAsyncQueue<T>(name, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n concurrency: options?.concurrency,\n })\n }\n return createLocalQueue<T>(name, { concurrency: options?.concurrency })\n}\n"],
5
+ "mappings": "AACA,SAAS,wBAAwB;AACjC,SAAS,wBAAwB;AACjC,SAAS,0BAA0B;AA0C5B,SAAS,YACd,MACA,UACA,SACU;AACV,MAAI,aAAa,SAAS;AACxB,WAAO,iBAAoB,MAAM,OAA4B;AAAA,EAC/D;AAEA,SAAO,iBAAoB,MAAM,OAA4B;AAC/D;AAKO,SAAS,uBAA0C;AACxD,SAAO,QAAQ,IAAI,mBAAmB,UAAU,UAAU;AAC5D;AAoBO,SAAS,kBACd,MACA,SACU;AACV,QAAM,WAAW,qBAAqB;AACtC,MAAI,aAAa,SAAS;AACxB,WAAO,iBAAoB,MAAM;AAAA,MAC/B,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE;AAAA,MAC/C,aAAa,SAAS;AAAA,IACxB,CAAC;AAAA,EACH;AACA,SAAO,iBAAoB,MAAM,EAAE,aAAa,SAAS,YAAY,CAAC;AACxE;",
6
6
  "names": []
7
7
  }
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from "./types.js";
2
- import { createQueue } from "./factory.js";
2
+ import { createQueue, createModuleQueue, resolveQueueStrategy } from "./factory.js";
3
3
  export * from "./worker/registry.js";
4
4
  import { runWorker, createRoutedHandler } from "./worker/runner.js";
5
5
  export {
6
+ createModuleQueue,
6
7
  createQueue,
7
8
  createRoutedHandler,
9
+ resolveQueueStrategy,
8
10
  runWorker
9
11
  };
10
12
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
- "sourcesContent": ["/**\n * @open-mercato/queue\n *\n * Multi-strategy job queue package supporting local (file-based) and async (BullMQ) strategies.\n *\n * @example\n * ```typescript\n * import { createQueue } from '@open-mercato/queue'\n *\n * // Create a local queue\n * const queue = createQueue<{ userId: string }>('my-queue', 'local')\n *\n * // Enqueue a job\n * await queue.enqueue({ userId: '123' })\n *\n * // Process jobs\n * await queue.process(async (job, ctx) => {\n * console.log(`Processing job ${ctx.jobId}:`, job.payload)\n * })\n * ```\n */\n\nexport * from './types'\nexport { createQueue } from './factory'\n\n// Worker utilities\nexport * from './worker/registry'\nexport { runWorker, createRoutedHandler } from './worker/runner'\n"],
5
- "mappings": "AAsBA,cAAc;AACd,SAAS,mBAAmB;AAG5B,cAAc;AACd,SAAS,WAAW,2BAA2B;",
4
+ "sourcesContent": ["/**\n * @open-mercato/queue\n *\n * Multi-strategy job queue package supporting local (file-based) and async (BullMQ) strategies.\n *\n * @example\n * ```typescript\n * import { createQueue } from '@open-mercato/queue'\n *\n * // Create a local queue\n * const queue = createQueue<{ userId: string }>('my-queue', 'local')\n *\n * // Enqueue a job\n * await queue.enqueue({ userId: '123' })\n *\n * // Process jobs\n * await queue.process(async (job, ctx) => {\n * console.log(`Processing job ${ctx.jobId}:`, job.payload)\n * })\n * ```\n */\n\nexport * from './types'\nexport { createQueue, createModuleQueue, resolveQueueStrategy } from './factory'\n\n// Worker utilities\nexport * from './worker/registry'\nexport { runWorker, createRoutedHandler } from './worker/runner'\n"],
5
+ "mappings": "AAsBA,cAAc;AACd,SAAS,aAAa,mBAAmB,4BAA4B;AAGrE,cAAc;AACd,SAAS,WAAW,2BAA2B;",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- import { getRedisUrl } from "@open-mercato/shared/lib/redis/connection";
1
+ import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
2
2
  function resolveConnection(options) {
3
3
  if (options?.url) {
4
4
  return { url: options.url };
@@ -13,7 +13,7 @@ function resolveConnection(options) {
13
13
  tls: options.tls
14
14
  };
15
15
  }
16
- return { url: getRedisUrl("QUEUE") };
16
+ return { url: getRedisUrlOrThrow("QUEUE") };
17
17
  }
18
18
  function createAsyncQueue(name, options) {
19
19
  const connection = resolveConnection(options?.connection);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/async.ts"],
4
- "sourcesContent": ["import type { Queue, QueuedJob, JobHandler, AsyncQueueOptions, ProcessResult, EnqueueOptions } from '../types'\nimport { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'\n\n// BullMQ interface types - we define the shape we use to maintain type safety\n// while keeping bullmq as an optional peer dependency\ntype ConnectionOptions = {\n url?: string\n host?: string\n port?: number\n username?: string\n password?: string\n db?: number\n tls?: Record<string, unknown>\n}\n\ninterface BullQueueInterface<T> {\n add: (\n name: string,\n data: T,\n opts?: {\n removeOnComplete?: boolean\n removeOnFail?: number\n delay?: number\n attempts?: number\n backoff?: { type: string; delay: number }\n },\n ) => Promise<{ id?: string }>\n obliterate: (opts?: { force?: boolean }) => Promise<void>\n close: () => Promise<void>\n getJobCounts: (...states: string[]) => Promise<Record<string, number>>\n}\n\ninterface BullWorkerInterface {\n on: (event: string, handler: (...args: unknown[]) => void) => void\n close: () => Promise<void>\n}\n\ninterface BullMQModule {\n Queue: new <T>(name: string, opts: { connection: ConnectionOptions }) => BullQueueInterface<T>\n Worker: new <T>(\n name: string,\n processor: (job: { id?: string; data: T; attemptsMade: number }) => Promise<void>,\n opts: { connection: ConnectionOptions; concurrency: number }\n ) => BullWorkerInterface\n}\n\n/**\n * Resolves Redis connection options from various sources.\n *\n * BullMQ expects an ioredis-compatible connection object. Preserve the full\n * Redis URL under the `url` key so rediss://, username, database, and query\n * params are not lost in translation.\n */\nfunction resolveConnection(options?: AsyncQueueOptions['connection']): ConnectionOptions {\n if (options?.url) {\n return { url: options.url }\n }\n\n if (options?.host) {\n return {\n host: options.host,\n port: options.port ?? 6379,\n username: options.username,\n password: options.password,\n db: options.db,\n tls: options.tls,\n }\n }\n\n return { url: getRedisUrl('QUEUE') }\n}\n\n/**\n * Creates a BullMQ-based async queue.\n *\n * This strategy provides:\n * - Persistent job storage in Redis\n * - Automatic retries with exponential backoff\n * - Concurrent job processing\n * - Job prioritization and scheduling\n *\n * @template T - The payload type for jobs\n * @param name - Queue name\n * @param options - Async queue options\n */\nexport function createAsyncQueue<T = unknown>(\n name: string,\n options?: AsyncQueueOptions\n): Queue<T> {\n const connection = resolveConnection(options?.connection)\n const concurrency = options?.concurrency ?? 1\n\n let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null\n let bullWorker: BullWorkerInterface | null = null\n let bullmqModule: BullMQModule | null = null\n\n // -------------------------------------------------------------------------\n // Lazy BullMQ initialization\n // -------------------------------------------------------------------------\n\n async function getBullMQ(): Promise<BullMQModule> {\n if (!bullmqModule) {\n try {\n bullmqModule = await import('bullmq') as unknown as BullMQModule\n } catch {\n throw new Error(\n 'BullMQ is required for async queue strategy. Install it with: npm install bullmq'\n )\n }\n }\n return bullmqModule\n }\n\n async function getQueue(): Promise<BullQueueInterface<QueuedJob<T>>> {\n if (!bullQueue) {\n const { Queue: BullQueueClass } = await getBullMQ()\n bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection })\n }\n return bullQueue\n }\n\n // -------------------------------------------------------------------------\n // Queue Implementation\n // -------------------------------------------------------------------------\n\n async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {\n const queue = await getQueue()\n const jobData: QueuedJob<T> = {\n id: crypto.randomUUID(),\n payload: data,\n createdAt: new Date().toISOString(),\n }\n\n const job = await queue.add(jobData.id, jobData, {\n delay: options?.delayMs && options.delayMs > 0 ? options.delayMs : undefined,\n removeOnComplete: true,\n removeOnFail: 1000,\n attempts: 3,\n backoff: { type: 'exponential', delay: 1000 },\n })\n\n return job.id ?? jobData.id\n }\n\n async function process(handler: JobHandler<T>): Promise<ProcessResult> {\n const { Worker } = await getBullMQ()\n\n // Create worker that processes jobs\n bullWorker = new Worker<QueuedJob<T>>(\n name,\n async (job) => {\n const jobData = job.data\n await handler(jobData, {\n jobId: job.id ?? jobData.id,\n attemptNumber: job.attemptsMade + 1,\n queueName: name,\n })\n },\n {\n connection,\n concurrency,\n }\n )\n\n // Set up event handlers\n bullWorker.on('completed', (job) => {\n const jobWithId = job as { id?: string }\n console.log(`[queue:${name}] Job ${jobWithId.id} completed`)\n })\n\n bullWorker.on('failed', (job, err) => {\n const jobWithId = job as { id?: string } | undefined\n const error = err as Error\n console.error(`[queue:${name}] Job ${jobWithId?.id} failed:`, error.message)\n })\n\n bullWorker.on('error', (err) => {\n const error = err as Error\n console.error(`[queue:${name}] Worker error:`, error.message)\n })\n\n console.log(`[queue:${name}] Worker started with concurrency ${concurrency}`)\n\n // For async strategy, return a sentinel result indicating worker mode\n // processed=-1 signals that this is a continuous worker, not a batch process\n return { processed: -1, failed: -1, lastJobId: undefined }\n }\n\n async function clear(): Promise<{ removed: number }> {\n const queue = await getQueue()\n\n // Obliterate removes all jobs from the queue\n await queue.obliterate({ force: true })\n\n return { removed: -1 } // BullMQ obliterate doesn't return count\n }\n\n async function close(): Promise<void> {\n if (bullWorker) {\n await bullWorker.close()\n bullWorker = null\n }\n if (bullQueue) {\n await bullQueue.close()\n bullQueue = null\n }\n }\n\n async function getJobCounts(): Promise<{\n waiting: number\n active: number\n completed: number\n failed: number\n }> {\n const queue = await getQueue()\n const counts = await queue.getJobCounts('waiting', 'active', 'completed', 'failed')\n return {\n waiting: counts.waiting ?? 0,\n active: counts.active ?? 0,\n completed: counts.completed ?? 0,\n failed: counts.failed ?? 0,\n }\n }\n\n return {\n name,\n strategy: 'async',\n enqueue,\n process,\n clear,\n close,\n getJobCounts,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { Queue, QueuedJob, JobHandler, AsyncQueueOptions, ProcessResult, EnqueueOptions } from '../types'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\n\n// BullMQ interface types - we define the shape we use to maintain type safety\n// while keeping bullmq as an optional peer dependency\ntype ConnectionOptions = {\n url?: string\n host?: string\n port?: number\n username?: string\n password?: string\n db?: number\n tls?: Record<string, unknown>\n}\n\ninterface BullQueueInterface<T> {\n add: (\n name: string,\n data: T,\n opts?: {\n removeOnComplete?: boolean\n removeOnFail?: number\n delay?: number\n attempts?: number\n backoff?: { type: string; delay: number }\n },\n ) => Promise<{ id?: string }>\n obliterate: (opts?: { force?: boolean }) => Promise<void>\n close: () => Promise<void>\n getJobCounts: (...states: string[]) => Promise<Record<string, number>>\n}\n\ninterface BullWorkerInterface {\n on: (event: string, handler: (...args: unknown[]) => void) => void\n close: () => Promise<void>\n}\n\ninterface BullMQModule {\n Queue: new <T>(name: string, opts: { connection: ConnectionOptions }) => BullQueueInterface<T>\n Worker: new <T>(\n name: string,\n processor: (job: { id?: string; data: T; attemptsMade: number }) => Promise<void>,\n opts: { connection: ConnectionOptions; concurrency: number }\n ) => BullWorkerInterface\n}\n\n/**\n * Resolves Redis connection options from various sources.\n *\n * BullMQ expects an ioredis-compatible connection object. Preserve the full\n * Redis URL under the `url` key so rediss://, username, database, and query\n * params are not lost in translation.\n */\nfunction resolveConnection(options?: AsyncQueueOptions['connection']): ConnectionOptions {\n if (options?.url) {\n return { url: options.url }\n }\n\n if (options?.host) {\n return {\n host: options.host,\n port: options.port ?? 6379,\n username: options.username,\n password: options.password,\n db: options.db,\n tls: options.tls,\n }\n }\n\n return { url: getRedisUrlOrThrow('QUEUE') }\n}\n\n/**\n * Creates a BullMQ-based async queue.\n *\n * This strategy provides:\n * - Persistent job storage in Redis\n * - Automatic retries with exponential backoff\n * - Concurrent job processing\n * - Job prioritization and scheduling\n *\n * @template T - The payload type for jobs\n * @param name - Queue name\n * @param options - Async queue options\n */\nexport function createAsyncQueue<T = unknown>(\n name: string,\n options?: AsyncQueueOptions\n): Queue<T> {\n const connection = resolveConnection(options?.connection)\n const concurrency = options?.concurrency ?? 1\n\n let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null\n let bullWorker: BullWorkerInterface | null = null\n let bullmqModule: BullMQModule | null = null\n\n // -------------------------------------------------------------------------\n // Lazy BullMQ initialization\n // -------------------------------------------------------------------------\n\n async function getBullMQ(): Promise<BullMQModule> {\n if (!bullmqModule) {\n try {\n bullmqModule = await import('bullmq') as unknown as BullMQModule\n } catch {\n throw new Error(\n 'BullMQ is required for async queue strategy. Install it with: npm install bullmq'\n )\n }\n }\n return bullmqModule\n }\n\n async function getQueue(): Promise<BullQueueInterface<QueuedJob<T>>> {\n if (!bullQueue) {\n const { Queue: BullQueueClass } = await getBullMQ()\n bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection })\n }\n return bullQueue\n }\n\n // -------------------------------------------------------------------------\n // Queue Implementation\n // -------------------------------------------------------------------------\n\n async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {\n const queue = await getQueue()\n const jobData: QueuedJob<T> = {\n id: crypto.randomUUID(),\n payload: data,\n createdAt: new Date().toISOString(),\n }\n\n const job = await queue.add(jobData.id, jobData, {\n delay: options?.delayMs && options.delayMs > 0 ? options.delayMs : undefined,\n removeOnComplete: true,\n removeOnFail: 1000,\n attempts: 3,\n backoff: { type: 'exponential', delay: 1000 },\n })\n\n return job.id ?? jobData.id\n }\n\n async function process(handler: JobHandler<T>): Promise<ProcessResult> {\n const { Worker } = await getBullMQ()\n\n // Create worker that processes jobs\n bullWorker = new Worker<QueuedJob<T>>(\n name,\n async (job) => {\n const jobData = job.data\n await handler(jobData, {\n jobId: job.id ?? jobData.id,\n attemptNumber: job.attemptsMade + 1,\n queueName: name,\n })\n },\n {\n connection,\n concurrency,\n }\n )\n\n // Set up event handlers\n bullWorker.on('completed', (job) => {\n const jobWithId = job as { id?: string }\n console.log(`[queue:${name}] Job ${jobWithId.id} completed`)\n })\n\n bullWorker.on('failed', (job, err) => {\n const jobWithId = job as { id?: string } | undefined\n const error = err as Error\n console.error(`[queue:${name}] Job ${jobWithId?.id} failed:`, error.message)\n })\n\n bullWorker.on('error', (err) => {\n const error = err as Error\n console.error(`[queue:${name}] Worker error:`, error.message)\n })\n\n console.log(`[queue:${name}] Worker started with concurrency ${concurrency}`)\n\n // For async strategy, return a sentinel result indicating worker mode\n // processed=-1 signals that this is a continuous worker, not a batch process\n return { processed: -1, failed: -1, lastJobId: undefined }\n }\n\n async function clear(): Promise<{ removed: number }> {\n const queue = await getQueue()\n\n // Obliterate removes all jobs from the queue\n await queue.obliterate({ force: true })\n\n return { removed: -1 } // BullMQ obliterate doesn't return count\n }\n\n async function close(): Promise<void> {\n if (bullWorker) {\n await bullWorker.close()\n bullWorker = null\n }\n if (bullQueue) {\n await bullQueue.close()\n bullQueue = null\n }\n }\n\n async function getJobCounts(): Promise<{\n waiting: number\n active: number\n completed: number\n failed: number\n }> {\n const queue = await getQueue()\n const counts = await queue.getJobCounts('waiting', 'active', 'completed', 'failed')\n return {\n waiting: counts.waiting ?? 0,\n active: counts.active ?? 0,\n completed: counts.completed ?? 0,\n failed: counts.failed ?? 0,\n }\n }\n\n return {\n name,\n strategy: 'async',\n enqueue,\n process,\n clear,\n close,\n getJobCounts,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["options"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/queue",
3
- "version": "0.4.11-develop.2235.49f509443f",
3
+ "version": "0.4.11-develop.2245.a1059680ca",
4
4
  "description": "Multi-strategy job queue with local and BullMQ support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@open-mercato/shared": "0.4.11-develop.2235.49f509443f"
53
+ "@open-mercato/shared": "0.4.11-develop.2245.a1059680ca"
54
54
  },
55
55
  "stableVersion": "0.4.10"
56
56
  }
@@ -1,5 +1,5 @@
1
1
  import { createQueue } from '../factory'
2
- import { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'
2
+ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
3
3
 
4
4
  const queueCtor = jest.fn()
5
5
  const workerCtor = jest.fn()
@@ -16,7 +16,7 @@ const workerClose = jest.fn(async () => {})
16
16
  const workerOn = jest.fn()
17
17
 
18
18
  jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
19
- getRedisUrl: jest.fn(),
19
+ getRedisUrlOrThrow: jest.fn(),
20
20
  }))
21
21
 
22
22
  jest.mock('bullmq', () => {
@@ -51,11 +51,11 @@ jest.mock('bullmq', () => {
51
51
  })
52
52
 
53
53
  describe('Queue - async strategy', () => {
54
- const getRedisUrlMock = getRedisUrl as jest.MockedFunction<typeof getRedisUrl>
54
+ const getRedisUrlOrThrowMock = getRedisUrlOrThrow as jest.MockedFunction<typeof getRedisUrlOrThrow>
55
55
 
56
56
  beforeEach(() => {
57
57
  jest.clearAllMocks()
58
- getRedisUrlMock.mockReturnValue('rediss://default:secret@example.com:6380/1')
58
+ getRedisUrlOrThrowMock.mockReturnValue('rediss://default:secret@example.com:6380/1')
59
59
  })
60
60
 
61
61
  it('passes the full Redis URL to BullMQ when using env-based async config', async () => {
@@ -0,0 +1,96 @@
1
+ import { resolveQueueStrategy, createModuleQueue } from '../factory'
2
+ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
3
+
4
+ jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
5
+ getRedisUrlOrThrow: jest.fn(),
6
+ }))
7
+
8
+ jest.mock('bullmq', () => {
9
+ class MockQueue<T> {
10
+ constructor(_name: string, _opts: unknown) {}
11
+ add = jest.fn(async () => ({ id: 'bull-job-id' }))
12
+ close = jest.fn(async () => {})
13
+ obliterate = jest.fn(async () => {})
14
+ getJobCounts = jest.fn(async () => ({ waiting: 0, active: 0, completed: 0, failed: 0 }))
15
+ }
16
+
17
+ class MockWorker<T> {
18
+ constructor(_name: string, _processor: unknown, _opts: unknown) {}
19
+ on = jest.fn()
20
+ close = jest.fn(async () => {})
21
+ }
22
+
23
+ return { Queue: MockQueue, Worker: MockWorker }
24
+ })
25
+
26
+ describe('resolveQueueStrategy', () => {
27
+ const originalEnv = process.env.QUEUE_STRATEGY
28
+
29
+ afterEach(() => {
30
+ if (originalEnv === undefined) {
31
+ delete process.env.QUEUE_STRATEGY
32
+ } else {
33
+ process.env.QUEUE_STRATEGY = originalEnv
34
+ }
35
+ })
36
+
37
+ it('returns "local" when QUEUE_STRATEGY is not set', () => {
38
+ delete process.env.QUEUE_STRATEGY
39
+ expect(resolveQueueStrategy()).toBe('local')
40
+ })
41
+
42
+ it('returns "local" when QUEUE_STRATEGY is "local"', () => {
43
+ process.env.QUEUE_STRATEGY = 'local'
44
+ expect(resolveQueueStrategy()).toBe('local')
45
+ })
46
+
47
+ it('returns "async" when QUEUE_STRATEGY is "async"', () => {
48
+ process.env.QUEUE_STRATEGY = 'async'
49
+ expect(resolveQueueStrategy()).toBe('async')
50
+ })
51
+
52
+ it('returns "local" for any unrecognized value', () => {
53
+ process.env.QUEUE_STRATEGY = 'unknown'
54
+ expect(resolveQueueStrategy()).toBe('local')
55
+ })
56
+ })
57
+
58
+ describe('createModuleQueue', () => {
59
+ const originalEnv = process.env.QUEUE_STRATEGY
60
+ const getRedisUrlOrThrowMock = getRedisUrlOrThrow as jest.MockedFunction<typeof getRedisUrlOrThrow>
61
+
62
+ beforeEach(() => {
63
+ jest.clearAllMocks()
64
+ getRedisUrlOrThrowMock.mockReturnValue('redis://localhost:6379')
65
+ })
66
+
67
+ afterEach(() => {
68
+ if (originalEnv === undefined) {
69
+ delete process.env.QUEUE_STRATEGY
70
+ } else {
71
+ process.env.QUEUE_STRATEGY = originalEnv
72
+ }
73
+ })
74
+
75
+ it('creates a local queue when QUEUE_STRATEGY is not set', () => {
76
+ delete process.env.QUEUE_STRATEGY
77
+ const queue = createModuleQueue('test-queue')
78
+ expect(queue.strategy).toBe('local')
79
+ expect(queue.name).toBe('test-queue')
80
+ })
81
+
82
+ it('creates an async queue when QUEUE_STRATEGY is "async"', () => {
83
+ process.env.QUEUE_STRATEGY = 'async'
84
+ const queue = createModuleQueue('test-queue', { concurrency: 5 })
85
+ expect(queue.strategy).toBe('async')
86
+ expect(queue.name).toBe('test-queue')
87
+ expect(getRedisUrlOrThrowMock).toHaveBeenCalledWith('QUEUE')
88
+ })
89
+
90
+ it('passes concurrency to local strategy', () => {
91
+ delete process.env.QUEUE_STRATEGY
92
+ const queue = createModuleQueue('test-queue', { concurrency: 3 })
93
+ expect(queue.strategy).toBe('local')
94
+ expect(queue.name).toBe('test-queue')
95
+ })
96
+ })
package/src/factory.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { Queue, LocalQueueOptions, AsyncQueueOptions } from './types'
1
+ import type { Queue, LocalQueueOptions, AsyncQueueOptions, QueueStrategyType } from './types'
2
2
  import { createLocalQueue } from './strategies/local'
3
3
  import { createAsyncQueue } from './strategies/async'
4
+ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
4
5
 
5
6
  /**
6
7
  * Creates a queue instance with the specified strategy.
@@ -53,3 +54,42 @@ export function createQueue<T = unknown>(
53
54
 
54
55
  return createLocalQueue<T>(name, options as LocalQueueOptions)
55
56
  }
57
+
58
+ /**
59
+ * Resolve the queue strategy from `QUEUE_STRATEGY`. Defaults to `'local'`.
60
+ */
61
+ export function resolveQueueStrategy(): QueueStrategyType {
62
+ return process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local'
63
+ }
64
+
65
+ /**
66
+ * Create a module-owned queue using the strategy declared in `QUEUE_STRATEGY`.
67
+ *
68
+ * - When `QUEUE_STRATEGY=async`, builds a BullMQ queue and resolves the
69
+ * Redis URL via `getRedisUrlOrThrow('QUEUE')` so missing config fails loudly.
70
+ * - Otherwise builds a local file-based queue.
71
+ *
72
+ * Replaces the boilerplate `process.env.QUEUE_STRATEGY === 'async' ? ... : ...`
73
+ * pattern that every module queue helper used to repeat. Concurrency applies
74
+ * to both strategies so the same number means the same thing in dev and prod.
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * export function getDataSyncQueue(name: string) {
79
+ * return createModuleQueue<MyJob>(name, { concurrency: 5 })
80
+ * }
81
+ * ```
82
+ */
83
+ export function createModuleQueue<T = unknown>(
84
+ name: string,
85
+ options?: { concurrency?: number },
86
+ ): Queue<T> {
87
+ const strategy = resolveQueueStrategy()
88
+ if (strategy === 'async') {
89
+ return createAsyncQueue<T>(name, {
90
+ connection: { url: getRedisUrlOrThrow('QUEUE') },
91
+ concurrency: options?.concurrency,
92
+ })
93
+ }
94
+ return createLocalQueue<T>(name, { concurrency: options?.concurrency })
95
+ }
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  export * from './types'
24
- export { createQueue } from './factory'
24
+ export { createQueue, createModuleQueue, resolveQueueStrategy } from './factory'
25
25
 
26
26
  // Worker utilities
27
27
  export * from './worker/registry'
@@ -1,5 +1,5 @@
1
1
  import type { Queue, QueuedJob, JobHandler, AsyncQueueOptions, ProcessResult, EnqueueOptions } from '../types'
2
- import { getRedisUrl } from '@open-mercato/shared/lib/redis/connection'
2
+ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
3
3
 
4
4
  // BullMQ interface types - we define the shape we use to maintain type safety
5
5
  // while keeping bullmq as an optional peer dependency
@@ -67,7 +67,7 @@ function resolveConnection(options?: AsyncQueueOptions['connection']): Connectio
67
67
  }
68
68
  }
69
69
 
70
- return { url: getRedisUrl('QUEUE') }
70
+ return { url: getRedisUrlOrThrow('QUEUE') }
71
71
  }
72
72
 
73
73
  /**