@devindex/api-kit 0.1.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.
@@ -0,0 +1,124 @@
1
+ import fp from 'fastify-plugin';
2
+ import { ERROR_CODE, isDomainError } from '../../errors/index.js';
3
+
4
+ export const STATUS_BY_CODE = Object.freeze({
5
+ [ERROR_CODE.VALIDATION_ERROR]: 422,
6
+ [ERROR_CODE.UNAUTHORIZED]: 401,
7
+ [ERROR_CODE.FORBIDDEN]: 403,
8
+ [ERROR_CODE.NOT_FOUND]: 404,
9
+ [ERROR_CODE.METHOD_NOT_ALLOWED]: 405,
10
+ [ERROR_CODE.CONFLICT]: 409,
11
+ [ERROR_CODE.LIMIT_REACHED]: 402,
12
+ [ERROR_CODE.PAYLOAD_TOO_LARGE]: 413,
13
+ [ERROR_CODE.TOO_MANY_REQUESTS]: 429,
14
+ [ERROR_CODE.UNAVAILABLE]: 503,
15
+ [ERROR_CODE.DOMAIN_ERROR]: 400,
16
+ [ERROR_CODE.INTERNAL_ERROR]: 500,
17
+ });
18
+
19
+ // A Fastify/plugin error carrying only an HTTP status must not be labelled a
20
+ // validation failure: the envelope's `code` follows the status, so a 429 reads
21
+ // TOO_MANY_REQUESTS and a 403 reads FORBIDDEN instead of all collapsing to one.
22
+ const CODE_BY_STATUS = Object.freeze({
23
+ 400: ERROR_CODE.VALIDATION_ERROR,
24
+ 401: ERROR_CODE.UNAUTHORIZED,
25
+ 402: ERROR_CODE.LIMIT_REACHED,
26
+ 403: ERROR_CODE.FORBIDDEN,
27
+ 404: ERROR_CODE.NOT_FOUND,
28
+ 405: ERROR_CODE.METHOD_NOT_ALLOWED,
29
+ 409: ERROR_CODE.CONFLICT,
30
+ 413: ERROR_CODE.PAYLOAD_TOO_LARGE,
31
+ 422: ERROR_CODE.VALIDATION_ERROR,
32
+ 429: ERROR_CODE.TOO_MANY_REQUESTS,
33
+ });
34
+
35
+ // Own-property only: an app code spelled `constructor` or `toString` would
36
+ // otherwise resolve up the prototype chain and hand Fastify a function as status.
37
+ function statusForCode(code) {
38
+ return Object.hasOwn(STATUS_BY_CODE, code) ? STATUS_BY_CODE[code] : 400;
39
+ }
40
+
41
+ function schemaDetails(validation = []) {
42
+ return validation.map((issue) => ({
43
+ path: issue.instancePath || issue.params?.missingProperty || '',
44
+ message: issue.message,
45
+ }));
46
+ }
47
+
48
+ /**
49
+ * Maps an error to its response shape. A domain error's own `status` wins over
50
+ * `STATUS_BY_CODE`, which is what lets an app code carry a status the kit's
51
+ * table has never heard of.
52
+ *
53
+ * @return {{status: number, code: string, message: string, details: Array<object>}|null}
54
+ * Null when the error is unrecognized; treat it as a 500.
55
+ */
56
+ export function classifyError(error) {
57
+ if (isDomainError(error)) {
58
+ return {
59
+ status: error.status ?? statusForCode(error.code),
60
+ code: error.code,
61
+ message: error.message,
62
+ details: error.details ?? [],
63
+ };
64
+ }
65
+
66
+ if (error?.validation) {
67
+ return {
68
+ // Same code as a domain ValidationError, so the same status (STATUS_BY_CODE).
69
+ status: STATUS_BY_CODE[ERROR_CODE.VALIDATION_ERROR],
70
+ code: ERROR_CODE.VALIDATION_ERROR,
71
+ message: 'Request validation failed',
72
+ details: schemaDetails(error.validation),
73
+ };
74
+ }
75
+
76
+ if (Number.isInteger(error?.statusCode) && error.statusCode >= 400 && error.statusCode < 500) {
77
+ return {
78
+ status: error.statusCode,
79
+ code: CODE_BY_STATUS[error.statusCode] ?? ERROR_CODE.DOMAIN_ERROR,
80
+ message: error.message,
81
+ details: [],
82
+ };
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ function envelope(known, requestId) {
89
+ return {
90
+ error: {
91
+ code: known.code,
92
+ message: known.message,
93
+ details: known.details ?? [],
94
+ requestId,
95
+ },
96
+ };
97
+ }
98
+
99
+ export default fp(async function errorHandler(app) {
100
+ app.setErrorHandler((error, req, reply) => {
101
+ const known = classifyError(error);
102
+ if (!known) {
103
+ req.log.error({ err: error }, 'unhandled request error');
104
+ reply.status(500).send(envelope({
105
+ code: ERROR_CODE.INTERNAL_ERROR,
106
+ message: 'Internal server error',
107
+ details: [],
108
+ }, req.id));
109
+ return;
110
+ }
111
+
112
+ const level = known.status >= 500 ? 'error' : 'info';
113
+ req.log[level]({ err: error, code: known.code }, 'request failed');
114
+ reply.status(known.status).send(envelope(known, req.id));
115
+ });
116
+
117
+ app.setNotFoundHandler((req, reply) => {
118
+ reply.status(404).send(envelope({
119
+ code: ERROR_CODE.NOT_FOUND,
120
+ message: `Route ${req.method} ${req.url} not found`,
121
+ details: [],
122
+ }, req.id));
123
+ });
124
+ });
@@ -0,0 +1,23 @@
1
+ import fp from 'fastify-plugin';
2
+
3
+ /** Optional exact-body capture for webhook signature verification. */
4
+ export default fp(async function rawBody(app) {
5
+ if (!app.hasRequestDecorator('rawBody')) app.decorateRequest('rawBody', null);
6
+ app.addContentTypeParser(
7
+ 'application/json',
8
+ { parseAs: 'buffer' },
9
+ (req, body, done) => {
10
+ req.rawBody = body;
11
+ if (body.length === 0) {
12
+ done(null, undefined);
13
+ return;
14
+ }
15
+ try {
16
+ done(null, JSON.parse(body.toString('utf8')));
17
+ } catch (error) {
18
+ error.statusCode = 400;
19
+ done(error);
20
+ }
21
+ },
22
+ );
23
+ });
@@ -0,0 +1,9 @@
1
+ import fp from 'fastify-plugin';
2
+
3
+ /** Makes request/correlation ids available below the HTTP layer via ALS. */
4
+ export default fp(async function requestContext(app, { context } = {}) {
5
+ if (!context) return;
6
+ app.addHook('onRequest', (req, _reply, done) => {
7
+ context.run({ requestId: req.id, correlationId: req.id }, done);
8
+ });
9
+ });
@@ -0,0 +1,21 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fp from 'fastify-plugin';
3
+
4
+ export const REQUEST_ID_HEADER = 'x-request-id';
5
+ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+
7
+ export function isUuid(value) {
8
+ return typeof value === 'string' && UUID_PATTERN.test(value);
9
+ }
10
+
11
+ /** Only a valid UUID is trusted as an inbound correlation id. */
12
+ export function genReqId(req) {
13
+ const inbound = req.headers[REQUEST_ID_HEADER];
14
+ return isUuid(inbound) ? inbound : randomUUID();
15
+ }
16
+
17
+ export default fp(async function requestId(app) {
18
+ app.addHook('onSend', async (req, reply) => {
19
+ reply.header(REQUEST_ID_HEADER, req.id);
20
+ });
21
+ });
package/http/schema.js ADDED
@@ -0,0 +1,16 @@
1
+ export function objectSchema(properties, required = []) {
2
+ return { type: 'object', properties, required };
3
+ }
4
+
5
+ export function pageQuery({ maxLimit = 100, defaultLimit = 20 } = {}) {
6
+ return objectSchema({
7
+ limit: { type: 'integer', minimum: 1, maximum: maxLimit, default: defaultLimit },
8
+ offset: { type: 'integer', minimum: 0, default: 0 },
9
+ });
10
+ }
11
+
12
+ export const stringSchema = (options = {}) => ({ type: 'string', minLength: 1, ...options });
13
+ export const email = Object.freeze({ type: 'string', format: 'email' });
14
+ export const dateTime = Object.freeze({ type: 'string', format: 'date-time' });
15
+ export const dateKey = Object.freeze({ type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' });
16
+ export const clock = Object.freeze({ type: 'string', pattern: '^([01]\\d|2[0-3]):[0-5]\\d$' });
@@ -0,0 +1,53 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export async function loadBullmq() {
4
+ try {
5
+ return await import('bullmq');
6
+ } catch (error) {
7
+ if (['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'].includes(error?.code)) {
8
+ throw new Error(
9
+ 'BullMQ support requires `bullmq` and `ioredis`: install both packages',
10
+ { cause: error },
11
+ );
12
+ }
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export function redisConnection(redisUrl, { worker = false } = {}) {
18
+ if (!redisUrl) throw new Error('a Redis connection requires `redisUrl`');
19
+ let url;
20
+ try {
21
+ url = new URL(redisUrl);
22
+ } catch (error) {
23
+ throw new Error(`invalid redisUrl: ${redisUrl}`, { cause: error });
24
+ }
25
+ if (!['redis:', 'rediss:'].includes(url.protocol)) {
26
+ throw new Error(`redisUrl must use redis:// or rediss://, got ${url.protocol}`);
27
+ }
28
+ const db = url.pathname.length > 1 ? Number(url.pathname.slice(1)) : 0;
29
+ if (!Number.isInteger(db) || db < 0) throw new Error(`invalid Redis database in ${redisUrl}`);
30
+
31
+ return {
32
+ host: url.hostname,
33
+ port: Number(url.port || 6379),
34
+ db,
35
+ ...(url.username ? { username: decodeURIComponent(url.username) } : {}),
36
+ ...(url.password ? { password: decodeURIComponent(url.password) } : {}),
37
+ ...(url.protocol === 'rediss:' ? { tls: {} } : {}),
38
+ // BullMQ workers refuse to start unless this is null; producers fail fast.
39
+ maxRetriesPerRequest: worker ? null : 1,
40
+ };
41
+ }
42
+
43
+ function safeSegment(value) {
44
+ const source = String(value);
45
+ // Slugifying is lossy; the hash suffix keeps the queue name unique.
46
+ const readable = source.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'work';
47
+ const hash = createHash('sha256').update(source).digest('hex').slice(0, 10);
48
+ return `${readable.slice(0, 40)}-${hash}`;
49
+ }
50
+
51
+ export function queueName(prefix, kind, name) {
52
+ return `${safeSegment(prefix)}-${kind}-${safeSegment(name)}`;
53
+ }
@@ -0,0 +1,6 @@
1
+ const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'];
2
+
3
+ export const noopLogger = Object.freeze({
4
+ ...Object.fromEntries(LOG_LEVELS.map((level) => [level, () => {}])),
5
+ child: () => noopLogger,
6
+ });
@@ -0,0 +1,42 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { assertDuration, assertPositiveInt } from './validation.js';
3
+
4
+ export const DEFAULTS = Object.freeze({
5
+ attempts: 3,
6
+ backoff: { type: 'exponential', delay: 1_000 },
7
+ concurrency: 1,
8
+ });
9
+
10
+ export function dedupId(name, key) {
11
+ // NUL separates the fields so ("ab","c") and ("a","bc") can't collide.
12
+ return createHash('sha256').update(`${name}\0${key}`).digest('hex');
13
+ }
14
+
15
+ export function normalizeBackoff(backoff) {
16
+ if (typeof backoff === 'number') return assertDuration('backoff', backoff);
17
+ if (!backoff || !['fixed', 'exponential'].includes(backoff.type)) {
18
+ throw new TypeError('backoff must be milliseconds or `{ type: "fixed"|"exponential", delay }`');
19
+ }
20
+ return Object.freeze({
21
+ type: backoff.type,
22
+ delay: assertDuration('backoff.delay', backoff.delay),
23
+ });
24
+ }
25
+
26
+ // setTimeout clamps anything past ~24.8 days to 1ms, turning a slow backoff into
27
+ // a hot retry loop; keep the delay under that ceiling so large `attempts` stay spaced.
28
+ const MAX_BACKOFF_MS = 2_147_483_647;
29
+
30
+ export function backoffDelay(backoff, attempt) {
31
+ if (typeof backoff === 'number') return backoff;
32
+ if (backoff.type !== 'exponential') return backoff.delay;
33
+ return Math.min(MAX_BACKOFF_MS, backoff.delay * 2 ** (attempt - 1));
34
+ }
35
+
36
+ export function resolveDefinition(defaults, options = {}) {
37
+ return Object.freeze({
38
+ attempts: assertPositiveInt('attempts', options.attempts ?? defaults.attempts),
39
+ backoff: normalizeBackoff(options.backoff ?? defaults.backoff),
40
+ concurrency: assertPositiveInt('concurrency', options.concurrency ?? defaults.concurrency),
41
+ });
42
+ }
@@ -0,0 +1,34 @@
1
+ export function assertName(name, subject = 'job') {
2
+ if (typeof name !== 'string' || name.trim().length === 0) {
3
+ throw new TypeError(`a ${subject} needs a non-empty name`);
4
+ }
5
+ return name.trim();
6
+ }
7
+
8
+ export function assertKey(key) {
9
+ if (typeof key !== 'string' || key.length === 0) {
10
+ throw new TypeError(`a \`key\` must be a non-empty string, got ${typeof key === 'string' ? "''" : typeof key}`);
11
+ }
12
+ return key;
13
+ }
14
+
15
+ export function assertHandler(name, handler) {
16
+ if (typeof handler !== 'function') {
17
+ throw new TypeError(`the handler for "${name}" must be a function`);
18
+ }
19
+ return handler;
20
+ }
21
+
22
+ export function assertPositiveInt(label, value) {
23
+ if (!Number.isInteger(value) || value < 1) {
24
+ throw new TypeError(`${label} must be an integer >= 1, got ${value}`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function assertDuration(label, value) {
30
+ if (!Number.isInteger(value) || value < 0) {
31
+ throw new TypeError(`${label} must be an integer >= 0 milliseconds, got ${value}`);
32
+ }
33
+ return value;
34
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ loadBullmq,
3
+ queueName,
4
+ redisConnection,
5
+ } from '../../internal/bullmq.js';
6
+ import { noopLogger } from '../../internal/logger.js';
7
+
8
+ /**
9
+ * Distributed backend on BullMQ, one queue per job name. Redis is only its store.
10
+ *
11
+ * @param {object} options
12
+ * @param {string} options.redisUrl
13
+ * @param {string} [options.prefix='app']
14
+ * @param {object} [options.logger]
15
+ * @return {object} A backend: add, work, stop and idle.
16
+ */
17
+ export function bullmqBackend({ redisUrl, prefix = 'app', logger = noopLogger } = {}) {
18
+ const queueConnection = redisConnection(redisUrl);
19
+ const workerConnection = redisConnection(redisUrl, { worker: true });
20
+ const queuePromises = new Map();
21
+ const workers = new Set();
22
+
23
+ function getQueue(name) {
24
+ let promise = queuePromises.get(name);
25
+ if (!promise) {
26
+ promise = (async () => {
27
+ const { Queue } = await loadBullmq();
28
+ return new Queue(queueName(prefix, 'job', name), {
29
+ connection: queueConnection,
30
+ defaultJobOptions: {
31
+ removeOnComplete: { age: 24 * 60 * 60, count: 1_000 },
32
+ removeOnFail: { age: 7 * 24 * 60 * 60 },
33
+ },
34
+ });
35
+ })();
36
+ queuePromises.set(name, promise);
37
+ }
38
+ return promise;
39
+ }
40
+
41
+ return {
42
+ async add(name, payload, { key, dedupId, attempts, backoff, delay = 0 }) {
43
+ const queue = await getQueue(name);
44
+ const job = await queue.add(name, { payload, key }, {
45
+ attempts,
46
+ backoff,
47
+ deduplication: { id: dedupId },
48
+ ...(delay > 0 ? { delay } : {}),
49
+ });
50
+ return job.id;
51
+ },
52
+
53
+ async work(name, definition) {
54
+ const { Worker } = await loadBullmq();
55
+ const queue = await getQueue(name);
56
+ const worker = new Worker(queue.name, async (job, _token, signal) => {
57
+ if (job.name !== name) throw new Error(`job "${job.name}" reached the worker for "${name}"`);
58
+ const attempt = job.attemptsMade + 1;
59
+ const totalAttempts = job.opts.attempts ?? 1;
60
+ const log = logger.child({ job: name, key: job.data.key, jobId: job.id });
61
+ try {
62
+ return await definition.handler(job.data.payload, {
63
+ name,
64
+ jobId: job.id,
65
+ key: job.data.key,
66
+ attempt,
67
+ attemptsLeft: totalAttempts - attempt,
68
+ signal,
69
+ log,
70
+ });
71
+ } catch (error) {
72
+ // A retry that BullMQ will re-run is a warning; only the last attempt is
73
+ // an error, so expected transient retries don't trip error-rate alerts.
74
+ const final = attempt >= totalAttempts;
75
+ log[final ? 'error' : 'warn']({ err: error, attempt, final }, 'job attempt failed');
76
+ throw error;
77
+ }
78
+ }, { connection: workerConnection, concurrency: definition.concurrency });
79
+ workers.add(worker);
80
+ worker.on('error', (error) => logger.error({ err: error, job: name }, 'worker error'));
81
+ worker.on('stalled', (jobId) => {
82
+ logger.error({ job: name, jobId }, 'job stalled and will be delivered again');
83
+ });
84
+
85
+ // Surface a worker that cannot reach Redis at start(), not silently later.
86
+ await worker.waitUntilReady();
87
+ },
88
+
89
+ async idle() {
90
+ while (true) {
91
+ const queues = await Promise.all([...queuePromises.values()]);
92
+ const remaining = await Promise.all(
93
+ queues.map((queue) => queue.getJobCountByTypes(
94
+ 'active',
95
+ 'waiting',
96
+ 'delayed',
97
+ 'prioritized',
98
+ 'waiting-children',
99
+ )),
100
+ );
101
+ if (remaining.every((count) => count === 0)) return;
102
+ await new Promise((resolve) => setTimeout(resolve, 50));
103
+ }
104
+ },
105
+
106
+ // BullMQ owns the drain deadline once worker.close() starts, so the queue's
107
+ // timeoutMs is accepted for a uniform signature but not used here.
108
+ async stop() {
109
+ // Workers first: they stop taking jobs and let the running ones finish.
110
+ const workerResults = await Promise.allSettled([...workers].map((worker) => worker.close()));
111
+ const queueResults = await Promise.allSettled([...queuePromises.values()]);
112
+ const closeResults = await Promise.allSettled(
113
+ queueResults.flatMap((result) => (result.status === 'fulfilled' ? [result.value.close()] : [])),
114
+ );
115
+ for (const result of [...workerResults, ...queueResults, ...closeResults]) {
116
+ if (result.status === 'rejected') logger.error({ err: result.reason }, 'job queue close failed');
117
+ }
118
+ workers.clear();
119
+ queuePromises.clear();
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,178 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { noopLogger } from '../../internal/logger.js';
3
+ import { backoffDelay } from '../../internal/retry.js';
4
+
5
+ /**
6
+ * In-process backend for tests and local runs. Honors delay, attempts, backoff,
7
+ * deduplication and concurrency, and keeps nothing once the process exits.
8
+ *
9
+ * @param {object} [options]
10
+ * @param {object} [options.logger]
11
+ * @return {object} A backend: add, work, stop and idle.
12
+ */
13
+ export function memoryBackend({ logger = noopLogger } = {}) {
14
+ const consumers = new Map();
15
+ const lanes = new Map();
16
+ const dedup = new Map();
17
+ const timers = new Set();
18
+ const inFlight = new Set();
19
+ const shutdown = new AbortController();
20
+ let closed = false;
21
+
22
+ /** Get or create the per-name queue: its backlog, head pointer and running count. */
23
+ function lane(name) {
24
+ let entry = lanes.get(name);
25
+ if (!entry) {
26
+ entry = { waiting: [], head: 0, running: 0 };
27
+ lanes.set(name, entry);
28
+ }
29
+ return entry;
30
+ }
31
+
32
+ /** Resolve after `ms`, or early if the queue is stopped, without leaking the timer. */
33
+ function sleep(ms) {
34
+ if (ms === 0) return Promise.resolve();
35
+ return new Promise((resolve) => {
36
+ const done = () => {
37
+ clearTimeout(timer);
38
+ timers.delete(timer);
39
+ shutdown.signal.removeEventListener('abort', done);
40
+ resolve();
41
+ };
42
+ const timer = setTimeout(done, ms);
43
+ timers.add(timer);
44
+ shutdown.signal.addEventListener('abort', done, { once: true });
45
+ });
46
+ }
47
+
48
+ /** Run one job through its handler, retrying with backoff until it succeeds or exhausts attempts. */
49
+ async function run(name, definition, job) {
50
+ const log = logger.child({ job: name, key: job.key, jobId: job.id });
51
+ try {
52
+ for (let attempt = 1; attempt <= definition.attempts; attempt += 1) {
53
+ try {
54
+ await definition.handler(job.payload, {
55
+ name,
56
+ jobId: job.id,
57
+ key: job.key,
58
+ attempt,
59
+ attemptsLeft: definition.attempts - attempt,
60
+ signal: shutdown.signal,
61
+ log,
62
+ });
63
+ return;
64
+ } catch (error) {
65
+ // A retry that will run again is a warning; only the last failure is an
66
+ // error, so expected transient retries don't trip error-rate alerts.
67
+ const final = attempt === definition.attempts || shutdown.signal.aborted;
68
+ log[final ? 'error' : 'warn']({ err: error, attempt, final }, 'job attempt failed');
69
+ if (final) return;
70
+ await sleep(backoffDelay(definition.backoff, attempt));
71
+ if (shutdown.signal.aborted) return;
72
+ }
73
+ }
74
+ } finally {
75
+ if (dedup.get(job.dedupId) === job.id) dedup.delete(job.dedupId);
76
+ }
77
+ }
78
+
79
+ /** Start as many queued jobs for `name` as concurrency allows, re-pumping as each finishes. */
80
+ function pump(name) {
81
+ const definition = consumers.get(name);
82
+ // A producer enqueues names this process never declared; without a local
83
+ // worker there is nothing here to run them.
84
+ if (closed || !definition) return;
85
+ const entry = lane(name);
86
+ while (entry.running < definition.concurrency && entry.head < entry.waiting.length) {
87
+ const job = entry.waiting[entry.head];
88
+ entry.waiting[entry.head] = undefined; // drop the payload reference for GC
89
+ entry.head += 1;
90
+ // `shift()` reindexes the whole backlog (O(n^2) under a burst); a head
91
+ // pointer keeps dequeue O(1), compacted so it can't grow unbounded.
92
+ if (entry.head > 1024 && entry.head * 2 >= entry.waiting.length) {
93
+ entry.waiting = entry.waiting.slice(entry.head);
94
+ entry.head = 0;
95
+ }
96
+ entry.running += 1;
97
+ const promise = run(name, definition, job).finally(() => {
98
+ entry.running -= 1;
99
+ inFlight.delete(promise);
100
+ pump(name);
101
+ });
102
+ inFlight.add(promise);
103
+ }
104
+ }
105
+
106
+ return {
107
+ async add(name, payload, { key, dedupId, delay = 0 }) {
108
+ if (closed) throw new Error('cannot enqueue on a stopped job queue');
109
+ // Dedup is held only while the job is in flight, so the same key is free
110
+ // again once the job settles.
111
+ const held = dedup.get(dedupId);
112
+ if (held) return held;
113
+
114
+ const jobId = randomUUID();
115
+ dedup.set(dedupId, jobId);
116
+ const job = { id: jobId, dedupId, key, payload };
117
+
118
+ if (delay > 0) {
119
+ const timer = setTimeout(() => {
120
+ timers.delete(timer);
121
+ if (closed) return;
122
+ lane(name).waiting.push(job);
123
+ pump(name);
124
+ }, delay);
125
+ timers.add(timer);
126
+ } else {
127
+ lane(name).waiting.push(job);
128
+ pump(name);
129
+ }
130
+ return jobId;
131
+ },
132
+
133
+ async work(name, definition) {
134
+ // Draining here picks up anything enqueued before the worker existed.
135
+ consumers.set(name, definition);
136
+ pump(name);
137
+ },
138
+
139
+ async stop({ timeoutMs = 30_000 } = {}) {
140
+ if (closed) return;
141
+ closed = true;
142
+ shutdown.abort();
143
+ for (const timer of timers) clearTimeout(timer);
144
+ timers.clear();
145
+ for (const entry of lanes.values()) {
146
+ entry.waiting.length = 0;
147
+ entry.head = 0;
148
+ }
149
+
150
+ // A handler that ignores `signal` would otherwise keep the process alive
151
+ // forever, so drain in-flight work under a deadline instead of unbounded.
152
+ const drained = await Promise.race([
153
+ Promise.allSettled([...inFlight]).then(() => true),
154
+ new Promise((resolve) => {
155
+ setTimeout(() => resolve(false), timeoutMs).unref?.();
156
+ }),
157
+ ]);
158
+ if (!drained) {
159
+ logger.warn({ timeoutMs, pending: inFlight.size }, 'job queue stop timed out; abandoning in-flight jobs');
160
+ }
161
+
162
+ lanes.clear();
163
+ dedup.clear();
164
+ inFlight.clear();
165
+ },
166
+
167
+ /** Resolves once nothing is delayed, queued or running. */
168
+ async idle() {
169
+ while (
170
+ timers.size
171
+ || inFlight.size
172
+ || [...lanes.values()].some((entry) => entry.head < entry.waiting.length)
173
+ ) {
174
+ await new Promise((resolve) => setTimeout(resolve, 5));
175
+ }
176
+ },
177
+ };
178
+ }