@pattern-stack/codegen 0.8.1 → 0.9.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 (107) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  3. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  4. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
  5. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  6. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  7. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  8. package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
  9. package/dist/runtime/subsystems/bridge/index.js +837 -182
  10. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  11. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  12. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  13. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  14. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  15. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  16. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  17. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  18. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  19. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  20. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  21. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  22. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  23. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  24. package/dist/runtime/subsystems/events/events.module.js +177 -3
  25. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  26. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  27. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  28. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  29. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  30. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  31. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  32. package/dist/runtime/subsystems/events/index.js +178 -3
  33. package/dist/runtime/subsystems/events/index.js.map +1 -1
  34. package/dist/runtime/subsystems/index.d.ts +1 -0
  35. package/dist/runtime/subsystems/index.js +1194 -264
  36. package/dist/runtime/subsystems/index.js.map +1 -1
  37. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  38. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  39. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  40. package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
  41. package/dist/runtime/subsystems/jobs/index.js +861 -201
  42. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  43. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
  44. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  45. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  46. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
  47. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  48. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  49. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
  50. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  51. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  52. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
  53. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  54. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  55. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
  56. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
  57. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  58. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  59. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
  60. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  61. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  62. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  65. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  66. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  67. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  68. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  69. package/dist/runtime/subsystems/observability/index.js +109 -2
  70. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  71. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  72. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  73. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
  74. package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
  75. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  76. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  77. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
  78. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
  79. package/dist/src/cli/index.js +30 -6
  80. package/dist/src/cli/index.js.map +1 -1
  81. package/package.json +1 -1
  82. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  83. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  84. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  85. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  86. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  87. package/runtime/subsystems/events/events.module.ts +18 -2
  88. package/runtime/subsystems/events/events.tokens.ts +16 -0
  89. package/runtime/subsystems/events/index.ts +7 -0
  90. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  91. package/runtime/subsystems/jobs/index.ts +22 -0
  92. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  93. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  94. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  95. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  96. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  97. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  98. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  99. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  100. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  101. package/runtime/subsystems/observability/index.ts +8 -0
  102. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  103. package/runtime/subsystems/observability/observability.service.ts +148 -1
  104. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  105. package/templates/relationship/new/prompt.js +8 -5
  106. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  107. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
@@ -54,6 +54,75 @@ export interface PoolStatusCount {
54
54
  count: number;
55
55
  }
56
56
 
57
+ /**
58
+ * Filter + keyset-pagination input for `IJobRunService.listJobRuns`
59
+ * (OBS-LIST-1). The combiner's `listJobRuns` forwards this verbatim.
60
+ *
61
+ * Pagination is keyset (a.k.a. seek) on `created_at` descending: pass the
62
+ * previous page's `nextCursor` as `cursor` to fetch the following page.
63
+ * Keyset (not offset) so deep pages stay O(log n) and don't drift as new
64
+ * rows arrive at the head.
65
+ */
66
+ export interface ListJobRunsQuery {
67
+ /** Filter to a single `pool`. */
68
+ poolId?: string;
69
+ /**
70
+ * Filter to a single run tree by `root_run_id`. Used by the correlation
71
+ * timeline to gather every run sharing a root.
72
+ */
73
+ rootRunId?: string;
74
+ /** Filter to a single status. Accepts any `JobRun['status']`. */
75
+ status?: JobRun['status'];
76
+ /** Lower bound on `created_at` (inclusive). */
77
+ since?: Date;
78
+ /**
79
+ * Opaque keyset cursor returned as `nextCursor` from a previous page.
80
+ * Encodes the `(createdAt, id)` of the last row seen.
81
+ */
82
+ cursor?: string;
83
+ /** Page size. Backend clamps to a sane default + max. */
84
+ limit?: number;
85
+ /**
86
+ * Multi-tenancy gate, same semantics as `countByPoolAndStatus`:
87
+ * - `multiTenant` off → ignored.
88
+ * - on + string → filters `tenant_id = :tenantId`.
89
+ * - on + null → filters `tenant_id IS NULL`.
90
+ * - on + undefined → throws `MissingTenantIdError`.
91
+ */
92
+ tenantId?: string | null;
93
+ }
94
+
95
+ /**
96
+ * Summary row for the `job_run` list (OBS-LIST-1). A narrow projection over
97
+ * `JobRun` carrying the columns a runs viewer renders. `rootRunId` is
98
+ * included so the correlation timeline can stitch runs to events.
99
+ */
100
+ export interface JobRunSummary {
101
+ runId: string;
102
+ rootRunId: string;
103
+ jobType: string;
104
+ pool: string;
105
+ status: JobRun['status'];
106
+ scopeEntityType: string | null;
107
+ scopeEntityId: string | null;
108
+ tenantId: string | null;
109
+ attempts: number;
110
+ errorMessage: string | null;
111
+ runAt: Date;
112
+ startedAt: Date | null;
113
+ finishedAt: Date | null;
114
+ createdAt: Date;
115
+ }
116
+
117
+ /**
118
+ * One page of `listJobRuns` results. `nextCursor` is `null` when there are
119
+ * no more rows; otherwise pass it back as `query.cursor` for the next page.
120
+ */
121
+ export interface JobRunPage {
122
+ items: JobRunSummary[];
123
+ nextCursor: string | null;
124
+ }
125
+
57
126
  /**
58
127
  * Summary row for the "recent failed runs" observability widget (OBS-2). A
59
128
  * narrow projection over `JobRun` — just the fields a dashboard needs.
@@ -122,4 +191,12 @@ export interface IJobRunService {
122
191
  limit: number,
123
192
  tenantId?: string | null,
124
193
  ): Promise<JobRunFailure[]>;
194
+
195
+ /**
196
+ * Paginated, filterable list of `job_run` rows for the observability runs
197
+ * viewer (OBS-LIST-1). Newest first (`created_at` desc, `id` desc as the
198
+ * keyset tie-break). Returns a `JobRunPage` with an opaque `nextCursor`
199
+ * for keyset pagination. Tenant gate follows `countByPoolAndStatus`.
200
+ */
201
+ listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage>;
125
202
  }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * BullMQJobWorker — BullMQ-backed claim/dispatch worker (BULLMQ-1).
3
+ *
4
+ * Replaces the Drizzle `JobWorker` polling loop with one BullMQ `Worker` per
5
+ * active pool. BullMQ owns claim (its native atomic BRPOPLPUSH), concurrency
6
+ * (`{ concurrency }`), and retry/backoff (job opts set by the orchestrator) —
7
+ * so this class is thinner than the Drizzle poller: no claim query, no stale
8
+ * sweeper, no backoff math.
9
+ *
10
+ * The processor still drives the domain through Postgres `job_run` (the
11
+ * source of truth) and runs the user handler through the existing
12
+ * `JobHandlerBase` contract (`ctx.input` / `ctx.step` / `ctx.spawnChild`),
13
+ * identical to the Drizzle path — only the claim mechanism differs.
14
+ *
15
+ * BullMQ job (runId) → load job_run → mark running → resolve handler via
16
+ * ModuleRef → run(ctx) → mark completed / let BullMQ retry on throw.
17
+ *
18
+ * On a thrown handler error we rethrow so BullMQ applies the job's `attempts`/
19
+ * `backoff` policy; the final failure (attempts exhausted) is mirrored to
20
+ * `job_run.status='failed'` in the `failed` event handler.
21
+ */
22
+ import { Logger } from '@nestjs/common';
23
+ import type { ModuleRef } from '@nestjs/core';
24
+ // `bullmq` is an OPTIONAL peer dependency — TYPE imports ONLY here. `Worker`,
25
+ // `Job`, `ConnectionOptions` are erased at compile time and never resolve
26
+ // `'bullmq'` at runtime. The `Worker` VALUE constructor is loaded lazily via
27
+ // `await import('bullmq')` in `onModuleInit` (mirrors
28
+ // `event-bus.redis-backend.ts:createRedisClient`). See BULLMQ-1 §Lazy import.
29
+ import type { Worker, Job, ConnectionOptions } from 'bullmq';
30
+ import { eq } from 'drizzle-orm';
31
+ import type { DrizzleClient } from '../../types/drizzle';
32
+ import { jobRuns, type JobRunRow } from './job-orchestration.schema';
33
+ import type { IJobOrchestrator, JobRun } from './job-orchestrator.protocol';
34
+ import type { IJobStepService } from './job-step-service.protocol';
35
+ import {
36
+ JOB_HANDLER_REGISTRY,
37
+ type JobContext,
38
+ type JobHandlerBase,
39
+ type SpawnChildOptions,
40
+ type StepOptions,
41
+ } from './job-handler.base';
42
+
43
+ interface BullJobPayload {
44
+ runId: string;
45
+ type: string;
46
+ input: unknown;
47
+ }
48
+
49
+ function serialiseError(err: unknown, attempt: number, retryable: boolean) {
50
+ const e = err as { message?: string; stack?: string } | undefined;
51
+ return {
52
+ message: (e?.message ?? String(err)) as string,
53
+ stack: e?.stack,
54
+ retryable,
55
+ attempt,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Options for a single per-pool BullMQ worker.
61
+ */
62
+ export interface BullMQJobWorkerOptions {
63
+ /** Logical pool name (matches `job_run.pool`). */
64
+ pool: string;
65
+ /** Fully-resolved BullMQ queue name to consume. */
66
+ queueName: string;
67
+ /** Max concurrent in-flight processors. */
68
+ concurrency: number;
69
+ /** ioredis-compatible connection. */
70
+ connection: ConnectionOptions;
71
+ }
72
+
73
+ export class BullMQJobWorker {
74
+ private readonly logger = new Logger(BullMQJobWorker.name);
75
+ private worker: Worker | null = null;
76
+
77
+ constructor(
78
+ private readonly db: DrizzleClient,
79
+ private readonly orchestrator: IJobOrchestrator,
80
+ private readonly stepService: IJobStepService,
81
+ private readonly options: BullMQJobWorkerOptions,
82
+ private readonly moduleRef: ModuleRef,
83
+ ) {}
84
+
85
+ async onModuleInit(): Promise<void> {
86
+ let WorkerCtor: typeof import('bullmq').Worker;
87
+ try {
88
+ const mod = await import('bullmq');
89
+ WorkerCtor = mod.Worker;
90
+ } catch {
91
+ throw new Error(
92
+ 'BullMQ backend requires the "bullmq" package. Install it with: npm install bullmq',
93
+ );
94
+ }
95
+ this.worker = new WorkerCtor(
96
+ this.options.queueName,
97
+ (job) => this.process(job as Job<BullJobPayload>),
98
+ {
99
+ connection: this.options.connection,
100
+ concurrency: this.options.concurrency,
101
+ },
102
+ );
103
+ this.worker.on('failed', (job, err) => {
104
+ // BullMQ fires `failed` after EACH attempt; only mirror to job_run when
105
+ // attempts are exhausted (BullMQ will not retry further).
106
+ if (!job) return;
107
+ const attemptsMade = job.attemptsMade;
108
+ const maxAttempts = job.opts.attempts ?? 1;
109
+ if (attemptsMade >= maxAttempts) {
110
+ void this.markFailed(job.data.runId, err, attemptsMade);
111
+ }
112
+ });
113
+ this.logger.log(
114
+ `BullMQ worker started: pool='${this.options.pool}' queue='${this.options.queueName}' concurrency=${this.options.concurrency}`,
115
+ );
116
+ }
117
+
118
+ async onModuleDestroy(): Promise<void> {
119
+ if (this.worker) {
120
+ await this.worker.close();
121
+ this.worker = null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Process one BullMQ job. Returns the handler output (stored by BullMQ as
127
+ * the job return value AND written to `job_run.output`). Throws on handler
128
+ * failure so BullMQ applies the retry policy.
129
+ */
130
+ private async process(job: Job<BullJobPayload>): Promise<unknown> {
131
+ const { runId } = job.data;
132
+ const [row] = await this.db
133
+ .select()
134
+ .from(jobRuns)
135
+ .where(eq(jobRuns.id, runId))
136
+ .limit(1);
137
+ if (!row) {
138
+ // Domain row vanished (canceled + removed). Treat as a no-op success so
139
+ // BullMQ doesn't retry a job whose authoritative state is gone.
140
+ this.logger.warn(`process: job_run ${runId} not found; skipping`);
141
+ return {};
142
+ }
143
+ const run = row as JobRunRow;
144
+
145
+ // Canceled in Postgres after enqueue but before claim — honour the domain
146
+ // decision and skip without running the handler.
147
+ if (run.status === 'canceled') {
148
+ return {};
149
+ }
150
+
151
+ const registryEntry = JOB_HANDLER_REGISTRY.get(run.jobType);
152
+ if (!registryEntry) {
153
+ throw new Error(
154
+ `No handler registered for jobType='${run.jobType}' (run ${run.id})`,
155
+ );
156
+ }
157
+
158
+ // Mark running (mirrors the Drizzle worker's claim transition).
159
+ await this.db
160
+ .update(jobRuns)
161
+ .set({
162
+ status: 'running',
163
+ claimedAt: new Date(),
164
+ startedAt: new Date(),
165
+ attempts: job.attemptsMade + 1,
166
+ updatedAt: new Date(),
167
+ })
168
+ .where(eq(jobRuns.id, run.id));
169
+
170
+ const HandlerClass = registryEntry.handlerClass;
171
+ const handler = this.moduleRef.get(
172
+ HandlerClass as unknown as new (...args: unknown[]) => unknown,
173
+ { strict: false },
174
+ ) as JobHandlerBase<unknown>;
175
+
176
+ const ctx: JobContext<unknown> = {
177
+ input: run.input,
178
+ run: run as JobRun,
179
+ step: this.makeStepFn(run),
180
+ spawnChild: this.makeSpawnFn(run),
181
+ logger: new Logger(`JobRun:${run.id}`),
182
+ };
183
+
184
+ const output = (await handler.run(ctx)) as
185
+ | Record<string, unknown>
186
+ | undefined;
187
+
188
+ await this.db
189
+ .update(jobRuns)
190
+ .set({
191
+ status: 'completed',
192
+ output: (output ?? {}) as Record<string, unknown>,
193
+ finishedAt: new Date(),
194
+ updatedAt: new Date(),
195
+ })
196
+ .where(eq(jobRuns.id, run.id));
197
+
198
+ return output ?? {};
199
+ }
200
+
201
+ private async markFailed(
202
+ runId: string,
203
+ err: unknown,
204
+ finalAttempts: number,
205
+ ): Promise<void> {
206
+ const [row] = await this.db
207
+ .select()
208
+ .from(jobRuns)
209
+ .where(eq(jobRuns.id, runId))
210
+ .limit(1);
211
+ if (!row) return;
212
+ const run = row as JobRunRow;
213
+ await this.db
214
+ .update(jobRuns)
215
+ .set({
216
+ status: 'failed',
217
+ attempts: finalAttempts,
218
+ finishedAt: new Date(),
219
+ error: serialiseError(err, finalAttempts, false),
220
+ updatedAt: new Date(),
221
+ })
222
+ .where(eq(jobRuns.id, runId));
223
+
224
+ // Parent-close-policy cascade — identical semantics to the Drizzle worker.
225
+ if (run.parentClosePolicy === 'terminate') {
226
+ try {
227
+ await this.orchestrator.cancel(run.id, {
228
+ cascade: true,
229
+ reason: 'parent-failed',
230
+ tenantId: run.tenantId,
231
+ });
232
+ } catch (cascadeErr) {
233
+ this.logger.warn(
234
+ `cascade on failed run ${run.id}: ${(cascadeErr as Error).message}`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+
240
+ // ── ctx.step / ctx.spawnChild (mirror JobWorker) ──────────────────────────
241
+
242
+ private makeStepFn(run: JobRunRow) {
243
+ return async <TOutput>(
244
+ stepId: string,
245
+ fn: () => Promise<TOutput>,
246
+ _opts?: StepOptions,
247
+ ): Promise<TOutput> => {
248
+ void _opts;
249
+ const existing = await this.stepService.findStep(run.id, stepId);
250
+ if (existing?.status === 'completed') {
251
+ return existing.output as TOutput;
252
+ }
253
+ const nextAttempts = (existing?.attempts ?? 0) + 1;
254
+ const seq = nextAttempts; // BullMQ path: seq is per-step attempt index
255
+ await this.stepService.recordStep({
256
+ jobRunId: run.id,
257
+ stepId,
258
+ kind: 'task',
259
+ seq,
260
+ status: 'running',
261
+ startedAt: new Date(),
262
+ attempts: nextAttempts,
263
+ });
264
+ try {
265
+ const output = await fn();
266
+ await this.stepService.recordStep({
267
+ jobRunId: run.id,
268
+ stepId,
269
+ kind: 'task',
270
+ seq,
271
+ status: 'completed',
272
+ output: output as Record<string, unknown> | undefined,
273
+ finishedAt: new Date(),
274
+ attempts: nextAttempts,
275
+ });
276
+ return output;
277
+ } catch (err) {
278
+ await this.stepService.recordStep({
279
+ jobRunId: run.id,
280
+ stepId,
281
+ kind: 'task',
282
+ seq,
283
+ status: 'failed',
284
+ error: serialiseError(err, nextAttempts, false),
285
+ finishedAt: new Date(),
286
+ attempts: nextAttempts,
287
+ });
288
+ throw err;
289
+ }
290
+ };
291
+ }
292
+
293
+ private makeSpawnFn(run: JobRunRow) {
294
+ return async (
295
+ type: string,
296
+ input: unknown,
297
+ opts?: SpawnChildOptions,
298
+ ): Promise<JobRun> => {
299
+ return this.orchestrator.start(type, input, {
300
+ parentRunId: run.id,
301
+ parentClosePolicy: opts?.closePolicy,
302
+ runAt: opts?.runAt,
303
+ priority: opts?.priority,
304
+ tags: opts?.tags,
305
+ triggerSource: 'parent',
306
+ triggerRef: run.id,
307
+ tenantId: run.tenantId,
308
+ });
309
+ };
310
+ }
311
+ }
@@ -47,10 +47,19 @@ import type { IJobRunService } from './job-run-service.protocol';
47
47
  import type { IJobStepService } from './job-step-service.protocol';
48
48
  import {
49
49
  allNonReservedPoolNames,
50
+ allPoolNames,
50
51
  loadPoolConfig,
51
52
  type PoolConfig,
52
53
  } from './pool-config.loader';
53
54
  import { JobWorker, type JobWorkerOptions } from './job-worker';
55
+ import { BullMQJobWorker } from './job-worker.bullmq-backend';
56
+ import type { ConnectionOptions } from 'bullmq';
57
+ import {
58
+ BULLMQ_CONNECTION,
59
+ BULLMQ_RESOLVED_CONFIG,
60
+ resolvePoolQueueName,
61
+ type BullMqResolvedConfig,
62
+ } from './bullmq.config';
54
63
  import {
55
64
  BootValidationError,
56
65
  ReservedPoolViolationError,
@@ -62,10 +71,11 @@ export interface JobWorkerModuleOptions {
62
71
  mode: 'embedded' | 'standalone';
63
72
  /**
64
73
  * Threads into the internal `JobsDomainModule.forRoot({ backend })`
65
- * import. Default `'drizzle'`. The boot-time validator runs only when
66
- * this is `'drizzle'`.
74
+ * import. Default `'drizzle'`. The boot-time validator runs for both
75
+ * `'drizzle'` and `'bullmq'` (both persist `job` rows to Postgres);
76
+ * `'memory'` skips it.
67
77
  */
68
- backend?: 'drizzle' | 'memory';
78
+ backend?: 'drizzle' | 'memory' | 'bullmq';
69
79
  /**
70
80
  * Active pool names. Defaults to every non-reserved pool in the resolved
71
81
  * config (i.e. `interactive`, `batch`, plus any user-defined pools).
@@ -73,6 +83,18 @@ export interface JobWorkerModuleOptions {
73
83
  * horizontally.
74
84
  */
75
85
  pools?: string[];
86
+ /**
87
+ * BULLMQ-1 Phase 1 — when `true`, `onModuleInit` activates **every** pool
88
+ * in the resolved config, including the reserved `events_*` lanes. This is
89
+ * how the standalone worker (`worker.ts`) drains bridge wrappers without
90
+ * the consumer hand-listing `...BRIDGE_RESERVED_POOLS`. Mutually exclusive
91
+ * with an explicit `pools` list — when both are set, `pools` wins (explicit
92
+ * beats blanket) and `allPools` is ignored.
93
+ *
94
+ * `BridgeModule`'s reserved-pool guard short-circuits to "pass" when this
95
+ * is `true`, since every reserved pool is provably being polled.
96
+ */
97
+ allPools?: boolean;
76
98
  /** SIGTERM drain budget. Default 30_000 ms. */
77
99
  shutdownTimeoutMs?: number;
78
100
  /**
@@ -128,6 +150,17 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
128
150
  */
129
151
  @Optional() @Inject(DRIZZLE) private readonly db: DrizzleClient | null = null,
130
152
  private readonly moduleRef?: ModuleRef,
153
+ /**
154
+ * BULLMQ-1 — resolved BullMQ connection + config, only bound when the
155
+ * inner `JobsDomainModule` was booted with `backend: 'bullmq'`. `@Optional()`
156
+ * so drizzle/memory boots see `null`.
157
+ */
158
+ @Optional()
159
+ @Inject(BULLMQ_CONNECTION)
160
+ private readonly bullConnection: ConnectionOptions | null = null,
161
+ @Optional()
162
+ @Inject(BULLMQ_RESOLVED_CONFIG)
163
+ private readonly bullConfig: BullMqResolvedConfig | null = null,
131
164
  ) {}
132
165
 
133
166
  // ============================================================================
@@ -166,8 +199,14 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
166
199
  }
167
200
 
168
201
  // (6) Resolve active pool list and spawn one worker per pool.
169
- const activePools =
170
- this.options.pools ?? allNonReservedPoolNames(poolConfig);
202
+ // Precedence: explicit `pools` > `allPools` (incl. reserved) >
203
+ // non-reserved default. BULLMQ-1 Phase 1 adds the `allPools` rung so
204
+ // the standalone worker drains the reserved `events_*` bridge lanes.
205
+ const activePools = this.options.pools
206
+ ? this.options.pools
207
+ : this.options.allPools
208
+ ? allPoolNames(poolConfig)
209
+ : allNonReservedPoolNames(poolConfig);
171
210
 
172
211
  for (const poolName of activePools) {
173
212
  const def = poolConfig.get(poolName);
@@ -193,14 +232,20 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
193
232
  };
194
233
  const worker = this.options.workerFactory
195
234
  ? this.options.workerFactory(workerOptions)
196
- : this.spawnWorker(workerOptions);
235
+ : backend === 'bullmq'
236
+ ? this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig)
237
+ : this.spawnWorker(workerOptions);
197
238
  // `JobWorker` extends Nest's lifecycle hooks but the worker isn't
198
239
  // a Nest provider here (we manage the array ourselves). Call
199
- // `onModuleInit` synchronously to start the polling loop.
200
- worker.onModuleInit();
240
+ // `onModuleInit` to start the loop. The Drizzle/stub workers return
241
+ // void; `BullMQJobWorker.onModuleInit` is async (it lazily loads the
242
+ // optional `bullmq` package), so we `await` — awaiting a `void` is a
243
+ // harmless no-op for the synchronous workers.
244
+ await worker.onModuleInit();
201
245
  this.workers.push(worker);
202
246
  this.logger.log(
203
- `JobWorker started: pool='${poolName}' (queue='${def.queue}') concurrency=${def.concurrency}`,
247
+ `JobWorker started: pool='${poolName}' (queue='${def.queue}') ` +
248
+ `concurrency=${def.concurrency} backend='${backend}'`,
204
249
  );
205
250
  }
206
251
  }
@@ -220,6 +265,20 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
220
265
  }
221
266
  }
222
267
  this.workers.length = 0;
268
+
269
+ // BULLMQ-1 — close the orchestrator's producer-side Queue/FlowProducer
270
+ // connections so the process can exit cleanly. The orchestrator is the
271
+ // BullMQ producer; workers are the consumers (closed above).
272
+ const orch = this.orchestrator as { closeConnections?: () => Promise<void> };
273
+ if (typeof orch.closeConnections === 'function') {
274
+ try {
275
+ await orch.closeConnections();
276
+ } catch (err) {
277
+ this.logger.error(
278
+ `BullMQ orchestrator connection close failed: ${(err as Error).message}`,
279
+ );
280
+ }
281
+ }
223
282
  }
224
283
 
225
284
  // ============================================================================
@@ -296,6 +355,54 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
296
355
  this.moduleRef,
297
356
  );
298
357
  }
358
+
359
+ /**
360
+ * BULLMQ-1 — spawn a per-pool `BullMQJobWorker`. Requires the Drizzle
361
+ * client (the worker drives `job_run` as the source of truth) AND the
362
+ * resolved BullMQ connection (bound by `JobsDomainModule` when
363
+ * `backend: 'bullmq'`). The queue name is derived identically to the
364
+ * orchestrator's `dispatch` via `resolvePoolQueueName(pool, …)` so producer
365
+ * and consumer agree.
366
+ */
367
+ private spawnBullMQWorker(
368
+ pool: string,
369
+ _queueAlias: string,
370
+ concurrency: number,
371
+ poolConfig: PoolConfig,
372
+ ): BullMQJobWorker {
373
+ if (!this.db) {
374
+ throw new Error(
375
+ `JobWorkerModule: BullMQ worker spawning requires the Drizzle client ` +
376
+ `(no DRIZZLE provider available) — job_run remains the source of truth.`,
377
+ );
378
+ }
379
+ if (!this.bullConnection) {
380
+ throw new Error(
381
+ `JobWorkerModule: BullMQ worker spawning requires a resolved ` +
382
+ `BULLMQ_CONNECTION. Ensure JobsDomainModule was booted with ` +
383
+ `backend: 'bullmq'.`,
384
+ );
385
+ }
386
+ if (!this.moduleRef) {
387
+ throw new Error(
388
+ `JobWorkerModule: ModuleRef not available — cannot construct ` +
389
+ `BullMQJobWorker with handler DI support.`,
390
+ );
391
+ }
392
+ const queueName = resolvePoolQueueName(pool, this.bullConfig, poolConfig);
393
+ return new BullMQJobWorker(
394
+ this.db,
395
+ this.orchestrator,
396
+ this.stepService,
397
+ {
398
+ pool,
399
+ queueName,
400
+ concurrency,
401
+ connection: this.bullConnection,
402
+ },
403
+ this.moduleRef,
404
+ );
405
+ }
299
406
  }
300
407
 
301
408
  @Module({})
@@ -314,7 +421,14 @@ export class JobWorkerModule {
314
421
  { provide: JOB_WORKER_MODULE_OPTIONS, useValue: opts },
315
422
  JobWorkerOrchestrator,
316
423
  ],
317
- exports: [],
424
+ // BULLMQ-1 Phase 1 — export the options token so `BridgeModule`'s
425
+ // reserved-pool guard (`onModuleInit`) can actually inject it.
426
+ // Previously `exports: []` left the `@Optional()` inject resolving to
427
+ // `undefined` and the guard silently no-opped (a dead check). With the
428
+ // token exported the guard fires for real; consumers that omit the
429
+ // reserved pools (and don't set `allPools`) now fail fast with
430
+ // `BridgeReservedPoolsNotPolledError` — which is correct.
431
+ exports: [JOB_WORKER_MODULE_OPTIONS],
318
432
  };
319
433
  }
320
434
  }