@fullstackhouse/open-mercato-durable-work 0.1.1 → 0.1.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/transport/bullmq.ts"],
4
- "sourcesContent": ["// The BullMQ adapter: the production default wherever an Open Mercato app already runs Redis.\n//\n// `bullmq` and `ioredis` are optional peers and are imported lazily, so an app that only uses\n// the pg-boss adapter never has to install them.\n//\n// Supports BullMQ 5 and 6. Both carry everything the mechanism needs \u2014 a caller-supplied job\n// id, the three-argument processor (so a real AbortSignal), `moveToDelayed` for a hand-back\n// that spends no attempt, and job schedulers for the tick. The range matches\n// `@open-mercato/queue`'s own peer range deliberately: a host on 5 must be able to install\n// this package, because two BullMQ majors against one Redis is not a thing to arrange by\n// accident.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, RetrySettings } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\ntype BullMQModule = typeof import('bullmq')\ntype BullQueue = InstanceType<BullMQModule['Queue']>\ntype BullJob = InstanceType<BullMQModule['Job']>\n// Structural rather than `InstanceType<Worker>`: the concrete worker type is parameterised by\n// the processor's return type and by a backend generic that differs between BullMQ 5 and 6,\n// and nothing here needs more of it than shutdown.\ntype ClosableWorker = { close(force?: boolean): Promise<void>; on(event: 'error', listener: () => void): unknown }\n\nexport type BullMQTransportOptions = {\n /** An ioredis connection or the options to build one. Passed through untouched. */\n connection: unknown\n prefix?: string\n}\n\nlet cached: BullMQModule | null = null\nasync function bullmq(): Promise<BullMQModule> {\n if (cached) return cached\n try {\n cached = await import('bullmq')\n return cached\n } catch (error) {\n throw new Error(\n 'The bullmq transport requires the optional peer dependencies `bullmq` and `ioredis`. Install them, or use DURABLE_WORK_TRANSPORT=pgboss.',\n { cause: error },\n )\n }\n}\n\nfunction backoffFor(retry: RetrySettings) {\n return retry.backoff.type === 'fixed'\n ? { type: 'fixed' as const, delay: retry.backoff.delayMs }\n : { type: 'exponential' as const, delay: retry.backoff.delayMs }\n}\n\nexport class BullMQTransport implements TransportAdapter {\n readonly name = 'bullmq' as const\n /** BullMQ writes to Redis, so it cannot join a Postgres transaction. Callers that need a\n * job row and its delivery to commit together must enqueue after commit \u2014 and accept the\n * gap the reconciler exists to close. */\n readonly supportsTransactionalEnqueue = false\n\n private readonly queues = new Map<string, BullQueue>()\n private readonly workers: ClosableWorker[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: BullMQTransportOptions) {}\n\n private async queue(name: string): Promise<BullQueue> {\n const existing = this.queues.get(name)\n if (existing) return existing\n const { Queue } = await bullmq()\n const queue = new Queue(name, { connection: this.options.connection as never, prefix: this.options.prefix })\n this.queues.set(name, queue)\n return queue\n }\n\n async enqueue(queueName: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const queue = await this.queue(queueName)\n const jobId = deliveryId(delivery)\n // A caller-supplied job id makes the broker deduplicate a re-enqueue of the same delivery.\n // It is a convenience, not the guarantee: the lease refuses a duplicate regardless, which\n // is the version of this that survives a Redis flush.\n await queue.add('delivery', delivery, {\n jobId,\n delay: opts.delayMs && opts.delayMs > 0 ? opts.delayMs : undefined,\n attempts: opts.retry.attempts,\n backoff: backoffFor(opts.retry),\n removeOnComplete: { age: 3_600, count: 1_000 },\n removeOnFail: { age: 86_400 },\n })\n return { transportJobId: jobId }\n }\n\n async remove(queueName: string, transportJobId: string): Promise<void> {\n const queue = await this.queue(queueName)\n // A job that is currently active cannot be removed; that is fine \u2014 the lease is what stops\n // it, and this is only an optimisation to keep a cancelled job from being delivered.\n await queue.remove(transportJobId).catch(() => undefined)\n }\n\n async getState(queueName: string, transportJobId: string): Promise<DeliveryState> {\n const queue = await this.queue(queueName)\n const job = await queue.getJob(transportJobId)\n if (!job) return 'unknown'\n const state = await job.getState()\n switch (state) {\n case 'waiting':\n case 'waiting-children':\n case 'prioritized':\n return 'waiting'\n case 'delayed':\n return 'delayed'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\n case 'failed':\n return 'failed'\n default:\n return 'unknown'\n }\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n const queue = await this.queue(opts.queue)\n // A job scheduler rather than a self-re-enqueueing job: the schedule lives in Redis, so it\n // survives every worker restarting at once, and one missed tick does not end the loop.\n await queue.upsertJobScheduler(\n opts.id,\n { every: opts.everyMs },\n // Delivery-shaped like every other payload, so a bound handler never has to tell a tick\n // from a delivery \u2014 and no adapter has to inspect a payload to decide.\n { name: 'tick', data: { jobId: opts.id, seq: 0, redrives: 0 } },\n )\n }\n\n async bind(queueName: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const { Worker, DelayedError, UnrecoverableError } = await bullmq()\n\n const worker = new Worker(\n queueName,\n async (job: BullJob, token?: string, signal?: AbortSignal) => {\n const delivery = job.data as Delivery\n let handedBack = false\n\n // BullMQ's signal aborts when the job's lock is lost; ours aborts on shutdown. A slice\n // needs to stop for either reason, so it watches both.\n const combined = new AbortController()\n const relay = () => combined.abort()\n signal?.addEventListener('abort', relay, { once: true })\n this.shutdown.signal.addEventListener('abort', relay, { once: true })\n\n try {\n await handler(delivery, {\n transportJobId: job.id ?? deliveryId(delivery),\n attempt: job.attemptsMade + 1,\n maxAttempts: job.opts.attempts ?? 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n handedBack = true\n // Native hand-back: the job keeps its id and its attempt count, and only its\n // payload moves on. That is what makes yielding free \u2014 a re-enqueue would\n // start a new job, and a retry would spend an attempt.\n await job.updateData(next as unknown as never)\n await job.moveToDelayed(Date.now() + (handBackOpts?.delayMs ?? 0), token)\n },\n })\n } catch (error) {\n // BullMQ retries on any throw, so \"there is nothing left to attempt\" has to be said\n // in its own vocabulary. Without this translation a job that has already reached a\n // terminal state is redelivered for every remaining attempt \u2014 each one refused by\n // the claim, each one a wasted slice and a misleading log line.\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') {\n throw new UnrecoverableError((error as Error).message)\n }\n throw error\n } finally {\n signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n\n // BullMQ requires this to propagate out of the processor for the move to take effect.\n if (handedBack) throw new DelayedError()\n },\n {\n connection: this.options.connection as never,\n prefix: this.options.prefix,\n concurrency: opts.concurrency,\n // Must exceed a whole slice, or BullMQ redelivers work that is still running \u2014 which\n // the lease then refuses, wasting the slice and inflating the stall counter.\n lockDuration: opts.activeTimeoutMs,\n },\n )\n\n // Errors here are the broker's, not a job's; swallowing them would make a broken Redis\n // look like an idle queue.\n worker.on('error', () => undefined)\n this.workers.push(worker as unknown as ClosableWorker)\n return { queue: queueName, close: async (o) => void (await worker.close(o?.timeoutMs === 0)) }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n // Abort first: in-flight slices see the signal and hand back at their next boundary,\n // rather than being cut off wherever they happen to be.\n this.shutdown.abort()\n const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())\n await Promise.race([Promise.allSettled(this.workers.map((w) => w.close())).then(() => undefined), deadline])\n await Promise.allSettled([...this.queues.values()].map((q) => q.close()))\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// The BullMQ adapter: the production default wherever an Open Mercato app already runs Redis.\n//\n// `bullmq` and `ioredis` are optional peers and are imported lazily, so an app that only uses\n// the pg-boss adapter never has to install them.\n//\n// Supports BullMQ 5 and 6. Both carry everything the mechanism needs \u2014 a caller-supplied job\n// id, the three-argument processor (so a real AbortSignal), `moveToDelayed` for a hand-back\n// that spends no attempt, and job schedulers for the tick. The range matches\n// `@open-mercato/queue`'s own peer range deliberately: a host on 5 must be able to install\n// this package, because two BullMQ majors against one Redis is not a thing to arrange by\n// accident.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, RetrySettings } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\n// BullMQ's surface, described structurally rather than imported \u2014 same reason as the pgboss\n// adapter, and the same real failure waiting to happen: `types` resolves to these sources, so a\n// host typechecks this file, and bullmq is an *optional* peer. Only what this adapter calls is\n// described; the runtime import stays a literal so bundlers can see it.\ntype BullJobOptions = {\n jobId?: string\n delay?: number\n attempts?: number\n backoff?: { type: 'fixed' | 'exponential'; delay: number }\n removeOnComplete?: { age?: number; count?: number }\n removeOnFail?: { age?: number; count?: number }\n}\n\ntype BullJob = {\n id?: string | null\n data: unknown\n attemptsMade: number\n opts: { attempts?: number }\n updateData(data: never): Promise<unknown>\n moveToDelayed(timestamp: number, token?: string): Promise<unknown>\n getState(): Promise<string>\n}\n\ntype BullQueue = {\n add(name: string, data: unknown, opts?: BullJobOptions): Promise<unknown>\n remove(jobId: string): Promise<unknown>\n getJob(jobId: string): Promise<BullJob | undefined | null>\n upsertJobScheduler(key: string, repeat: { every: number }, job: { name: string; data: unknown }): Promise<unknown>\n close(): Promise<void>\n}\n\ntype BullMQModule = {\n Queue: new (name: string, opts: { connection: never; prefix?: string }) => BullQueue\n Worker: new (\n name: string,\n processor: (job: BullJob, token?: string, signal?: AbortSignal) => Promise<unknown>,\n opts: { connection: never; prefix?: string; concurrency?: number; lockDuration?: number },\n ) => ClosableWorker & { close(force?: boolean): Promise<void> }\n DelayedError: new (message?: string) => Error\n UnrecoverableError: new (message?: string) => Error\n}\n// Structural rather than `InstanceType<Worker>`: the concrete worker type is parameterised by\n// the processor's return type and by a backend generic that differs between BullMQ 5 and 6,\n// and nothing here needs more of it than shutdown.\ntype ClosableWorker = { close(force?: boolean): Promise<void>; on(event: 'error', listener: () => void): unknown }\n\nexport type BullMQTransportOptions = {\n /** An ioredis connection or the options to build one. Passed through untouched. */\n connection: unknown\n prefix?: string\n}\n\nlet cached: BullMQModule | null = null\nasync function bullmq(): Promise<BullMQModule> {\n if (cached) return cached\n try {\n // `@ts-expect-error` is the wrong tool here: in this repo the dependency IS installed, so\n // there is no error to expect and the build would fail on the assertion itself. The error\n // exists only in a host that never installed this optional peer \u2014 the case being suppressed.\n // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n // @ts-ignore -- optional peer, exactly as in the pgboss adapter. See the note there.\n cached = (await import('bullmq')) as unknown as BullMQModule\n return cached\n } catch (error) {\n throw new Error(\n 'The bullmq transport requires the optional peer dependencies `bullmq` and `ioredis`. Install them, or use DURABLE_WORK_TRANSPORT=pgboss.',\n { cause: error },\n )\n }\n}\n\nfunction backoffFor(retry: RetrySettings) {\n return retry.backoff.type === 'fixed'\n ? { type: 'fixed' as const, delay: retry.backoff.delayMs }\n : { type: 'exponential' as const, delay: retry.backoff.delayMs }\n}\n\nexport class BullMQTransport implements TransportAdapter {\n readonly name = 'bullmq' as const\n /** BullMQ writes to Redis, so it cannot join a Postgres transaction. Callers that need a\n * job row and its delivery to commit together must enqueue after commit \u2014 and accept the\n * gap the reconciler exists to close. */\n readonly supportsTransactionalEnqueue = false\n\n private readonly queues = new Map<string, BullQueue>()\n private readonly workers: ClosableWorker[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: BullMQTransportOptions) {}\n\n private async queue(name: string): Promise<BullQueue> {\n const existing = this.queues.get(name)\n if (existing) return existing\n const { Queue } = await bullmq()\n const queue = new Queue(name, { connection: this.options.connection as never, prefix: this.options.prefix })\n this.queues.set(name, queue)\n return queue\n }\n\n async enqueue(queueName: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const queue = await this.queue(queueName)\n const jobId = deliveryId(delivery)\n // A caller-supplied job id makes the broker deduplicate a re-enqueue of the same delivery.\n // It is a convenience, not the guarantee: the lease refuses a duplicate regardless, which\n // is the version of this that survives a Redis flush.\n await queue.add('delivery', delivery, {\n jobId,\n delay: opts.delayMs && opts.delayMs > 0 ? opts.delayMs : undefined,\n attempts: opts.retry.attempts,\n backoff: backoffFor(opts.retry),\n removeOnComplete: { age: 3_600, count: 1_000 },\n removeOnFail: { age: 86_400 },\n })\n return { transportJobId: jobId }\n }\n\n async remove(queueName: string, transportJobId: string): Promise<void> {\n const queue = await this.queue(queueName)\n // A job that is currently active cannot be removed; that is fine \u2014 the lease is what stops\n // it, and this is only an optimisation to keep a cancelled job from being delivered.\n await queue.remove(transportJobId).catch(() => undefined)\n }\n\n async getState(queueName: string, transportJobId: string): Promise<DeliveryState> {\n const queue = await this.queue(queueName)\n const job = await queue.getJob(transportJobId)\n if (!job) return 'unknown'\n const state = await job.getState()\n switch (state) {\n case 'waiting':\n case 'waiting-children':\n case 'prioritized':\n return 'waiting'\n case 'delayed':\n return 'delayed'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\n case 'failed':\n return 'failed'\n default:\n return 'unknown'\n }\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n const queue = await this.queue(opts.queue)\n // A job scheduler rather than a self-re-enqueueing job: the schedule lives in Redis, so it\n // survives every worker restarting at once, and one missed tick does not end the loop.\n await queue.upsertJobScheduler(\n opts.id,\n { every: opts.everyMs },\n // Delivery-shaped like every other payload, so a bound handler never has to tell a tick\n // from a delivery \u2014 and no adapter has to inspect a payload to decide.\n { name: 'tick', data: { jobId: opts.id, seq: 0, redrives: 0 } },\n )\n }\n\n async bind(queueName: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const { Worker, DelayedError, UnrecoverableError } = await bullmq()\n\n const worker = new Worker(\n queueName,\n async (job: BullJob, token?: string, signal?: AbortSignal) => {\n const delivery = job.data as Delivery\n let handedBack = false\n\n // BullMQ's signal aborts when the job's lock is lost; ours aborts on shutdown. A slice\n // needs to stop for either reason, so it watches both.\n const combined = new AbortController()\n const relay = () => combined.abort()\n signal?.addEventListener('abort', relay, { once: true })\n this.shutdown.signal.addEventListener('abort', relay, { once: true })\n\n try {\n await handler(delivery, {\n transportJobId: job.id ?? deliveryId(delivery),\n attempt: job.attemptsMade + 1,\n maxAttempts: job.opts.attempts ?? 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n handedBack = true\n // Native hand-back: the job keeps its id and its attempt count, and only its\n // payload moves on. That is what makes yielding free \u2014 a re-enqueue would\n // start a new job, and a retry would spend an attempt.\n await job.updateData(next as unknown as never)\n await job.moveToDelayed(Date.now() + (handBackOpts?.delayMs ?? 0), token)\n },\n })\n } catch (error) {\n // BullMQ retries on any throw, so \"there is nothing left to attempt\" has to be said\n // in its own vocabulary. Without this translation a job that has already reached a\n // terminal state is redelivered for every remaining attempt \u2014 each one refused by\n // the claim, each one a wasted slice and a misleading log line.\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') {\n throw new UnrecoverableError((error as Error).message)\n }\n throw error\n } finally {\n signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n\n // BullMQ requires this to propagate out of the processor for the move to take effect.\n if (handedBack) throw new DelayedError()\n },\n {\n connection: this.options.connection as never,\n prefix: this.options.prefix,\n concurrency: opts.concurrency,\n // Must exceed a whole slice, or BullMQ redelivers work that is still running \u2014 which\n // the lease then refuses, wasting the slice and inflating the stall counter.\n lockDuration: opts.activeTimeoutMs,\n },\n )\n\n // Errors here are the broker's, not a job's; swallowing them would make a broken Redis\n // look like an idle queue.\n worker.on('error', () => undefined)\n this.workers.push(worker as unknown as ClosableWorker)\n return { queue: queueName, close: async (o) => void (await worker.close(o?.timeoutMs === 0)) }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n // Abort first: in-flight slices see the signal and hand back at their next boundary,\n // rather than being cut off wherever they happen to be.\n this.shutdown.abort()\n const deadline = new Promise<void>((resolve) => setTimeout(resolve, opts.timeoutMs ?? 30_000).unref?.())\n await Promise.race([Promise.allSettled(this.workers.map((w) => w.close())).then(() => undefined), deadline])\n await Promise.allSettled([...this.queues.values()].map((q) => q.close()))\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/transport/pgboss.ts"],
4
- "sourcesContent": ["// The pg-boss adapter: production without Redis, and the only adapter that can enqueue a\n// delivery inside the caller's transaction.\n//\n// That one capability is why it exists. `send(..., { db })` composes its statements on a\n// client the caller supplies, so a domain row, its job row and its delivery all commit or all\n// roll back. With any other transport there is a window between commit and enqueue where a\n// crash leaves a job nobody will ever deliver \u2014 the reconciler closes it, but closing it after\n// fifteen minutes is not the same as never opening it.\n//\n// pg-boss is a peer dependency and is imported lazily.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, SqlExecutor } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\ntype PgBossModule = typeof import('pg-boss')\n// pg-boss 12 exports the class by name, not as a default.\ntype PgBossInstance = InstanceType<PgBossModule['PgBoss']>\ntype PgBossJob = import('pg-boss').Job<Delivery> & { signal?: AbortSignal }\n\nexport type PgBossTransportOptions = {\n connectionString: string\n /** Keeps pg-boss's own tables out of `public`, so they are obviously not the app's. */\n schema?: string\n /** Reuse an already-started instance instead of owning its lifecycle. */\n instance?: PgBossInstance\n}\n\nlet cached: PgBossModule | null = null\nasync function pgboss(): Promise<PgBossModule> {\n if (cached) return cached\n try {\n cached = await import('pg-boss')\n return cached\n } catch (error) {\n throw new Error(\n 'The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.',\n { cause: error },\n )\n }\n}\n\n/**\n * Adapts a `SqlExecutor` to the shape pg-boss expects from a caller-supplied client.\n *\n * pg-boss only ever calls `executeSql`, so the whole surface is one method. Passing our own\n * executor through \u2014 rather than requiring a raw `pg` client \u2014 is what lets the caller's\n * transaction be a MikroORM one, a node-postgres one, or the harness's, without any of them\n * knowing about the others.\n */\nfunction asDb(tx: SqlExecutor) {\n return {\n async executeSql(text: string, values: unknown[]) {\n const result = await tx.query(text, values)\n return { rows: result.rows as Record<string, unknown>[], rowCount: result.rowCount }\n },\n }\n}\n\n/** A tick, in the same shape as any other delivery. */\nconst tickDelivery = (id: string): Delivery => ({ jobId: id, seq: 0, redrives: 0 })\n\nexport class PgBossTransport implements TransportAdapter {\n readonly name = 'pgboss' as const\n readonly supportsTransactionalEnqueue = true\n\n private boss: PgBossInstance | null = null\n private starting: Promise<PgBossInstance> | null = null\n private readonly ownsInstance: boolean\n private readonly ensuredQueues = new Set<string>()\n private readonly workerIds: Array<{ queue: string; id: string }> = []\n private readonly ticks: NodeJS.Timeout[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: PgBossTransportOptions) {\n this.boss = options.instance ?? null\n this.ownsInstance = !options.instance\n }\n\n private async ready(): Promise<PgBossInstance> {\n if (this.boss) return this.boss\n if (!this.starting) {\n this.starting = (async () => {\n const { PgBoss } = await pgboss()\n const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? 'durable_work_boss' })\n await instance.start()\n this.boss = instance\n return instance\n })()\n }\n return this.starting\n }\n\n /** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`\n * belongs to the queue, not to the worker: it is how long a delivery may stay active before\n * pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets\n * handed to a second worker \u2014 which the lease then refuses, wasting the slice. */\n private async ensureQueue(name: string, expireInSeconds?: number): Promise<PgBossInstance> {\n const boss = await this.ready()\n if (this.ensuredQueues.has(name)) return boss\n await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : undefined)\n this.ensuredQueues.add(name)\n return boss\n }\n\n async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const boss = await this.ensureQueue(queue)\n const key = deliveryId(delivery)\n const sent = await boss.send(queue, delivery as unknown as object, {\n // pg-boss job ids are uuids, so the delivery identity travels as the singleton key \u2014\n // which is also what makes a re-enqueue of the same delivery a no-op.\n singletonKey: key,\n startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1000) : undefined,\n retryLimit: opts.retry.attempts,\n retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1000)),\n // pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.\n ...(opts.retry.backoff.type === 'exponential'\n ? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1000)) }\n : { retryBackoff: false }),\n ...(opts.tx ? { db: asDb(opts.tx) } : {}),\n })\n // `send` returns null when the singleton key collapsed this into an existing job. That is\n // the intended outcome, not a failure: the delivery is already scheduled.\n return { transportJobId: sent ?? key }\n }\n\n async remove(queue: string, transportJobId: string): Promise<void> {\n const boss = await this.ready()\n await boss.deleteJob(queue, transportJobId).catch(() => undefined)\n }\n\n async getState(queue: string, transportJobId: string): Promise<DeliveryState> {\n const boss = await this.ready()\n const job = await boss.getJobById(queue, transportJobId).catch(() => null)\n if (!job) return 'unknown'\n switch (job.state) {\n case 'created':\n case 'retry':\n return 'waiting'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\n case 'cancelled':\n case 'failed':\n return 'failed'\n default:\n return 'unknown'\n }\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n await this.ensureQueue(opts.queue)\n // pg-boss's own scheduler is cron-based, so its finest granularity is a minute \u2014 too\n // coarse for a repair loop. A per-process timer with a singleton key gives the cadence we\n // need and still collapses the fleet's ticks into one job per window. The trade-off is\n // stated rather than hidden: with zero workers up there is no tick, exactly as with a\n // broker-owned schedule that nobody polls.\n const everySeconds = Math.max(1, Math.round(opts.everyMs / 1000))\n const fire = async () => {\n if (this.shutdown.signal.aborted) return\n const boss = await this.ready()\n // Delivery-shaped, like every other payload on every adapter: a tick is a delivery\n // whose handler happens to ignore it, not a second kind of message.\n await boss\n .send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds })\n .catch(() => undefined)\n }\n void fire()\n const timer = setInterval(() => void fire(), opts.everyMs)\n timer.unref?.()\n this.ticks.push(timer)\n }\n\n async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))\n\n const workerId = await boss.work<Delivery>(\n queue,\n { batchSize: opts.concurrency },\n async (jobs: PgBossJob[]) => {\n for (const job of jobs) {\n // Whatever arrived is handed on unexamined. An adapter that inspects payloads\n // decides what counts as a real delivery, and this one used to skip anything\n // without a `jobId` \u2014 which silently swallowed every reconciler tick.\n const delivery = job.data\n\n const combined = new AbortController()\n const relay = () => combined.abort()\n job.signal?.addEventListener('abort', relay, { once: true })\n this.shutdown.signal.addEventListener('abort', relay, { once: true })\n\n try {\n await handler(delivery, {\n transportJobId: job.id,\n // pg-boss does not expose the attempt on the job, so the adapter reports the\n // first attempt and lets its own retry policy carry the rest. The consequence is\n // narrow: the delay written to `next_run_at` is the base rather than a backed-off\n // one, and pg-boss's own `retryBackoff` still spaces the real deliveries.\n attempt: 1,\n maxAttempts: 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n // No native hand-back: send the next delivery and let this one complete. The\n // row is already at `seq + 1`, so the new key cannot collide with this job.\n await this.enqueue(queue, next, {\n delayMs: handBackOpts?.delayMs,\n retry: { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } },\n })\n },\n })\n } catch (error) {\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') continue // settled; no retry wanted\n throw error\n } finally {\n job.signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n }\n },\n )\n\n this.workerIds.push({ queue, id: workerId })\n return {\n queue,\n close: async () => {\n const instance = await this.ready()\n await instance.offWork(queue, { id: workerId }).catch(() => undefined)\n },\n }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n this.shutdown.abort()\n for (const timer of this.ticks) clearInterval(timer)\n this.ticks.length = 0\n if (!this.boss) return\n if (!this.ownsInstance) return\n await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 30_000 }).catch(() => undefined)\n this.boss = null\n this.starting = null\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// The pg-boss adapter: production without Redis, and the only adapter that can enqueue a\n// delivery inside the caller's transaction.\n//\n// That one capability is why it exists. `send(..., { db })` composes its statements on a\n// client the caller supplies, so a domain row, its job row and its delivery all commit or all\n// roll back. With any other transport there is a window between commit and enqueue where a\n// crash leaves a job nobody will ever deliver \u2014 the reconciler closes it, but closing it after\n// fifteen minutes is not the same as never opening it.\n//\n// pg-boss is a peer dependency and is imported lazily.\n\nimport { deliveryId } from '../core/ids'\nimport type { Delivery, SqlExecutor } from '../core/types'\nimport type {\n BindOptions,\n BoundWorker,\n DeliveryHandler,\n DeliveryState,\n EnqueueOptions,\n TransportAdapter,\n} from './types'\n\n// pg-boss's surface, described structurally rather than imported.\n//\n// `typeof import('pg-boss')` is more faithful and is the wrong tool here. This package's\n// `exports` map points its `types` condition at these sources, so a host typechecks this file \u2014\n// and pg-boss is an *optional* peer. A host running the BullMQ transport, which therefore never\n// installs pg-boss, failed its own typecheck on a file it never loads. Found in a real adopter,\n// not in this repo, because here the dependency is always present.\n//\n// Only what this adapter calls is described. pg-boss 12 exports the class by name, not as a\n// default. The runtime import below is unchanged and still a literal, so bundlers can see it.\ntype PgBossSendOptions = {\n singletonKey?: string\n singletonSeconds?: number\n startAfter?: number\n retryLimit?: number\n retryDelay?: number\n retryBackoff?: boolean\n retryDelayMax?: number\n db?: unknown\n}\n\ntype PgBossJob = { id: string; data: Delivery; state?: string; signal?: AbortSignal }\n\ntype PgBossInstance = {\n start(): Promise<unknown>\n stop(options?: { graceful?: boolean; close?: boolean; timeout?: number }): Promise<unknown>\n createQueue(name: string, options?: { expireInSeconds?: number }): Promise<unknown>\n send(name: string, data: object, options?: PgBossSendOptions): Promise<string | null>\n deleteJob(name: string, id: string): Promise<unknown>\n getJobById(name: string, id: string): Promise<{ state?: string } | null>\n work(name: string, options: { batchSize?: number }, handler: (jobs: PgBossJob[]) => Promise<unknown>): Promise<string>\n offWork(name: string, options?: { id?: string }): Promise<unknown>\n}\n\ntype PgBossModule = { PgBoss: new (options: { connectionString: string; schema?: string }) => PgBossInstance }\n\nexport type PgBossTransportOptions = {\n connectionString: string\n /** Keeps pg-boss's own tables out of `public`, so they are obviously not the app's. */\n schema?: string\n /** Reuse an already-started instance instead of owning its lifecycle. */\n instance?: PgBossInstance\n}\n\nlet cached: PgBossModule | null = null\nasync function pgboss(): Promise<PgBossModule> {\n if (cached) return cached\n try {\n // `@ts-expect-error` is the wrong tool here: in this repo the dependency IS installed, so\n // there is no error to expect and the build would fail on the assertion itself. The error\n // exists only in a host that never installed this optional peer \u2014 the case being suppressed.\n // eslint-disable-next-line @typescript-eslint/ban-ts-comment\n // @ts-ignore -- optional peer: absent in a host that runs another transport, and this file\n // must still typecheck there. The structural types above are why nothing else needs it.\n cached = (await import('pg-boss')) as unknown as PgBossModule\n return cached\n } catch (error) {\n throw new Error(\n 'The pgboss transport requires the optional peer dependency `pg-boss`. Install it, or use DURABLE_WORK_TRANSPORT=bullmq.',\n { cause: error },\n )\n }\n}\n\n/**\n * Adapts a `SqlExecutor` to the shape pg-boss expects from a caller-supplied client.\n *\n * pg-boss only ever calls `executeSql`, so the whole surface is one method. Passing our own\n * executor through \u2014 rather than requiring a raw `pg` client \u2014 is what lets the caller's\n * transaction be a MikroORM one, a node-postgres one, or the harness's, without any of them\n * knowing about the others.\n */\nfunction asDb(tx: SqlExecutor) {\n return {\n async executeSql(text: string, values: unknown[]) {\n const result = await tx.query(text, values)\n return { rows: result.rows as Record<string, unknown>[], rowCount: result.rowCount }\n },\n }\n}\n\n/** A tick, in the same shape as any other delivery. */\nconst tickDelivery = (id: string): Delivery => ({ jobId: id, seq: 0, redrives: 0 })\n\nexport class PgBossTransport implements TransportAdapter {\n readonly name = 'pgboss' as const\n readonly supportsTransactionalEnqueue = true\n\n private boss: PgBossInstance | null = null\n private starting: Promise<PgBossInstance> | null = null\n private readonly ownsInstance: boolean\n private readonly ensuredQueues = new Set<string>()\n private readonly workerIds: Array<{ queue: string; id: string }> = []\n private readonly ticks: NodeJS.Timeout[] = []\n private readonly shutdown = new AbortController()\n\n constructor(private readonly options: PgBossTransportOptions) {\n this.boss = options.instance ?? null\n this.ownsInstance = !options.instance\n }\n\n private async ready(): Promise<PgBossInstance> {\n if (this.boss) return this.boss\n if (!this.starting) {\n this.starting = (async () => {\n const { PgBoss } = await pgboss()\n const instance = new PgBoss({ connectionString: this.options.connectionString, schema: this.options.schema ?? 'durable_work_boss' })\n await instance.start()\n this.boss = instance\n return instance\n })()\n }\n return this.starting\n }\n\n /** pg-boss 10+ requires a queue to exist before anything is sent to it. `expireInSeconds`\n * belongs to the queue, not to the worker: it is how long a delivery may stay active before\n * pg-boss reclaims it, so it must exceed a whole slice or work that is still running gets\n * handed to a second worker \u2014 which the lease then refuses, wasting the slice. */\n private async ensureQueue(name: string, expireInSeconds?: number): Promise<PgBossInstance> {\n const boss = await this.ready()\n if (this.ensuredQueues.has(name)) return boss\n await boss.createQueue(name, expireInSeconds ? { expireInSeconds } : undefined)\n this.ensuredQueues.add(name)\n return boss\n }\n\n async enqueue(queue: string, delivery: Delivery, opts: EnqueueOptions): Promise<{ transportJobId: string }> {\n const boss = await this.ensureQueue(queue)\n const key = deliveryId(delivery)\n const sent = await boss.send(queue, delivery as unknown as object, {\n // pg-boss job ids are uuids, so the delivery identity travels as the singleton key \u2014\n // which is also what makes a re-enqueue of the same delivery a no-op.\n singletonKey: key,\n startAfter: opts.delayMs && opts.delayMs > 0 ? Math.ceil(opts.delayMs / 1000) : undefined,\n retryLimit: opts.retry.attempts,\n retryDelay: Math.max(1, Math.round(opts.retry.backoff.delayMs / 1000)),\n // pg-boss rejects a max delay unless backoff is on, so the cap travels only with it.\n ...(opts.retry.backoff.type === 'exponential'\n ? { retryBackoff: true, retryDelayMax: Math.max(1, Math.round(opts.retry.backoff.maxDelayMs / 1000)) }\n : { retryBackoff: false }),\n ...(opts.tx ? { db: asDb(opts.tx) } : {}),\n })\n // `send` returns null when the singleton key collapsed this into an existing job. That is\n // the intended outcome, not a failure: the delivery is already scheduled.\n return { transportJobId: sent ?? key }\n }\n\n async remove(queue: string, transportJobId: string): Promise<void> {\n const boss = await this.ready()\n await boss.deleteJob(queue, transportJobId).catch(() => undefined)\n }\n\n async getState(queue: string, transportJobId: string): Promise<DeliveryState> {\n const boss = await this.ready()\n const job = await boss.getJobById(queue, transportJobId).catch(() => null)\n if (!job) return 'unknown'\n switch (job.state) {\n case 'created':\n case 'retry':\n return 'waiting'\n case 'active':\n return 'active'\n case 'completed':\n return 'completed'\n case 'cancelled':\n case 'failed':\n return 'failed'\n default:\n return 'unknown'\n }\n }\n\n async upsertTick(opts: { id: string; queue: string; everyMs: number }): Promise<void> {\n await this.ensureQueue(opts.queue)\n // pg-boss's own scheduler is cron-based, so its finest granularity is a minute \u2014 too\n // coarse for a repair loop. A per-process timer with a singleton key gives the cadence we\n // need and still collapses the fleet's ticks into one job per window. The trade-off is\n // stated rather than hidden: with zero workers up there is no tick, exactly as with a\n // broker-owned schedule that nobody polls.\n const everySeconds = Math.max(1, Math.round(opts.everyMs / 1000))\n const fire = async () => {\n if (this.shutdown.signal.aborted) return\n const boss = await this.ready()\n // Delivery-shaped, like every other payload on every adapter: a tick is a delivery\n // whose handler happens to ignore it, not a second kind of message.\n await boss\n .send(opts.queue, tickDelivery(opts.id), { singletonKey: opts.id, singletonSeconds: everySeconds })\n .catch(() => undefined)\n }\n void fire()\n const timer = setInterval(() => void fire(), opts.everyMs)\n timer.unref?.()\n this.ticks.push(timer)\n }\n\n async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {\n const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))\n\n const workerId = await boss.work(\n queue,\n { batchSize: opts.concurrency },\n async (jobs: PgBossJob[]) => {\n for (const job of jobs) {\n // Whatever arrived is handed on unexamined. An adapter that inspects payloads\n // decides what counts as a real delivery, and this one used to skip anything\n // without a `jobId` \u2014 which silently swallowed every reconciler tick.\n const delivery = job.data\n\n const combined = new AbortController()\n const relay = () => combined.abort()\n job.signal?.addEventListener('abort', relay, { once: true })\n this.shutdown.signal.addEventListener('abort', relay, { once: true })\n\n try {\n await handler(delivery, {\n transportJobId: job.id,\n // pg-boss does not expose the attempt on the job, so the adapter reports the\n // first attempt and lets its own retry policy carry the rest. The consequence is\n // narrow: the delay written to `next_run_at` is the base rather than a backed-off\n // one, and pg-boss's own `retryBackoff` still spaces the real deliveries.\n attempt: 1,\n maxAttempts: 1,\n signal: combined.signal,\n handBack: async (next, handBackOpts) => {\n // No native hand-back: send the next delivery and let this one complete. The\n // row is already at `seq + 1`, so the new key cannot collide with this job.\n await this.enqueue(queue, next, {\n delayMs: handBackOpts?.delayMs,\n retry: { attempts: 1, backoff: { type: 'fixed', delayMs: 0, maxDelayMs: 0 } },\n })\n },\n })\n } catch (error) {\n if ((error as { name?: string })?.name === 'NoFurtherAttempts') continue // settled; no retry wanted\n throw error\n } finally {\n job.signal?.removeEventListener('abort', relay)\n this.shutdown.signal.removeEventListener('abort', relay)\n }\n }\n },\n )\n\n this.workerIds.push({ queue, id: workerId })\n return {\n queue,\n close: async () => {\n const instance = await this.ready()\n await instance.offWork(queue, { id: workerId }).catch(() => undefined)\n },\n }\n }\n\n async close(opts: { timeoutMs?: number } = {}): Promise<void> {\n this.shutdown.abort()\n for (const timer of this.ticks) clearInterval(timer)\n this.ticks.length = 0\n if (!this.boss) return\n if (!this.ownsInstance) return\n await this.boss.stop({ graceful: true, close: true, timeout: opts.timeoutMs ?? 30_000 }).catch(() => undefined)\n this.boss = null\n this.starting = null\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackhouse/open-mercato-durable-work",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Durable at-least-once background work for Open Mercato apps: a leased job record in Postgres with epoch fencing, bounded resumable slices, a server-side reconciler, fenced cancel and an operator API. Pluggable transport (BullMQ or pg-boss).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,9 +21,47 @@ import type {
21
21
  TransportAdapter,
22
22
  } from './types'
23
23
 
24
- type BullMQModule = typeof import('bullmq')
25
- type BullQueue = InstanceType<BullMQModule['Queue']>
26
- type BullJob = InstanceType<BullMQModule['Job']>
24
+ // BullMQ's surface, described structurally rather than imported — same reason as the pgboss
25
+ // adapter, and the same real failure waiting to happen: `types` resolves to these sources, so a
26
+ // host typechecks this file, and bullmq is an *optional* peer. Only what this adapter calls is
27
+ // described; the runtime import stays a literal so bundlers can see it.
28
+ type BullJobOptions = {
29
+ jobId?: string
30
+ delay?: number
31
+ attempts?: number
32
+ backoff?: { type: 'fixed' | 'exponential'; delay: number }
33
+ removeOnComplete?: { age?: number; count?: number }
34
+ removeOnFail?: { age?: number; count?: number }
35
+ }
36
+
37
+ type BullJob = {
38
+ id?: string | null
39
+ data: unknown
40
+ attemptsMade: number
41
+ opts: { attempts?: number }
42
+ updateData(data: never): Promise<unknown>
43
+ moveToDelayed(timestamp: number, token?: string): Promise<unknown>
44
+ getState(): Promise<string>
45
+ }
46
+
47
+ type BullQueue = {
48
+ add(name: string, data: unknown, opts?: BullJobOptions): Promise<unknown>
49
+ remove(jobId: string): Promise<unknown>
50
+ getJob(jobId: string): Promise<BullJob | undefined | null>
51
+ upsertJobScheduler(key: string, repeat: { every: number }, job: { name: string; data: unknown }): Promise<unknown>
52
+ close(): Promise<void>
53
+ }
54
+
55
+ type BullMQModule = {
56
+ Queue: new (name: string, opts: { connection: never; prefix?: string }) => BullQueue
57
+ Worker: new (
58
+ name: string,
59
+ processor: (job: BullJob, token?: string, signal?: AbortSignal) => Promise<unknown>,
60
+ opts: { connection: never; prefix?: string; concurrency?: number; lockDuration?: number },
61
+ ) => ClosableWorker & { close(force?: boolean): Promise<void> }
62
+ DelayedError: new (message?: string) => Error
63
+ UnrecoverableError: new (message?: string) => Error
64
+ }
27
65
  // Structural rather than `InstanceType<Worker>`: the concrete worker type is parameterised by
28
66
  // the processor's return type and by a backend generic that differs between BullMQ 5 and 6,
29
67
  // and nothing here needs more of it than shutdown.
@@ -39,7 +77,12 @@ let cached: BullMQModule | null = null
39
77
  async function bullmq(): Promise<BullMQModule> {
40
78
  if (cached) return cached
41
79
  try {
42
- cached = await import('bullmq')
80
+ // `@ts-expect-error` is the wrong tool here: in this repo the dependency IS installed, so
81
+ // there is no error to expect and the build would fail on the assertion itself. The error
82
+ // exists only in a host that never installed this optional peer — the case being suppressed.
83
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
84
+ // @ts-ignore -- optional peer, exactly as in the pgboss adapter. See the note there.
85
+ cached = (await import('bullmq')) as unknown as BullMQModule
43
86
  return cached
44
87
  } catch (error) {
45
88
  throw new Error(
@@ -0,0 +1,85 @@
1
+ // The transports describe bullmq and pg-boss structurally instead of importing their types,
2
+ // because both are *optional* peers and this package's `exports` map resolves `types` to these
3
+ // sources — so a host typechecks a transport it never installed. A real adopter hit exactly
4
+ // that: `Cannot find module 'pg-boss'` in an app that runs BullMQ.
5
+ //
6
+ // The cost is that nothing then checks those hand-written types against the real libraries, and
7
+ // they can drift silently. This file is that check, and it lives in a test because tests are
8
+ // unreachable from a host's program — nothing imports them, so a host never typechecks this
9
+ // file and never needs either library installed.
10
+ //
11
+ // It asserts the calls the adapters make, not whole-type assignability. Assignability fails on
12
+ // overloads and on option types being narrower than ours, neither of which says anything about
13
+ // whether the adapter works; a call that stops compiling always does.
14
+ //
15
+ // There is nothing to run. If this file compiles, the dependencies still support the adapters.
16
+
17
+ import { describe, expect, it } from 'vitest'
18
+
19
+ type RealPgBoss = InstanceType<typeof import('pg-boss').PgBoss>
20
+ type RealQueue = InstanceType<typeof import('bullmq').Queue>
21
+ type RealWorkerCtor = typeof import('bullmq').Worker
22
+
23
+ /** Every pg-boss call `PgBossTransport` makes. */
24
+ async function _pgbossCalls(boss: RealPgBoss, tx: never) {
25
+ await boss.start()
26
+ await boss.createQueue('q', { expireInSeconds: 60 })
27
+ const sent: string | null = await boss.send('q', {} as object, {
28
+ singletonKey: 'k',
29
+ singletonSeconds: 5,
30
+ startAfter: 1,
31
+ retryLimit: 3,
32
+ retryDelay: 1,
33
+ retryBackoff: true,
34
+ retryDelayMax: 300,
35
+ db: tx,
36
+ })
37
+ await boss.deleteJob('q', sent ?? 'id')
38
+ const job = await boss.getJobById('q', 'id')
39
+ void (job?.state satisfies string | undefined)
40
+ const workerId = await boss.work('q', { batchSize: 2 }, async (jobs) => {
41
+ const first = jobs[0]
42
+ void (first.id satisfies string)
43
+ void first.data
44
+ })
45
+ await boss.offWork('q', { id: workerId })
46
+ await boss.stop({ graceful: true, close: true, timeout: 30_000 })
47
+ }
48
+
49
+ /** Every bullmq call `BullMQTransport` makes. */
50
+ async function _bullmqCalls(queue: RealQueue, Worker: RealWorkerCtor) {
51
+ await queue.add('delivery', {}, {
52
+ jobId: 'id',
53
+ delay: 1,
54
+ attempts: 3,
55
+ backoff: { type: 'exponential', delay: 5_000 },
56
+ removeOnComplete: { age: 3_600, count: 1_000 },
57
+ removeOnFail: { age: 86_400 },
58
+ })
59
+ await queue.remove('id')
60
+ const job = await queue.getJob('id')
61
+ if (job) {
62
+ void (job.attemptsMade satisfies number)
63
+ void (job.opts.attempts satisfies number | undefined)
64
+ void (job.id satisfies string | undefined)
65
+ await job.updateData({} as never)
66
+ await job.moveToDelayed(Date.now(), 'token')
67
+ void ((await job.getState()) satisfies string)
68
+ }
69
+ await queue.upsertJobScheduler('tick', { every: 1_000 }, { name: 'tick', data: {} })
70
+ await queue.close()
71
+ const worker = new Worker('q', async () => undefined, {
72
+ connection: {} as never,
73
+ prefix: 'p',
74
+ concurrency: 1,
75
+ lockDuration: 60_000,
76
+ })
77
+ worker.on('error', () => undefined)
78
+ await worker.close(true)
79
+ }
80
+
81
+ describe('the optional peers still support the calls the transports make', () => {
82
+ it('compiles, which is the whole assertion', () => {
83
+ expect([_pgbossCalls, _bullmqCalls].every((fn) => typeof fn === 'function')).toBe(true)
84
+ })
85
+ })
@@ -20,10 +20,41 @@ import type {
20
20
  TransportAdapter,
21
21
  } from './types'
22
22
 
23
- type PgBossModule = typeof import('pg-boss')
24
- // pg-boss 12 exports the class by name, not as a default.
25
- type PgBossInstance = InstanceType<PgBossModule['PgBoss']>
26
- type PgBossJob = import('pg-boss').Job<Delivery> & { signal?: AbortSignal }
23
+ // pg-boss's surface, described structurally rather than imported.
24
+ //
25
+ // `typeof import('pg-boss')` is more faithful and is the wrong tool here. This package's
26
+ // `exports` map points its `types` condition at these sources, so a host typechecks this file —
27
+ // and pg-boss is an *optional* peer. A host running the BullMQ transport, which therefore never
28
+ // installs pg-boss, failed its own typecheck on a file it never loads. Found in a real adopter,
29
+ // not in this repo, because here the dependency is always present.
30
+ //
31
+ // Only what this adapter calls is described. pg-boss 12 exports the class by name, not as a
32
+ // default. The runtime import below is unchanged and still a literal, so bundlers can see it.
33
+ type PgBossSendOptions = {
34
+ singletonKey?: string
35
+ singletonSeconds?: number
36
+ startAfter?: number
37
+ retryLimit?: number
38
+ retryDelay?: number
39
+ retryBackoff?: boolean
40
+ retryDelayMax?: number
41
+ db?: unknown
42
+ }
43
+
44
+ type PgBossJob = { id: string; data: Delivery; state?: string; signal?: AbortSignal }
45
+
46
+ type PgBossInstance = {
47
+ start(): Promise<unknown>
48
+ stop(options?: { graceful?: boolean; close?: boolean; timeout?: number }): Promise<unknown>
49
+ createQueue(name: string, options?: { expireInSeconds?: number }): Promise<unknown>
50
+ send(name: string, data: object, options?: PgBossSendOptions): Promise<string | null>
51
+ deleteJob(name: string, id: string): Promise<unknown>
52
+ getJobById(name: string, id: string): Promise<{ state?: string } | null>
53
+ work(name: string, options: { batchSize?: number }, handler: (jobs: PgBossJob[]) => Promise<unknown>): Promise<string>
54
+ offWork(name: string, options?: { id?: string }): Promise<unknown>
55
+ }
56
+
57
+ type PgBossModule = { PgBoss: new (options: { connectionString: string; schema?: string }) => PgBossInstance }
27
58
 
28
59
  export type PgBossTransportOptions = {
29
60
  connectionString: string
@@ -37,7 +68,13 @@ let cached: PgBossModule | null = null
37
68
  async function pgboss(): Promise<PgBossModule> {
38
69
  if (cached) return cached
39
70
  try {
40
- cached = await import('pg-boss')
71
+ // `@ts-expect-error` is the wrong tool here: in this repo the dependency IS installed, so
72
+ // there is no error to expect and the build would fail on the assertion itself. The error
73
+ // exists only in a host that never installed this optional peer — the case being suppressed.
74
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
75
+ // @ts-ignore -- optional peer: absent in a host that runs another transport, and this file
76
+ // must still typecheck there. The structural types above are why nothing else needs it.
77
+ cached = (await import('pg-boss')) as unknown as PgBossModule
41
78
  return cached
42
79
  } catch (error) {
43
80
  throw new Error(
@@ -182,7 +219,7 @@ export class PgBossTransport implements TransportAdapter {
182
219
  async bind(queue: string, handler: DeliveryHandler, opts: BindOptions): Promise<BoundWorker> {
183
220
  const boss = await this.ensureQueue(queue, Math.ceil(opts.activeTimeoutMs / 1000))
184
221
 
185
- const workerId = await boss.work<Delivery>(
222
+ const workerId = await boss.work(
186
223
  queue,
187
224
  { batchSize: opts.concurrency },
188
225
  async (jobs: PgBossJob[]) => {