@pattern-stack/codegen 0.8.0 → 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 (118) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/runtime/subsystems/auth/controllers/auth.controller.d.ts +1 -0
  3. package/dist/runtime/subsystems/auth/index.d.ts +2 -0
  4. package/dist/runtime/subsystems/auth/index.js +55 -0
  5. package/dist/runtime/subsystems/auth/index.js.map +1 -1
  6. package/dist/runtime/subsystems/auth/middleware/requester-context.d.ts +81 -0
  7. package/dist/runtime/subsystems/auth/middleware/requester-context.js +60 -0
  8. package/dist/runtime/subsystems/auth/middleware/requester-context.js.map +1 -0
  9. package/dist/runtime/subsystems/auth/protocols/user-context.d.ts +18 -0
  10. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  11. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  12. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
  13. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  14. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  15. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  16. package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
  17. package/dist/runtime/subsystems/bridge/index.js +837 -182
  18. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  19. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  20. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  21. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  22. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  23. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  24. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  25. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  26. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  27. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  28. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  29. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  30. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  31. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  32. package/dist/runtime/subsystems/events/events.module.js +177 -3
  33. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  34. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  35. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  36. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  37. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  38. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  39. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  40. package/dist/runtime/subsystems/events/index.js +178 -3
  41. package/dist/runtime/subsystems/events/index.js.map +1 -1
  42. package/dist/runtime/subsystems/index.d.ts +2 -0
  43. package/dist/runtime/subsystems/index.js +1198 -264
  44. package/dist/runtime/subsystems/index.js.map +1 -1
  45. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  46. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  47. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  48. package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
  49. package/dist/runtime/subsystems/jobs/index.js +861 -201
  50. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  51. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
  52. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  53. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  54. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
  55. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  56. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  57. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
  58. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  59. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  60. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
  61. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  62. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  63. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
  64. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
  65. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  66. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  67. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
  68. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  69. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  70. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  71. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  72. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  73. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  74. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  75. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  76. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  77. package/dist/runtime/subsystems/observability/index.js +109 -2
  78. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  79. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  80. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  81. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
  82. package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
  83. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  84. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  85. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
  86. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
  87. package/dist/src/cli/index.js +43 -7
  88. package/dist/src/cli/index.js.map +1 -1
  89. package/package.json +1 -1
  90. package/runtime/subsystems/auth/index.ts +8 -0
  91. package/runtime/subsystems/auth/middleware/requester-context.ts +141 -0
  92. package/runtime/subsystems/auth/protocols/user-context.ts +17 -0
  93. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  94. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  95. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  96. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  97. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  98. package/runtime/subsystems/events/events.module.ts +18 -2
  99. package/runtime/subsystems/events/events.tokens.ts +16 -0
  100. package/runtime/subsystems/events/index.ts +7 -0
  101. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  102. package/runtime/subsystems/jobs/index.ts +22 -0
  103. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  104. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  105. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  106. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  107. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  108. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  109. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  110. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  111. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  112. package/runtime/subsystems/observability/index.ts +8 -0
  113. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  114. package/runtime/subsystems/observability/observability.service.ts +148 -1
  115. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  116. package/templates/relationship/new/prompt.js +8 -5
  117. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  118. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
@@ -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
  }
@@ -24,10 +24,17 @@ import {
24
24
  import { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
25
25
  import { DrizzleJobRunService } from './job-run-service.drizzle-backend';
26
26
  import { DrizzleJobStepService } from './job-step-service.drizzle-backend';
27
+ import { BullMQJobOrchestrator } from './job-orchestrator.bullmq-backend';
27
28
  import { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';
28
29
  import { MemoryJobRunService } from './job-run-service.memory-backend';
29
30
  import { MemoryJobStepService } from './job-step-service.memory-backend';
30
31
  import { MemoryJobStore } from './memory-job-store';
32
+ import {
33
+ BULLMQ_CONNECTION,
34
+ BULLMQ_RESOLVED_CONFIG,
35
+ resolveBullMqConfig,
36
+ type BullMqExtensionsConfig,
37
+ } from './bullmq.config';
31
38
 
32
39
  /**
33
40
  * Drizzle backend extensions surface. None are wired into the Drizzle
@@ -44,19 +51,8 @@ export interface DrizzleBackendExtensions {
44
51
  pollIntervalMs?: number;
45
52
  }
46
53
 
47
- // Phase 6+ — typed-but-unimplemented BullMQ extension slot. Kept as a
48
- // commented-out interface to make the future shape discoverable without
49
- // shipping dead runtime code. Per CLAUDE.md "no feature-flag-guarded dead
50
- // code" we don't ship the option in `JobsDomainModuleOptions.extensions`
51
- // either; flip it on when JOB-Phase-6 lands the BullMQ orchestrator.
52
- //
53
- // export interface BullMqBackendExtensions {
54
- // bullBoard?: { enabled: boolean; mountPath?: string };
55
- // redisUrl?: string;
56
- // }
57
-
58
54
  export interface JobsDomainModuleOptions {
59
- backend: 'drizzle' | 'memory';
55
+ backend: 'drizzle' | 'memory' | 'bullmq';
60
56
  /**
61
57
  * Backend-specific extensions. Only the matching backend's extensions
62
58
  * are read at boot; non-matching keys are ignored. This is the
@@ -64,7 +60,12 @@ export interface JobsDomainModuleOptions {
64
60
  */
65
61
  extensions?: {
66
62
  drizzle?: DrizzleBackendExtensions;
67
- // bullmq?: BullMqBackendExtensions; // Phase 6+
63
+ /**
64
+ * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML
65
+ * under `jobs.extensions.bullmq`. `redis_url` falls back to
66
+ * `process.env.REDIS_URL` then `redis://localhost:6379`.
67
+ */
68
+ bullmq?: BullMqExtensionsConfig;
68
69
  };
69
70
  /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */
70
71
  multiTenant?: boolean;
@@ -73,8 +74,6 @@ export interface JobsDomainModuleOptions {
73
74
  @Module({})
74
75
  export class JobsDomainModule {
75
76
  static forRoot(opts: JobsDomainModuleOptions): DynamicModule {
76
- void opts.extensions; // typed reservation; consumed by Phase 6+ wiring
77
-
78
77
  const multiTenant = opts.multiTenant ?? false;
79
78
 
80
79
  const providers: Provider[] = [
@@ -98,22 +97,42 @@ export class JobsDomainModule {
98
97
  providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });
99
98
  providers.push(MemoryJobRunService);
100
99
  providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });
100
+ } else if (opts.backend === 'bullmq') {
101
+ // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The
102
+ // run/step services stay Drizzle (domain reads + `listForScope` are
103
+ // Postgres queries, unchanged per spec). Only the orchestrator's
104
+ // claim/dispatch half swaps to BullMQ.
105
+ const resolved = resolveBullMqConfig(opts.extensions?.bullmq);
106
+ providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });
107
+ providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });
108
+ providers.push({ provide: JOB_ORCHESTRATOR, useClass: BullMQJobOrchestrator });
109
+ providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
110
+ providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
101
111
  } else {
102
112
  providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });
103
113
  providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });
104
114
  providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });
105
115
  }
106
116
 
117
+ const exports = [
118
+ JOB_ORCHESTRATOR,
119
+ JOB_RUN_SERVICE,
120
+ JOB_STEP_SERVICE,
121
+ JOBS_MULTI_TENANT,
122
+ ];
123
+ // BULLMQ-1 — only export the BullMQ tokens when they were actually
124
+ // provided. Nest throws "exported but not provided" otherwise. Exported so
125
+ // JobWorkerModule (which imports this module) can read the resolved
126
+ // connection/config to spawn BullMQ workers.
127
+ if (opts.backend === 'bullmq') {
128
+ exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);
129
+ }
130
+
107
131
  return {
108
132
  module: JobsDomainModule,
109
133
  global: true,
110
134
  providers,
111
- exports: [
112
- JOB_ORCHESTRATOR,
113
- JOB_RUN_SERVICE,
114
- JOB_STEP_SERVICE,
115
- JOBS_MULTI_TENANT,
116
- ],
135
+ exports,
117
136
  };
118
137
  }
119
138
  }
@@ -194,6 +194,17 @@ export function allNonReservedPoolNames(config: PoolConfig): string[] {
194
194
  return out;
195
195
  }
196
196
 
197
+ /**
198
+ * Names of **every** pool in the resolved config, reserved `events_*` lanes
199
+ * included. The activation set for a standalone worker booted with
200
+ * `JobWorkerModule.forRoot({ allPools: true })` (BULLMQ-1 Phase 1) — the
201
+ * single worker process drains both user pools and the bridge's reserved
202
+ * pools so wrapper `job_run` rows are never stranded.
203
+ */
204
+ export function allPoolNames(config: PoolConfig): string[] {
205
+ return [...config.keys()];
206
+ }
207
+
197
208
  // ─── internals ──────────────────────────────────────────────────────────────
198
209
 
199
210
  interface UserPoolShape {
@@ -30,6 +30,14 @@ export type {
30
30
  IObservability,
31
31
  PoolStatusCount,
32
32
  JobRunFailure,
33
+ JobRunSummary,
34
+ JobRunPage,
35
+ ListJobRunsQuery,
36
+ EventSummary,
37
+ EventPage,
38
+ ListEventsQuery,
39
+ CorrelationTimeline,
40
+ CorrelationTimelineEntry,
33
41
  StatusHistogram,
34
42
  SyncRunSummary,
35
43
  CursorSnapshot,
@@ -21,12 +21,49 @@
21
21
 
22
22
  import type {
23
23
  JobRunFailure,
24
+ JobRunPage,
25
+ JobRunSummary,
26
+ ListJobRunsQuery,
24
27
  PoolStatusCount,
25
28
  } from '../jobs/job-run-service.protocol';
29
+ import type {
30
+ EventPage,
31
+ EventSummary,
32
+ ListEventsQuery,
33
+ } from '../events/event-read.protocol';
26
34
  import type { StatusHistogram } from '../bridge/bridge.protocol';
27
35
  import type { SyncRunSummary } from '../sync/sync-run-recorder.protocol';
28
36
  import type { CursorSnapshot } from '../sync/sync-cursor-store.protocol';
29
37
 
38
+ /**
39
+ * One chronological entry in a correlation timeline (OBS-LIST-1). Either a
40
+ * `job_run` or a `domain_event` sharing the same `rootRunId`, tagged with a
41
+ * `kind` discriminator and a single `at` timestamp used for ordering.
42
+ */
43
+ export type CorrelationTimelineEntry =
44
+ | { kind: 'job_run'; at: Date; run: JobRunSummary }
45
+ | { kind: 'event'; at: Date; event: EventSummary };
46
+
47
+ /**
48
+ * Stitched view of everything correlated to a single `rootRunId`
49
+ * (OBS-LIST-1): the job runs sharing that root plus the domain events whose
50
+ * `metadata.rootRunId` matches, merged into one ascending timeline with a
51
+ * small roll-up summary.
52
+ */
53
+ export interface CorrelationTimeline {
54
+ rootRunId: string;
55
+ /** Ascending by `at`. Job runs ordered by `createdAt`; events by `occurredAt`. */
56
+ entries: CorrelationTimelineEntry[];
57
+ summary: {
58
+ runCount: number;
59
+ eventCount: number;
60
+ /** Earliest `at` across all entries, or `null` when empty. */
61
+ startedAt: Date | null;
62
+ /** Latest `at` across all entries, or `null` when empty. */
63
+ lastActivityAt: Date | null;
64
+ };
65
+ }
66
+
30
67
  export interface IObservability {
31
68
  /**
32
69
  * Live `(pool, status)` counts across `job_run`. Delegates to
@@ -79,6 +116,39 @@ export interface IObservability {
79
116
  * Empty array when the sync subsystem is not installed.
80
117
  */
81
118
  getCursors(tenantId?: string | null): Promise<CursorSnapshot[]>;
119
+
120
+ /**
121
+ * Paginated, filterable `job_run` list (OBS-LIST-1). Delegates to
122
+ * `IJobRunService.listJobRuns`. Keyset pagination on `created_at`.
123
+ *
124
+ * Returns an empty page (`{ items: [], nextCursor: null }`) when the jobs
125
+ * subsystem is not installed.
126
+ */
127
+ listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage>;
128
+
129
+ /**
130
+ * Paginated, filterable `domain_events` list (OBS-LIST-1). Delegates to
131
+ * `IEventReadPort.listEvents`. Keyset pagination on `occurred_at`.
132
+ *
133
+ * Returns an empty page when the events read port is not installed (e.g.
134
+ * the events subsystem is absent, or its backend is `redis` which retains
135
+ * no history).
136
+ */
137
+ listEvents(query?: ListEventsQuery): Promise<EventPage>;
138
+
139
+ /**
140
+ * Stitch the job runs and domain events sharing a `rootRunId` into a
141
+ * single ascending timeline + summary (OBS-LIST-1). Composes
142
+ * `IJobRunService.listJobRuns` (filtered by the run tree) and
143
+ * `IEventReadPort.listEvents({ rootRunId })`.
144
+ *
145
+ * Returns an empty timeline (zero counts, null bounds) when neither the
146
+ * jobs subsystem nor the events read port is installed.
147
+ */
148
+ getCorrelationTimeline(
149
+ rootRunId: string,
150
+ tenantId?: string | null,
151
+ ): Promise<CorrelationTimeline>;
82
152
  }
83
153
 
84
154
  // Re-export composed return types so consumers of IObservability can import
@@ -86,6 +156,12 @@ export interface IObservability {
86
156
  export type {
87
157
  PoolStatusCount,
88
158
  JobRunFailure,
159
+ JobRunSummary,
160
+ JobRunPage,
161
+ ListJobRunsQuery,
162
+ EventSummary,
163
+ EventPage,
164
+ ListEventsQuery,
89
165
  StatusHistogram,
90
166
  SyncRunSummary,
91
167
  CursorSnapshot,
@@ -33,9 +33,20 @@ import { JOB_RUN_SERVICE } from '../jobs/jobs-domain.tokens';
33
33
  import type {
34
34
  IJobRunService,
35
35
  JobRunFailure,
36
+ JobRunPage,
37
+ JobRunSummary,
38
+ ListJobRunsQuery,
36
39
  PoolStatusCount,
37
40
  } from '../jobs/job-run-service.protocol';
38
41
 
42
+ import { EVENT_READ_PORT } from '../events/events.tokens';
43
+ import type {
44
+ EventPage,
45
+ EventSummary,
46
+ IEventReadPort,
47
+ ListEventsQuery,
48
+ } from '../events/event-read.protocol';
49
+
39
50
  import { BRIDGE_DELIVERY_REPO } from '../bridge/bridge.tokens';
40
51
  import type { IJobBridge, StatusHistogram } from '../bridge/bridge.protocol';
41
52
 
@@ -49,7 +60,19 @@ import type {
49
60
  ICursorStore,
50
61
  } from '../sync/sync-cursor-store.protocol';
51
62
 
52
- import type { IObservability } from './observability.protocol';
63
+ import type {
64
+ CorrelationTimeline,
65
+ CorrelationTimelineEntry,
66
+ IObservability,
67
+ } from './observability.protocol';
68
+
69
+ /**
70
+ * Safety bound on how many pages the correlation timeline will walk when
71
+ * draining a sibling port. A single run tree producing more than
72
+ * 50 pages × default page size of correlated rows is pathological; cap to
73
+ * keep the stitch bounded rather than unbounded-loop on bad data.
74
+ */
75
+ const MAX_TIMELINE_PAGES = 50;
53
76
 
54
77
  @Injectable()
55
78
  export class ObservabilityService implements IObservability {
@@ -65,6 +88,16 @@ export class ObservabilityService implements IObservability {
65
88
  failed: 0,
66
89
  };
67
90
 
91
+ /** Empty page used when a sibling read port is absent. */
92
+ private static readonly EMPTY_JOB_RUN_PAGE: JobRunPage = {
93
+ items: [],
94
+ nextCursor: null,
95
+ };
96
+ private static readonly EMPTY_EVENT_PAGE: EventPage = {
97
+ items: [],
98
+ nextCursor: null,
99
+ };
100
+
68
101
  constructor(
69
102
  @Optional()
70
103
  @Inject(JOB_RUN_SERVICE)
@@ -78,6 +111,9 @@ export class ObservabilityService implements IObservability {
78
111
  @Optional()
79
112
  @Inject(SYNC_CURSOR_STORE)
80
113
  private readonly cursors?: ICursorStore,
114
+ @Optional()
115
+ @Inject(EVENT_READ_PORT)
116
+ private readonly events?: IEventReadPort | null,
81
117
  ) {}
82
118
 
83
119
  async getPoolDepths(tenantId?: string | null): Promise<PoolStatusCount[]> {
@@ -114,4 +150,115 @@ export class ObservabilityService implements IObservability {
114
150
  if (!this.cursors) return [];
115
151
  return this.cursors.listAll(tenantId);
116
152
  }
153
+
154
+ async listJobRuns(query?: ListJobRunsQuery): Promise<JobRunPage> {
155
+ if (!this.jobRuns) {
156
+ return { ...ObservabilityService.EMPTY_JOB_RUN_PAGE };
157
+ }
158
+ return this.jobRuns.listJobRuns(query);
159
+ }
160
+
161
+ async listEvents(query?: ListEventsQuery): Promise<EventPage> {
162
+ if (!this.events) {
163
+ return { ...ObservabilityService.EMPTY_EVENT_PAGE };
164
+ }
165
+ return this.events.listEvents(query);
166
+ }
167
+
168
+ async getCorrelationTimeline(
169
+ rootRunId: string,
170
+ tenantId?: string | null,
171
+ ): Promise<CorrelationTimeline> {
172
+ const runs = await this.collectRuns(rootRunId, tenantId);
173
+ const events = await this.collectEvents(rootRunId, tenantId);
174
+
175
+ const entries: CorrelationTimelineEntry[] = [
176
+ ...runs.map(
177
+ (run): CorrelationTimelineEntry => ({
178
+ kind: 'job_run',
179
+ at: run.createdAt,
180
+ run,
181
+ }),
182
+ ),
183
+ ...events.map(
184
+ (event): CorrelationTimelineEntry => ({
185
+ kind: 'event',
186
+ at: event.occurredAt,
187
+ event,
188
+ }),
189
+ ),
190
+ ];
191
+
192
+ // Ascending chronological order. Stable tie-break: job runs before
193
+ // events at the same instant (the run that emits an event precedes it).
194
+ entries.sort((a, b) => {
195
+ const dt = a.at.getTime() - b.at.getTime();
196
+ if (dt !== 0) return dt;
197
+ if (a.kind === b.kind) return 0;
198
+ return a.kind === 'job_run' ? -1 : 1;
199
+ });
200
+
201
+ const startedAt = entries.length > 0 ? entries[0]!.at : null;
202
+ const lastActivityAt =
203
+ entries.length > 0 ? entries[entries.length - 1]!.at : null;
204
+
205
+ return {
206
+ rootRunId,
207
+ entries,
208
+ summary: {
209
+ runCount: runs.length,
210
+ eventCount: events.length,
211
+ startedAt,
212
+ lastActivityAt,
213
+ },
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Drain every `job_run` sharing `rootRunId` by walking the keyset cursor.
219
+ * Empty when the jobs subsystem is absent.
220
+ */
221
+ private async collectRuns(
222
+ rootRunId: string,
223
+ tenantId?: string | null,
224
+ ): Promise<JobRunSummary[]> {
225
+ if (!this.jobRuns) return [];
226
+ const out: JobRunSummary[] = [];
227
+ let cursor: string | undefined;
228
+ for (let page = 0; page < MAX_TIMELINE_PAGES; page += 1) {
229
+ const result = await this.jobRuns.listJobRuns({
230
+ rootRunId,
231
+ tenantId,
232
+ cursor,
233
+ });
234
+ out.push(...result.items);
235
+ if (!result.nextCursor) break;
236
+ cursor = result.nextCursor;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /**
242
+ * Drain every `domain_event` whose `metadata.rootRunId` matches by walking
243
+ * the keyset cursor. Empty when the events read port is absent.
244
+ */
245
+ private async collectEvents(
246
+ rootRunId: string,
247
+ tenantId?: string | null,
248
+ ): Promise<EventSummary[]> {
249
+ if (!this.events) return [];
250
+ const out: EventSummary[] = [];
251
+ let cursor: string | undefined;
252
+ for (let page = 0; page < MAX_TIMELINE_PAGES; page += 1) {
253
+ const result = await this.events.listEvents({
254
+ rootRunId,
255
+ tenantId,
256
+ cursor,
257
+ });
258
+ out.push(...result.items);
259
+ if (!result.nextCursor) break;
260
+ cursor = result.nextCursor;
261
+ }
262
+ return out;
263
+ }
117
264
  }
@@ -495,28 +495,30 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
495
495
  function zodChainForCreate(field) {
496
496
  const { type, nullable, required, hasDefault, hasChoices, choices } = field;
497
497
 
498
+ // Apply nullability and optionality INDEPENDENTLY. A nullable column accepts
499
+ // null (`.nullable()`); a field without `required: true` may be omitted from
500
+ // the create payload (`.optional()`). A field that is both gets
501
+ // `.nullable().optional()`. Previously the `nullable` branch returned early,
502
+ // so a nullable-and-optional field never got `.optional()` — forcing callers
503
+ // to send an explicit `null` for every optional column (e.g. POST /accounts
504
+ // rejecting a body that omits `domain`/`industry`).
498
505
  if (hasChoices) {
499
- const base = `z.enum([${choices.map((c) => `'${c}'`).join(', ')}])`;
500
- if (!required && !nullable) return base + '.optional()';
501
- if (nullable) return base + '.nullable()';
506
+ let base = `z.enum([${choices.map((c) => `'${c}'`).join(', ')}])`;
507
+ if (nullable) base += '.nullable()';
508
+ if (!required) base += '.optional()';
502
509
  return base;
503
510
  }
504
511
 
505
512
  let base = ZOD_TYPE_MAP[type] || 'z.unknown()';
506
513
 
507
514
  if (type === 'boolean' && hasDefault) {
515
+ // `.default()` already makes the input optional in Zod.
508
516
  base += `.default(${field.default ?? false})`;
509
517
  return base;
510
518
  }
511
519
 
512
- if (nullable) {
513
- return base + '.nullable()';
514
- }
515
-
516
- if (!required) {
517
- return base + '.optional()';
518
- }
519
-
520
+ if (nullable) base += '.nullable()';
521
+ if (!required) base += '.optional()';
520
522
  return base;
521
523
  }
522
524
 
@@ -1132,7 +1134,7 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1132
1134
  // FK fields from belongs_to for create/output DTOs
1133
1135
  const belongsToFkFields = belongsTo.map((rel) => ({
1134
1136
  camelName: rel.camelField,
1135
- zodChainCreate: rel.nullable ? 'z.string().uuid().nullable()' : 'z.string().uuid()',
1137
+ zodChainCreate: rel.nullable ? 'z.string().uuid().nullable().optional()' : 'z.string().uuid()',
1136
1138
  zodChainOutput: rel.nullable ? 'z.string().uuid().nullable()' : 'z.string().uuid()',
1137
1139
  nullable: rel.nullable,
1138
1140
  }));