create-nexwire 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/express/gitignore-stub +31 -24
  2. package/express/package.json +3 -3
  3. package/express/src/database/migrations/sqlite/0000_init.sql +60 -60
  4. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  5. package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
  6. package/express/src/database/schema.ts +3 -3
  7. package/express/src/framework/maker-cli/db/index.mjs +1 -1
  8. package/express/src/framework/maker-cli/deploy/core.mjs +0 -1
  9. package/express/src/framework/maker-cli/index.mjs +13 -3
  10. package/express/src/framework/maker-cli/module/core.mjs +121 -43
  11. package/express/src/framework/maker-cli/runtime/core.mjs +26 -7
  12. package/express/src/framework/maker-cli/runtime/index.mjs +120 -104
  13. package/express/src/framework/maker-cli/stubs/controller/openapi.ts.stub +6 -6
  14. package/express/src/framework/maker-cli/stubs/controller/plain.ts.stub +6 -6
  15. package/express/src/framework/maker-cli/stubs/helper/name.ts.stub +1 -1
  16. package/express/src/framework/maker-cli/stubs/seeder/name.ts.stub +17 -19
  17. package/express/src/framework/maker-cli/stubs/seeder/standalone.ts.stub +22 -0
  18. package/express/src/framework/maker-cli/stubs/service/name.plain.ts.stub +2 -2
  19. package/express/src/framework/maker-cli/stubs/service/name.ts.stub +1 -1
  20. package/express/src/framework/maker-cli/utils/help.mjs +374 -50
  21. package/express/src/framework/maker-cli/utils/naming.mjs +83 -20
  22. package/express/src/framework/maker-cli/utils/project-commands.mjs +140 -0
  23. package/express/src/framework/queue/queue.ts +402 -342
  24. package/express/src/modules/auth/database/seeders/role.ts +18 -18
  25. package/express/src/modules/auth/database/seeders/user.ts +35 -35
  26. package/express/src/storage/database/nexwire.sqlite +0 -0
  27. package/express/vitest.config.ts +38 -23
  28. package/hono/gitignore-stub +31 -24
  29. package/hono/package.json +3 -3
  30. package/hono/src/database/migrations/sqlite/0000_init.sql +60 -60
  31. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
  32. package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
  33. package/hono/src/database/schema.ts +3 -3
  34. package/hono/src/framework/database/paginate.ts +253 -253
  35. package/hono/src/framework/maker-cli/db/index.mjs +1 -1
  36. package/hono/src/framework/maker-cli/deploy/core.mjs +0 -1
  37. package/hono/src/framework/maker-cli/index.mjs +13 -3
  38. package/hono/src/framework/maker-cli/module/core.mjs +116 -38
  39. package/hono/src/framework/maker-cli/runtime/core.mjs +24 -6
  40. package/hono/src/framework/maker-cli/runtime/index.mjs +120 -104
  41. package/hono/src/framework/maker-cli/stubs/controller/openapi.ts.stub +6 -6
  42. package/hono/src/framework/maker-cli/stubs/controller/plain.ts.stub +6 -6
  43. package/hono/src/framework/maker-cli/stubs/helper/name.ts.stub +1 -1
  44. package/hono/src/framework/maker-cli/stubs/seeder/name.ts.stub +17 -19
  45. package/hono/src/framework/maker-cli/stubs/seeder/standalone.ts.stub +22 -0
  46. package/hono/src/framework/maker-cli/stubs/service/name.plain.ts.stub +2 -2
  47. package/hono/src/framework/maker-cli/stubs/service/name.ts.stub +1 -1
  48. package/hono/src/framework/maker-cli/utils/help.mjs +374 -50
  49. package/hono/src/framework/maker-cli/utils/naming.mjs +83 -20
  50. package/hono/src/framework/maker-cli/utils/project-commands.mjs +140 -0
  51. package/hono/src/framework/queue/queue.ts +402 -342
  52. package/hono/src/modules/auth/database/seeders/role.ts +18 -18
  53. package/hono/src/modules/auth/database/seeders/user.ts +35 -35
  54. package/hono/src/storage/database/nexwire.sqlite +0 -0
  55. package/hono/vitest.config.ts +38 -23
  56. package/package.json +1 -1
  57. package/express/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +0 -73
  58. package/hono/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +0 -69
@@ -1,342 +1,402 @@
1
- import { type Job, type JobsOptions, Queue, QueueEvents, Worker } from "bullmq";
2
- import { type DurableContext, type DurableJob, DurableWorker, RedisStateStore } from "bullmq-durable";
3
- import { queueConfig } from "@/config/index.js";
4
- import { discoverModuleFiles, importFile } from "@/framework/modules/discover.js";
5
- import { redisClientIfReady } from "@/framework/redis/client.js";
6
-
7
- type QueueJob = {
8
- name: string;
9
- data: any;
10
- };
11
-
12
- type QueueHandler = (job: Job) => Promise<any>;
13
-
14
- type DurableQueueHandler = (job: DurableJob, ctx: DurableContext) => Promise<any>;
15
-
16
- type AnyQueueHandler = QueueHandler | DurableQueueHandler;
17
-
18
- const queues = new Map<string, Queue>();
19
- const events = new Map<string, QueueEvents>();
20
- const handlers = new Map<string, QueueHandler>();
21
- const durableHandlers = new Map<string, DurableQueueHandler>();
22
- const workers: Worker[] = [];
23
- let durableStateStore: RedisStateStore | null = null;
24
-
25
- function queuePrefix() {
26
- return queueConfig.prefix;
27
- }
28
-
29
- function key(queue: string, job: string) {
30
- return `${queue}:${job}`;
31
- }
32
-
33
- /**
34
- * Why: Registers a job handler for a queue + job name.
35
- * When: Module job files are loaded during boot.
36
- * Where: Module job definition files under `src/modules/<module>/jobs`.
37
- * How: Stores handler in an in-memory lookup key. Pass `{ durable: true }` to
38
- * register a checkpointed handler `(job, ctx)` powered by bullmq-durable;
39
- * such a handler can split its work into `ctx.step(...)` checkpoints that
40
- * survive crashes/restarts instead of re-running from scratch.
41
- */
42
- export function shouldQueue(job: string, queue: string, handler: AnyQueueHandler, options: { durable?: boolean } = {}) {
43
- const target = key(queue || "default", job);
44
- if (options.durable) {
45
- durableHandlers.set(target, handler as DurableQueueHandler);
46
- } else {
47
- handlers.set(target, handler as QueueHandler);
48
- }
49
- }
50
-
51
- /**
52
- * Why: Gets or lazily creates a BullMQ queue instance.
53
- * When: Enqueueing jobs or ensuring queue resources.
54
- * Where: Queue facade and dispatcher.
55
- * How: Reuses shared Redis connection and memoized queue map.
56
- */
57
- export function getQueue(queue = "default") {
58
- const client = redisClientIfReady();
59
- if (!client) return null;
60
-
61
- if (!queues.has(queue)) {
62
- queues.set(
63
- queue,
64
- new Queue(queue, {
65
- connection: client as any,
66
- prefix: queuePrefix(),
67
- defaultJobOptions: {
68
- attempts: 3,
69
- backoff: { type: "exponential", delay: 3000 },
70
- removeOnComplete: 1000,
71
- removeOnFail: 5000
72
- }
73
- })
74
- );
75
- }
76
-
77
- return queues.get(queue)!;
78
- }
79
-
80
- /**
81
- * Why: Provides the shared Redis-backed state store for durable queues.
82
- * When: Durable queue/worker construction.
83
- * Where: Queue facade internals.
84
- * How: Creates one `RedisStateStore` reusing the shared Redis client so durable
85
- * state lives in Redis without opening extra connections.
86
- */
87
- function durableStateStoreIfReady() {
88
- const client = redisClientIfReady();
89
- if (!client) return null;
90
-
91
- if (!durableStateStore) {
92
- durableStateStore = new RedisStateStore({ connection: client as never, prefix: queueConfig.durablePrefix });
93
- }
94
- return durableStateStore;
95
- }
96
-
97
- /**
98
- * Why: Facade-friendly alias for getQueue.
99
- * When: Consumers want fluent queue access.
100
- * Where: Framework facade/API usage.
101
- * How: Delegates directly to getQueue.
102
- */
103
- export function queue(queueName = "default") {
104
- return getQueue(queueName);
105
- }
106
-
107
- /**
108
- * Why: Ensures configured queues exist before UI/runtime usage.
109
- * When: Queue dashboard setup or startup warmup.
110
- * Where: Queue UI/bootstrap code.
111
- * How: Iterates names and resolves queue instances.
112
- */
113
- export function ensureQueues(queueNames: string[]) {
114
- for (const queueName of queueNames) {
115
- getQueue(queueName);
116
- }
117
- }
118
-
119
- /**
120
- * Why: Returns all created queue instances.
121
- * When: Queue introspection and dashboard binding.
122
- * Where: Queue dashboard setup code.
123
- * How: Converts internal queue map to array.
124
- */
125
- export function getAllQueues() {
126
- return Array.from(queues.values());
127
- }
128
-
129
- /**
130
- * Why: Adds a job to BullMQ with project defaults.
131
- * When: Commands/events need background execution.
132
- * Where: Dispatcher and feature modules.
133
- * How: Resolves queue then calls add with retry/backoff options.
134
- */
135
- export async function queueJob(
136
- job: string,
137
- data: any,
138
- options: {
139
- queue?: string;
140
- delay?: number;
141
- attempts?: number;
142
- jobId?: string;
143
- priority?: number;
144
- removeOnComplete?: JobsOptions["removeOnComplete"];
145
- removeOnFail?: JobsOptions["removeOnFail"];
146
- backoff?: JobsOptions["backoff"];
147
- } = {}
148
- ) {
149
- const queueName = options.queue || "default";
150
- const queue = getQueue(queueName);
151
- if (!queue) return null;
152
-
153
- return await queue.add(job, data, {
154
- delay: options.delay ? options.delay * 1000 : 0,
155
- attempts: options.attempts ?? 3,
156
- jobId: options.jobId,
157
- priority: options.priority,
158
- removeOnComplete: options.removeOnComplete ?? 1000,
159
- removeOnFail: options.removeOnFail ?? 5000,
160
- backoff: options.backoff ?? { type: "exponential", delay: 3000 }
161
- });
162
- }
163
-
164
- /**
165
- * Why: Loads module job files so handlers are registered.
166
- * When: Worker start and explicit queue boot flows.
167
- * Where: Worker/runtime bootstrap.
168
- * How: Discovers jobs glob and imports each file.
169
- */
170
- export async function bootQueueJobs() {
171
- const files = await discoverModuleFiles("**/jobs/*.{ts,js}");
172
- for (const file of files) await importFile(file);
173
- return files.length;
174
- }
175
-
176
- /**
177
- * Why: Deletes phantom queues whose names are neither configured nor worked by
178
- * this process.
179
- * When: Queue worker boot, after workers start.
180
- * Where: Worker runtime bootstrap.
181
- * How: Scans Redis for `{prefix}*:meta` keys, and for each queue name that is
182
- * not in queueConfig.queues nor in the queues this worker was launched
183
- * with (--queue), obliterates it only when it holds no live jobs
184
- * (waiting/active/delayed/paused/prioritized/waiting-children). Historical
185
- * completed/failed entries are ignored so leftover history cannot keep a
186
- * dead queue alive. Recreates nothing permanent: BullMQ re-creates a
187
- * queue's keys on demand if any code path still dispatches to it.
188
- */
189
- export async function pruneStaleQueues(protectedQueues: string[] = []) {
190
- if (!queueConfig.autoPruneQueues) return;
191
-
192
- const client = redisClientIfReady();
193
- if (!client) return;
194
-
195
- const configured = new Set([...queueConfig.queues, ...protectedQueues]);
196
- const prefix = `${queuePrefix()}:`;
197
- const candidates = new Set<string>();
198
-
199
- try {
200
- let cursor = "0";
201
- do {
202
- const [nextCursor, keys] = await client.scan(cursor, "MATCH", `${prefix}*:meta`, "COUNT", 100);
203
- cursor = nextCursor;
204
- for (const key of keys) {
205
- const name = key.slice(prefix.length, -5);
206
- if (name && !configured.has(name)) candidates.add(name);
207
- }
208
- } while (cursor !== "0");
209
-
210
- for (const name of candidates) {
211
- try {
212
- const queue = getQueue(name);
213
- if (!queue) continue;
214
-
215
- const counts = await queue.getJobCounts("waiting", "active", "delayed", "paused", "prioritized", "waiting-children");
216
- const live = Object.values(counts).reduce((sum, n) => sum + (Number(n) || 0), 0);
217
- if (live !== 0) continue;
218
-
219
- await queue.obliterate({ force: true });
220
- console.log(`Pruned stale queue "${name}"`);
221
- } catch (error: any) {
222
- console.warn(`Could not prune stale queue "${name}": ${error?.message || error}`);
223
- }
224
- }
225
- } catch (error: any) {
226
- console.warn(`Could not scan queues for stale cleanup: ${error?.message || error}`);
227
- }
228
- }
229
-
230
- /**
231
- * Why: Starts BullMQ workers for selected queues.
232
- * When: Queue worker process boots.
233
- * Where: Worker runtime entrypoint.
234
- * How: Boots handlers, creates QueueEvents, and creates workers.
235
- */
236
- export async function startQueueWorker(queueNames = ["default"]) {
237
- await bootQueueJobs();
238
- const client = redisClientIfReady();
239
- if (!client) throw new Error("Redis is required for queue workers");
240
-
241
- for (const queueName of queueNames) {
242
- if (!events.has(queueName)) {
243
- events.set(queueName, new QueueEvents(queueName, { connection: client as any, prefix: queuePrefix() }));
244
- }
245
-
246
- const durable = [...durableHandlers.keys()].some((k) => k.startsWith(`${queueName}:`));
247
-
248
- if (durable) {
249
- const store = durableStateStoreIfReady();
250
- if (!store) throw new Error("Redis is required for durable queue workers");
251
-
252
- const map: Record<string, AnyQueueHandler> = {};
253
- for (const [k, h] of handlers) {
254
- if (k.startsWith(`${queueName}:`)) map[k.slice(queueName.length + 1)] = h;
255
- }
256
- for (const [k, h] of durableHandlers) {
257
- if (k.startsWith(`${queueName}:`)) map[k.slice(queueName.length + 1)] = h;
258
- }
259
-
260
- const worker = new DurableWorker(queueName, map as never, {
261
- connection: client as any,
262
- prefix: queuePrefix(),
263
- stateStore: store,
264
- concurrency: 10
265
- });
266
-
267
- worker.on("completed", (job) => {
268
- console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
269
- });
270
-
271
- worker.on("failed", (job, error) => {
272
- const jobName = job?.name ?? "unknown";
273
- console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
274
- });
275
-
276
- workers.push(worker as unknown as Worker);
277
- continue;
278
- }
279
-
280
- const worker = new Worker(
281
- queueName,
282
- async (job) => {
283
- console.log(`[${new Date().toLocaleTimeString()}] Processing: ${job.name} (${queueName})`);
284
- const handler = handlers.get(key(queueName, job.name));
285
- if (!handler) throw new Error(`No handler registered for ${queueName}:${job.name}`);
286
- return await handler(job);
287
- },
288
- { connection: client as any, prefix: queuePrefix(), concurrency: 10 }
289
- );
290
-
291
- worker.on("completed", (job) => {
292
- console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
293
- });
294
-
295
- worker.on("failed", (job, error) => {
296
- const jobName = job?.name ?? "unknown";
297
- console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
298
- });
299
-
300
- workers.push(worker);
301
- }
302
- }
303
-
304
- /**
305
- * Why: Hard-clears ALL queue-system state for reset workflows.
306
- * When: Local cleanup and maintenance commands.
307
- * Where: `queue:clear` command.
308
- * How: Wipes every BullMQ queue key (including jobs, job schedulers, and
309
- * repeat keys for queues not listed in `queues`, e.g. `demo`), plus the
310
- * durable key space, by scanning each prefix and deleting in batches.
311
- * Cache/session/rate-limit keys are intentionally left alone.
312
- */
313
- export async function clearQueue() {
314
- const client = redisClientIfReady();
315
- if (!client) return;
316
-
317
- for (const prefix of [queuePrefix(), queueConfig.durablePrefix]) {
318
- const stream = client.scanStream({ match: `${prefix}*`, count: 100 });
319
- for await (const keys of stream) {
320
- if (keys.length) await client.del(keys);
321
- }
322
- }
323
- }
324
-
325
- /**
326
- * Why: Gracefully stops all workers/events/queues.
327
- * When: Process shutdown.
328
- * Where: Server/worker lifecycle hooks.
329
- * How: Closes resources with Promise.allSettled and clears registries.
330
- */
331
- export async function stopQueueRuntime() {
332
- await Promise.allSettled(workers.map((worker) => worker.close()));
333
- workers.length = 0;
334
-
335
- await Promise.allSettled(Array.from(events.values()).map((queueEvents) => queueEvents.close()));
336
- events.clear();
337
-
338
- await Promise.allSettled(Array.from(queues.values()).map((queue) => queue.close()));
339
- queues.clear();
340
-
341
- durableStateStore = null;
342
- }
1
+ import { type Job, type JobsOptions, Queue, QueueEvents, Worker } from "bullmq";
2
+ import { type DurableContext, type DurableJob, DurableWorker, RedisStateStore } from "bullmq-durable";
3
+ import { queueConfig } from "@/config/index.js";
4
+ import { discoverModuleFiles, importFile } from "@/framework/modules/discover.js";
5
+ import { redisClientIfReady } from "@/framework/redis/client.js";
6
+
7
+ export type QueueJob = {
8
+ name: string;
9
+ data: any;
10
+ };
11
+
12
+ export type QueueHandler = (job: Job) => Promise<any>;
13
+
14
+ export type DurableQueueHandler = (job: DurableJob, ctx: DurableContext) => Promise<any>;
15
+
16
+ export type AnyQueueHandler = QueueHandler | DurableQueueHandler;
17
+
18
+ const queues = new Map<string, Queue>();
19
+ const events = new Map<string, QueueEvents>();
20
+ const handlers = new Map<string, QueueHandler>();
21
+ const durableHandlers = new Map<string, DurableQueueHandler>();
22
+ const workers: Worker[] = [];
23
+ /**
24
+ * Queue names this process already has a worker for. Each BullMQ `Worker` holds
25
+ * its own blocking Redis connection, so starting the runtime twice for one queue
26
+ * would silently double that connection for no benefit. Cleared by
27
+ * `stopQueueRuntime` so a later boot in the same process still works.
28
+ */
29
+ const workedQueues = new Set<string>();
30
+ let durableStateStore: RedisStateStore | null = null;
31
+
32
+ /**
33
+ * Why: Jobs already reported as dropped, so that one unreachable queue cannot
34
+ * flood the log from a request hot path.
35
+ * When: A call to queueJob finds no Redis.
36
+ * Where: Queue facade module scope.
37
+ * How: Keyed by queue + job name, because that pair is what identifies a queued
38
+ * job - two queues running the same job name are two separate failures and
39
+ * must each be reported once.
40
+ */
41
+ const droppedJobs = new Set<string>();
42
+
43
+ function queuePrefix() {
44
+ return queueConfig.prefix;
45
+ }
46
+
47
+ function key(queue: string, job: string) {
48
+ return `${queue}:${job}`;
49
+ }
50
+
51
+ /**
52
+ * Why: Registers a job handler for a queue + job name.
53
+ * When: Module job files are loaded during boot.
54
+ * Where: Module job definition files under `src/modules/<module>/jobs`.
55
+ * How: Stores handler in an in-memory lookup key. Pass `{ durable: true }` to
56
+ * register a checkpointed handler `(job, ctx)` powered by bullmq-durable;
57
+ * such a handler can split its work into `ctx.step(...)` checkpoints that
58
+ * survive crashes/restarts instead of re-running from scratch.
59
+ */
60
+ export function shouldQueue(job: string, queue: string, handler: AnyQueueHandler, options: { durable?: boolean } = {}) {
61
+ const target = key(queue || "default", job);
62
+ if (options.durable) {
63
+ durableHandlers.set(target, handler as DurableQueueHandler);
64
+ } else {
65
+ handlers.set(target, handler as QueueHandler);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Why: Gets or lazily creates a BullMQ queue instance.
71
+ * When: Enqueueing jobs or ensuring queue resources.
72
+ * Where: Queue facade and dispatcher.
73
+ * How: Reuses shared Redis connection and memoized queue map.
74
+ */
75
+ export function getQueue(queue = "default") {
76
+ const client = redisClientIfReady();
77
+ if (!client) return null;
78
+
79
+ if (!queues.has(queue)) {
80
+ queues.set(
81
+ queue,
82
+ new Queue(queue, {
83
+ connection: client as any,
84
+ prefix: queuePrefix(),
85
+ defaultJobOptions: {
86
+ attempts: 3,
87
+ backoff: { type: "exponential", delay: 3000 },
88
+ removeOnComplete: 1000,
89
+ removeOnFail: 5000
90
+ }
91
+ })
92
+ );
93
+ }
94
+
95
+ return queues.get(queue)!;
96
+ }
97
+
98
+ /**
99
+ * Why: Provides the shared Redis-backed state store for durable queues.
100
+ * When: Durable queue/worker construction.
101
+ * Where: Queue facade internals.
102
+ * How: Creates one `RedisStateStore` reusing the shared Redis client so durable
103
+ * state lives in Redis. Note the store opens its own client on first use -
104
+ * bullmq-durable does this so that merely constructing a store costs no
105
+ * connection - which is why `stopQueueRuntime` must call `close()` on it.
106
+ */
107
+ function durableStateStoreIfReady() {
108
+ const client = redisClientIfReady();
109
+ if (!client) return null;
110
+
111
+ if (!durableStateStore) {
112
+ durableStateStore = new RedisStateStore({ connection: client as never, prefix: queueConfig.durablePrefix });
113
+ }
114
+ return durableStateStore;
115
+ }
116
+
117
+ /**
118
+ * Why: Facade-friendly alias for getQueue.
119
+ * When: Consumers want fluent queue access.
120
+ * Where: Framework facade/API usage.
121
+ * How: Delegates directly to getQueue.
122
+ */
123
+ export function queue(queueName = "default") {
124
+ return getQueue(queueName);
125
+ }
126
+
127
+ /**
128
+ * Why: Ensures configured queues exist before UI/runtime usage.
129
+ * When: Queue dashboard setup or startup warmup.
130
+ * Where: Queue UI/bootstrap code.
131
+ * How: Iterates names and resolves queue instances.
132
+ */
133
+ export function ensureQueues(queueNames: string[]) {
134
+ for (const queueName of queueNames) {
135
+ getQueue(queueName);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Why: Returns all created queue instances.
141
+ * When: Queue introspection and dashboard binding.
142
+ * Where: Queue dashboard setup code.
143
+ * How: Converts internal queue map to array.
144
+ */
145
+ export function getAllQueues() {
146
+ return Array.from(queues.values());
147
+ }
148
+
149
+ /**
150
+ * Why: Explains that a job was lost instead of letting it vanish.
151
+ * When: queueJob is called and no Redis connection is ready.
152
+ * Where: Called from queueJob.
153
+ * How: Warns once per queue + job. The message names the job and its queue,
154
+ * says plainly that it never ran, and gives both the Redis fix and the
155
+ * in-process alternative, so a caller reading only this line knows what
156
+ * to do without opening framework source.
157
+ */
158
+ function warnDroppedJob(queueName: string, job: string, delay?: number) {
159
+ const id = key(queueName, job);
160
+ if (droppedJobs.has(id)) return;
161
+ droppedJobs.add(id);
162
+
163
+ const schedule = delay ? ` It was due in ${delay}s, so it was never even scheduled.` : "";
164
+ console.warn(
165
+ `[queue] Dropped job "${job}" on queue "${queueName}". Redis is not available, so this job was never queued and never ran.${schedule} ` +
166
+ `Enable REDIS in .env and run a queue worker, or call dispatchCommand("${job}", payload) without { async: true } to run it in-process now.`
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Why: Adds a job to BullMQ with project defaults.
172
+ * When: Commands/events need background execution.
173
+ * Where: Dispatcher and feature modules.
174
+ * How: Resolves queue then calls add with retry/backoff options. When Redis is
175
+ * absent there is nowhere to put the job, so it returns null and says so -
176
+ * previously it returned null with no output at all, which meant silently
177
+ * losing the work.
178
+ */
179
+ export async function queueJob(
180
+ job: string,
181
+ data: any,
182
+ options: {
183
+ queue?: string;
184
+ delay?: number;
185
+ attempts?: number;
186
+ jobId?: string;
187
+ priority?: number;
188
+ removeOnComplete?: JobsOptions["removeOnComplete"];
189
+ removeOnFail?: JobsOptions["removeOnFail"];
190
+ backoff?: JobsOptions["backoff"];
191
+ } = {}
192
+ ) {
193
+ const queueName = options.queue || "default";
194
+ const queue = getQueue(queueName);
195
+ if (!queue) {
196
+ warnDroppedJob(queueName, job, options.delay);
197
+ return null;
198
+ }
199
+
200
+ return await queue.add(job, data, {
201
+ delay: options.delay ? options.delay * 1000 : 0,
202
+ attempts: options.attempts ?? 3,
203
+ jobId: options.jobId,
204
+ priority: options.priority,
205
+ removeOnComplete: options.removeOnComplete ?? 1000,
206
+ removeOnFail: options.removeOnFail ?? 5000,
207
+ backoff: options.backoff ?? { type: "exponential", delay: 3000 }
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Why: Loads module job files so handlers are registered.
213
+ * When: Worker start and explicit queue boot flows.
214
+ * Where: Worker/runtime bootstrap.
215
+ * How: Discovers jobs glob and imports each file.
216
+ */
217
+ export async function bootQueueJobs() {
218
+ const files = await discoverModuleFiles("**/jobs/*.{ts,js}");
219
+ for (const file of files) await importFile(file);
220
+ return files.length;
221
+ }
222
+
223
+ /**
224
+ * Why: Deletes phantom queues whose names are neither configured nor worked by
225
+ * this process.
226
+ * When: Queue worker boot, after workers start.
227
+ * Where: Worker runtime bootstrap.
228
+ * How: Scans Redis for `{prefix}*:meta` keys, and for each queue name that is
229
+ * not in queueConfig.queues nor in the queues this worker was launched
230
+ * with (--queue), obliterates it only when it holds no live jobs
231
+ * (waiting/active/delayed/paused/prioritized/waiting-children). Historical
232
+ * completed/failed entries are ignored so leftover history cannot keep a
233
+ * dead queue alive. Recreates nothing permanent: BullMQ re-creates a
234
+ * queue's keys on demand if any code path still dispatches to it.
235
+ */
236
+ export async function pruneStaleQueues(protectedQueues: string[] = []) {
237
+ if (!queueConfig.autoPruneQueues) return;
238
+
239
+ const client = redisClientIfReady();
240
+ if (!client) return;
241
+
242
+ const configured = new Set([...queueConfig.queues, ...protectedQueues]);
243
+ const prefix = `${queuePrefix()}:`;
244
+ const candidates = new Set<string>();
245
+
246
+ try {
247
+ let cursor = "0";
248
+ do {
249
+ const [nextCursor, keys] = await client.scan(cursor, "MATCH", `${prefix}*:meta`, "COUNT", 100);
250
+ cursor = nextCursor;
251
+ for (const key of keys) {
252
+ const name = key.slice(prefix.length, -5);
253
+ if (name && !configured.has(name)) candidates.add(name);
254
+ }
255
+ } while (cursor !== "0");
256
+
257
+ for (const name of candidates) {
258
+ try {
259
+ const queue = getQueue(name);
260
+ if (!queue) continue;
261
+
262
+ const counts = await queue.getJobCounts("waiting", "active", "delayed", "paused", "prioritized", "waiting-children");
263
+ const live = Object.values(counts).reduce((sum, n) => sum + (Number(n) || 0), 0);
264
+ if (live !== 0) continue;
265
+
266
+ await queue.obliterate({ force: true });
267
+ console.log(`Pruned stale queue "${name}"`);
268
+ } catch (error: any) {
269
+ console.warn(`Could not prune stale queue "${name}": ${error?.message || error}`);
270
+ }
271
+ }
272
+ } catch (error: any) {
273
+ console.warn(`Could not scan queues for stale cleanup: ${error?.message || error}`);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Why: Starts BullMQ workers for selected queues.
279
+ * When: Queue worker process boots.
280
+ * Where: Worker runtime entrypoint.
281
+ * How: Boots handlers, creates QueueEvents, and creates workers.
282
+ */
283
+ export async function startQueueWorker(queueNames = ["default"]) {
284
+ await bootQueueJobs();
285
+ const client = redisClientIfReady();
286
+ if (!client) throw new Error("Redis is required for queue workers");
287
+
288
+ for (const queueName of queueNames) {
289
+ // Already working this queue in this process: a second worker would add a
290
+ // second blocking connection and a second copy of every log line, and would
291
+ // never process a job the first one was not already getting.
292
+ if (workedQueues.has(queueName)) continue;
293
+ workedQueues.add(queueName);
294
+
295
+ if (!events.has(queueName)) {
296
+ events.set(queueName, new QueueEvents(queueName, { connection: client as any, prefix: queuePrefix() }));
297
+ }
298
+
299
+ const durable = [...durableHandlers.keys()].some((k) => k.startsWith(`${queueName}:`));
300
+
301
+ if (durable) {
302
+ const store = durableStateStoreIfReady();
303
+ if (!store) throw new Error("Redis is required for durable queue workers");
304
+
305
+ const map: Record<string, AnyQueueHandler> = {};
306
+ for (const [k, h] of handlers) {
307
+ if (k.startsWith(`${queueName}:`)) map[k.slice(queueName.length + 1)] = h;
308
+ }
309
+ for (const [k, h] of durableHandlers) {
310
+ if (k.startsWith(`${queueName}:`)) map[k.slice(queueName.length + 1)] = h;
311
+ }
312
+
313
+ const worker = new DurableWorker(queueName, map as never, {
314
+ connection: client as any,
315
+ prefix: queuePrefix(),
316
+ stateStore: store,
317
+ concurrency: 10
318
+ });
319
+
320
+ worker.on("completed", (job) => {
321
+ console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
322
+ });
323
+
324
+ worker.on("failed", (job, error) => {
325
+ const jobName = job?.name ?? "unknown";
326
+ console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
327
+ });
328
+
329
+ workers.push(worker as unknown as Worker);
330
+ continue;
331
+ }
332
+
333
+ const worker = new Worker(
334
+ queueName,
335
+ async (job) => {
336
+ console.log(`[${new Date().toLocaleTimeString()}] Processing: ${job.name} (${queueName})`);
337
+ const handler = handlers.get(key(queueName, job.name));
338
+ if (!handler) throw new Error(`No handler registered for ${queueName}:${job.name}`);
339
+ return await handler(job);
340
+ },
341
+ { connection: client as any, prefix: queuePrefix(), concurrency: 10 }
342
+ );
343
+
344
+ worker.on("completed", (job) => {
345
+ console.log(`[${new Date().toLocaleTimeString()}] Processed: ${job.name} (${queueName})`);
346
+ });
347
+
348
+ worker.on("failed", (job, error) => {
349
+ const jobName = job?.name ?? "unknown";
350
+ console.log(`[${new Date().toLocaleTimeString()}] Failed: ${jobName} (${queueName}) - ${error.message}`);
351
+ });
352
+
353
+ workers.push(worker);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * Why: Hard-clears ALL queue-system state for reset workflows.
359
+ * When: Local cleanup and maintenance commands.
360
+ * Where: `queue:clear` command.
361
+ * How: Wipes every BullMQ queue key (including jobs, job schedulers, and
362
+ * repeat keys for queues not listed in `queues`, e.g. `demo`), plus the
363
+ * durable key space, by scanning each prefix and deleting in batches.
364
+ * Cache/session/rate-limit keys are intentionally left alone.
365
+ */
366
+ export async function clearQueue() {
367
+ const client = redisClientIfReady();
368
+ if (!client) return;
369
+
370
+ for (const prefix of [queuePrefix(), queueConfig.durablePrefix]) {
371
+ const stream = client.scanStream({ match: `${prefix}*`, count: 100 });
372
+ for await (const keys of stream) {
373
+ if (keys.length) await client.del(keys);
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Why: Gracefully stops all workers/events/queues.
380
+ * When: Process shutdown.
381
+ * Where: Server/worker lifecycle hooks.
382
+ * How: Closes resources with Promise.allSettled and clears registries. The
383
+ * durable state store is closed too: it owns a Redis client of its own, and
384
+ * dropping the reference without closing it would leak that connection on
385
+ * every restart until Redis runs out of clients.
386
+ */
387
+ export async function stopQueueRuntime() {
388
+ await Promise.allSettled(workers.map((worker) => worker.close()));
389
+ workers.length = 0;
390
+ workedQueues.clear();
391
+
392
+ await Promise.allSettled(Array.from(events.values()).map((queueEvents) => queueEvents.close()));
393
+ events.clear();
394
+
395
+ await Promise.allSettled(Array.from(queues.values()).map((queue) => queue.close()));
396
+ queues.clear();
397
+
398
+ if (durableStateStore) {
399
+ await Promise.allSettled([durableStateStore.close()]);
400
+ durableStateStore = null;
401
+ }
402
+ }