@pattern-stack/codegen 0.4.1 → 0.4.2

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 (133) hide show
  1. package/package.json +2 -1
  2. package/runtime/analytics/index.ts +31 -0
  3. package/runtime/analytics/metrics.ts +85 -0
  4. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  5. package/runtime/analytics/packs/index.ts +5 -0
  6. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  7. package/runtime/analytics/specs.ts +54 -0
  8. package/runtime/analytics/types.ts +105 -0
  9. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  10. package/runtime/base-classes/activity-entity-service.ts +48 -0
  11. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  12. package/runtime/base-classes/base-repository.ts +289 -0
  13. package/runtime/base-classes/base-service.ts +183 -0
  14. package/runtime/base-classes/index.ts +38 -0
  15. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  16. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  17. package/runtime/base-classes/lifecycle-events.ts +152 -0
  18. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  19. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  20. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  21. package/runtime/base-classes/synced-entity-service.ts +50 -0
  22. package/runtime/base-classes/with-analytics.ts +22 -0
  23. package/runtime/constants/tokens.ts +29 -0
  24. package/runtime/eav-helpers.ts +74 -0
  25. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  26. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  27. package/runtime/shared/openapi/errors.ts +39 -0
  28. package/runtime/shared/openapi/index.ts +20 -0
  29. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  30. package/runtime/shared/openapi/registry.ts +151 -0
  31. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  32. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  33. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  34. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  35. package/runtime/subsystems/analytics/index.ts +15 -0
  36. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  37. package/runtime/subsystems/auth/auth.module.ts +91 -0
  38. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  39. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  40. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  41. package/runtime/subsystems/auth/index.ts +77 -0
  42. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  43. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  44. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  45. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  46. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  47. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  48. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  49. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  50. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  51. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  52. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  53. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  54. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  55. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  56. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  57. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  58. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  59. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  60. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  61. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  62. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  63. package/runtime/subsystems/bridge/index.ts +84 -0
  64. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  65. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  66. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  67. package/runtime/subsystems/cache/cache.module.ts +115 -0
  68. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  69. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  70. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  71. package/runtime/subsystems/cache/index.ts +22 -0
  72. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  74. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  75. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  76. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  77. package/runtime/subsystems/events/events-errors.ts +30 -0
  78. package/runtime/subsystems/events/events.module.ts +230 -0
  79. package/runtime/subsystems/events/events.tokens.ts +62 -0
  80. package/runtime/subsystems/events/generated/bus.ts +103 -0
  81. package/runtime/subsystems/events/generated/index.ts +7 -0
  82. package/runtime/subsystems/events/generated/registry.ts +84 -0
  83. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  84. package/runtime/subsystems/events/generated/types.ts +94 -0
  85. package/runtime/subsystems/events/index.ts +21 -0
  86. package/runtime/subsystems/index.ts +63 -0
  87. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  88. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  89. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  90. package/runtime/subsystems/jobs/index.ts +120 -0
  91. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  92. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  93. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  94. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  95. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  96. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  97. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  98. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  99. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  100. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  101. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  102. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  103. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  104. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  105. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  106. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  107. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  108. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  109. package/runtime/subsystems/storage/index.ts +18 -0
  110. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  111. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  112. package/runtime/subsystems/storage/storage.module.ts +60 -0
  113. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  114. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  115. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  116. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  117. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  118. package/runtime/subsystems/sync/index.ts +98 -0
  119. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  120. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  121. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  122. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  123. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  124. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  125. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  126. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  127. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  128. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  129. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  130. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  131. package/runtime/subsystems/sync/sync.module.ts +156 -0
  132. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  133. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * MemoryJobStepService — in-memory implementation of `IJobStepService`
3
+ * (ADR-022, JOB-4).
4
+ *
5
+ * Mirrors `DrizzleJobStepService` but against plain Maps. `findStep` only
6
+ * returns `completed` rows (memoization cache hit); anything non-completed
7
+ * is invisible so the ctx.step fn re-runs on replay / retry exactly like
8
+ * the Drizzle backend (which deletes non-completed rows on replay).
9
+ */
10
+ import { randomUUID } from 'node:crypto';
11
+ import { Injectable } from '@nestjs/common';
12
+ import type { JobStepRow } from './job-orchestration.schema';
13
+ import type {
14
+ IJobStepService,
15
+ JobStep,
16
+ RecordStepInput,
17
+ } from './job-step-service.protocol';
18
+ import { MemoryJobStore } from './memory-job-store';
19
+
20
+ @Injectable()
21
+ export class MemoryJobStepService implements IJobStepService {
22
+ constructor(private readonly store: MemoryJobStore) {}
23
+
24
+ async findStep(runId: string, stepId: string): Promise<JobStep | null> {
25
+ const rows = this.store.steps.get(runId);
26
+ if (!rows) return null;
27
+ const match = rows.find(
28
+ (r) => r.stepId === stepId && r.status === 'completed',
29
+ );
30
+ return (match ?? null) as JobStep | null;
31
+ }
32
+
33
+ async recordStep(input: RecordStepInput): Promise<JobStep> {
34
+ const rows = this.getOrCreateRows(input.jobRunId);
35
+ const existingIdx = rows.findIndex((r) => r.stepId === input.stepId);
36
+
37
+ const normalisedInput =
38
+ (input.input ?? null) as Record<string, unknown> | null;
39
+ const normalisedOutput =
40
+ (input.output ?? null) as Record<string, unknown> | null;
41
+
42
+ if (existingIdx >= 0) {
43
+ const prev = rows[existingIdx]!;
44
+ const next: JobStepRow = {
45
+ ...prev,
46
+ status: input.status,
47
+ input: normalisedInput ?? prev.input,
48
+ output: normalisedOutput ?? prev.output,
49
+ error: input.error ?? prev.error,
50
+ attempts: input.attempts ?? prev.attempts,
51
+ startedAt: input.startedAt ?? prev.startedAt,
52
+ finishedAt: input.finishedAt ?? prev.finishedAt,
53
+ };
54
+ rows[existingIdx] = next;
55
+ return next as JobStep;
56
+ }
57
+
58
+ const seq = input.seq ?? this.nextSeq(rows);
59
+ const row: JobStepRow = {
60
+ id: randomUUID(),
61
+ jobRunId: input.jobRunId,
62
+ stepId: input.stepId,
63
+ kind: input.kind,
64
+ seq,
65
+ status: input.status,
66
+ input: normalisedInput,
67
+ output: normalisedOutput,
68
+ error: input.error ?? null,
69
+ attempts: input.attempts ?? 0,
70
+ startedAt: input.startedAt ?? null,
71
+ finishedAt: input.finishedAt ?? null,
72
+ };
73
+ rows.push(row);
74
+ return row as JobStep;
75
+ }
76
+
77
+ /**
78
+ * Replay helper — wipe every step row for a run. Mirrors the `scratch`
79
+ * replay mode of the Drizzle backend (`DELETE FROM job_step WHERE job_run_id = …`).
80
+ */
81
+ clearStepsForRun(runId: string): void {
82
+ this.store.steps.delete(runId);
83
+ }
84
+
85
+ /**
86
+ * Remove every non-`completed` row for the run. Memoized (`completed`)
87
+ * rows are preserved — this is the `last_checkpoint` / `last_step`
88
+ * semantics the Drizzle backend implements via
89
+ * `DELETE … WHERE status != 'completed'`. Both replay modes route here
90
+ * (Phase 1 collapses `last_step` onto this behaviour; see JOB-3 notes).
91
+ */
92
+ clearIncompleteSteps(runId: string): void {
93
+ const rows = this.store.steps.get(runId);
94
+ if (!rows) return;
95
+ const kept = rows.filter((r) => r.status === 'completed');
96
+ if (kept.length === 0) {
97
+ this.store.steps.delete(runId);
98
+ } else {
99
+ this.store.steps.set(runId, kept);
100
+ }
101
+ }
102
+
103
+ private getOrCreateRows(runId: string): JobStepRow[] {
104
+ let rows = this.store.steps.get(runId);
105
+ if (!rows) {
106
+ rows = [];
107
+ this.store.steps.set(runId, rows);
108
+ }
109
+ return rows;
110
+ }
111
+
112
+ private nextSeq(rows: JobStepRow[]): number {
113
+ let max = 0;
114
+ for (const r of rows) {
115
+ if (r.seq > max) max = r.seq;
116
+ }
117
+ return max + 1;
118
+ }
119
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * IJobStepService — record and fetch `job_step` rows for step-level
3
+ * memoization and replay (ADR-022, JOB-2).
4
+ *
5
+ * `ctx.step(id, fn)` in `JobHandlerBase` goes through this service:
6
+ * check for an existing `completed` row (memo hit, return `output`),
7
+ * otherwise record `running`, `await fn()`, terminal-state the row.
8
+ */
9
+ import type { JobStepRow } from './job-orchestration.schema';
10
+
11
+ export type JobStep = JobStepRow;
12
+
13
+ export interface RecordStepInput {
14
+ jobRunId: string;
15
+ stepId: string;
16
+ /**
17
+ * `'task'` is the only value in `jobStepKindEnum` today; ADR-027 widens
18
+ * to include `tool_call | llm_call | wait | checkpoint | message`. The
19
+ * literal here intentionally mirrors the enum tuple in JOB-1.
20
+ */
21
+ kind: 'task';
22
+ seq: number;
23
+ input?: unknown;
24
+ output?: unknown;
25
+ error?: {
26
+ message: string;
27
+ stack?: string;
28
+ retryable: boolean;
29
+ attempt: number;
30
+ };
31
+ /**
32
+ * Must match the `jobStepStatusEnum` value tuple from JOB-1. Kept as a
33
+ * literal union here rather than `typeof jobStepStatusEnum.enumValues[number]`
34
+ * so protocol consumers don't need to import the schema module.
35
+ */
36
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
37
+ startedAt?: Date;
38
+ finishedAt?: Date;
39
+ attempts?: number;
40
+ }
41
+
42
+ export interface IJobStepService {
43
+ /**
44
+ * Insert or update a `job_step` row. Backend implementations upsert on
45
+ * `(job_run_id, step_id)` — the `idx_job_step_run_step` unique index.
46
+ */
47
+ recordStep(input: RecordStepInput): Promise<JobStep>;
48
+
49
+ /**
50
+ * Lookup for memoization. Returns `null` when no prior row exists.
51
+ */
52
+ findStep(runId: string, stepId: string): Promise<JobStep | null>;
53
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * JobWorkerModule — `DynamicModule.forRoot({ mode, pools? })` factory that
3
+ * boots one `JobWorker` per active pool and runs the boot-time validator
4
+ * (Drizzle only) (ADR-022, JOB-5).
5
+ *
6
+ * Imports `JobsDomainModule` internally so call sites only need to add
7
+ * `JobWorkerModule.forRoot(...)` to `AppModule.imports` — the protocol
8
+ * tokens become available transitively via `global: true`.
9
+ *
10
+ * Lifecycle (`onModuleInit`, **order-critical** per JOB-5 spec):
11
+ * 1. `loadPoolConfig()` → resolved `PoolConfig`
12
+ * 2. `HandlerRegistry.getAll()` → registered entries
13
+ * 3. Reserved-pool validation → throws `ReservedPoolViolationError`
14
+ * 4. `orchestrator.upsertJobRows(entries, …)` → persist `job` definitions
15
+ * 5. Boot validator (Drizzle only) → throws `BootValidationError`
16
+ * (skipped entirely in memory mode — Q4 resolution 2026-04-19)
17
+ * 6. Spawn one `JobWorker` per active pool → start polling loops
18
+ *
19
+ * `onModuleDestroy` calls `gracefulStop` on each worker (drains in-flight,
20
+ * resets `running` rows, removes SIGTERM handler).
21
+ */
22
+ import {
23
+ Inject,
24
+ Injectable,
25
+ Logger,
26
+ Module,
27
+ Optional,
28
+ type DynamicModule,
29
+ type OnModuleDestroy,
30
+ type OnModuleInit,
31
+ } from '@nestjs/common';
32
+ import { DRIZZLE } from '../../constants/tokens';
33
+ import type { DrizzleClient } from '../../types/drizzle';
34
+ import { HandlerRegistry, type HandlerRegistryEntry } from './job-handler.base';
35
+ import {
36
+ JobsDomainModule,
37
+ type JobsDomainModuleOptions,
38
+ } from './jobs-domain.module';
39
+ import {
40
+ JOB_ORCHESTRATOR,
41
+ JOB_RUN_SERVICE,
42
+ JOB_STEP_SERVICE,
43
+ } from './jobs-domain.tokens';
44
+ import type { IJobOrchestrator } from './job-orchestrator.protocol';
45
+ import type { IJobRunService } from './job-run-service.protocol';
46
+ import type { IJobStepService } from './job-step-service.protocol';
47
+ import {
48
+ allNonReservedPoolNames,
49
+ loadPoolConfig,
50
+ type PoolConfig,
51
+ } from './pool-config.loader';
52
+ import { JobWorker, type JobWorkerOptions } from './job-worker';
53
+ import {
54
+ BootValidationError,
55
+ ReservedPoolViolationError,
56
+ } from './jobs-errors';
57
+
58
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
59
+
60
+ export interface JobWorkerModuleOptions {
61
+ mode: 'embedded' | 'standalone';
62
+ /**
63
+ * Threads into the internal `JobsDomainModule.forRoot({ backend })`
64
+ * import. Default `'drizzle'`. The boot-time validator runs only when
65
+ * this is `'drizzle'`.
66
+ */
67
+ backend?: 'drizzle' | 'memory';
68
+ /**
69
+ * Active pool names. Defaults to every non-reserved pool in the resolved
70
+ * config (i.e. `interactive`, `batch`, plus any user-defined pools).
71
+ * Operators reduce this to one or two pools per worker process to scale
72
+ * horizontally.
73
+ */
74
+ pools?: string[];
75
+ /** SIGTERM drain budget. Default 30_000 ms. */
76
+ shutdownTimeoutMs?: number;
77
+ /**
78
+ * Test-only — point the pool config loader at a specific YAML file.
79
+ * Production code reads `${process.cwd()}/codegen.config.yaml`.
80
+ */
81
+ configPath?: string;
82
+ /**
83
+ * Forwarded into the inner `JobsDomainModule.forRoot()` call so the
84
+ * worker module's caller can configure backend extensions in one place.
85
+ */
86
+ domainModuleExtensions?: JobsDomainModuleOptions['extensions'];
87
+ /** Forwarded into `JobsDomainModule.forRoot()`. JOB-8 wires this. */
88
+ multiTenant?: boolean;
89
+ /**
90
+ * Test-only escape hatch — when set, the module uses this factory
91
+ * instead of `new JobWorker(...)` so unit tests can stub the worker
92
+ * without spinning up the polling loop.
93
+ */
94
+ workerFactory?: (options: JobWorkerOptions) => Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>;
95
+ }
96
+
97
+ /**
98
+ * DI token for the resolved `JobWorkerModuleOptions`. Exported so other
99
+ * subsystems can inject it `@Optional()` and inspect the active
100
+ * configuration — e.g. `BridgeModule.onModuleInit` checks
101
+ * `options.pools` against `BRIDGE_RESERVED_POOLS` to fail fast when a
102
+ * reserved pool isn't being polled (BRIDGE-8).
103
+ */
104
+ export const JOB_WORKER_MODULE_OPTIONS = Symbol('JOB_WORKER_MODULE_OPTIONS');
105
+
106
+ /**
107
+ * The lifecycle holder. Named `JobWorkerOrchestrator` in the spec to avoid
108
+ * collision with `JobWorker` and `IJobOrchestrator`. Registered as a
109
+ * provider on `JobWorkerModule`; Nest invokes `onModuleInit` /
110
+ * `onModuleDestroy` automatically.
111
+ */
112
+ @Injectable()
113
+ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
114
+ private readonly logger = new Logger(JobWorkerOrchestrator.name);
115
+ private readonly workers: Array<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> = [];
116
+
117
+ constructor(
118
+ @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,
119
+ @Inject(JOB_RUN_SERVICE) private readonly runService: IJobRunService,
120
+ @Inject(JOB_STEP_SERVICE) private readonly stepService: IJobStepService,
121
+ @Inject(JOB_WORKER_MODULE_OPTIONS)
122
+ private readonly options: JobWorkerModuleOptions,
123
+ /**
124
+ * Drizzle client is only required when `backend === 'drizzle'`. Made
125
+ * `@Optional()` so memory-mode boots in `Test.createTestingModule`
126
+ * without supplying a `DRIZZLE` provider.
127
+ */
128
+ @Optional() @Inject(DRIZZLE) private readonly db: DrizzleClient | null = null,
129
+ ) {}
130
+
131
+ // ============================================================================
132
+ // Lifecycle
133
+ // ============================================================================
134
+
135
+ async onModuleInit(): Promise<void> {
136
+ const backend = this.options.backend ?? 'drizzle';
137
+
138
+ // (1) Pool config first — every later step needs the resolved map.
139
+ const poolConfig = loadPoolConfig(this.options.configPath);
140
+
141
+ // (2) Snapshot the registry. Decorators run at class-load time so the
142
+ // map is fully populated before any module init fires.
143
+ const entries = HandlerRegistry.getAll();
144
+
145
+ // (3) Reserved-pool validation BEFORE the upsert. Persisting a
146
+ // reserved-pool handler row would leave the DB in a bad state for
147
+ // the next boot to clean up.
148
+ this.assertNoReservedPoolHandlers(entries, poolConfig);
149
+
150
+ // (4) Upsert `job` definitions. Drizzle: hash-gated `ON CONFLICT DO
151
+ // UPDATE`. Memory: populates `MemoryJobStore.jobs` + handler-class
152
+ // registry.
153
+ const { orphaned } = await this.orchestrator.upsertJobRows(
154
+ entries,
155
+ poolConfig,
156
+ );
157
+
158
+ // (5) Boot validator — Drizzle only. Memory mode never has DB rows
159
+ // to validate (Q4 resolution 2026-04-19); the equivalent
160
+ // protection is `MemoryJobOrchestrator.start()` throwing
161
+ // `JobTypeNotFoundError` synchronously for unknown types.
162
+ if (backend !== 'memory' && orphaned.length > 0) {
163
+ throw new BootValidationError(orphaned);
164
+ }
165
+
166
+ // (6) Resolve active pool list and spawn one worker per pool.
167
+ const activePools =
168
+ this.options.pools ?? allNonReservedPoolNames(poolConfig);
169
+
170
+ for (const poolName of activePools) {
171
+ const def = poolConfig.get(poolName);
172
+ if (!def) {
173
+ throw new Error(
174
+ `JobWorkerModule: active pool '${poolName}' is not defined in ` +
175
+ `the resolved pool config. Configured pools: [${[...poolConfig.keys()].join(', ')}].`,
176
+ );
177
+ }
178
+ const workerOptions: JobWorkerOptions = {
179
+ pool: def.queue,
180
+ concurrency: def.concurrency,
181
+ shutdownTimeoutMs:
182
+ this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
183
+ };
184
+ const worker = this.options.workerFactory
185
+ ? this.options.workerFactory(workerOptions)
186
+ : this.spawnWorker(workerOptions);
187
+ // `JobWorker` extends Nest's lifecycle hooks but the worker isn't
188
+ // a Nest provider here (we manage the array ourselves). Call
189
+ // `onModuleInit` synchronously to start the polling loop.
190
+ worker.onModuleInit();
191
+ this.workers.push(worker);
192
+ this.logger.log(
193
+ `JobWorker started: pool='${def.queue}' concurrency=${def.concurrency}`,
194
+ );
195
+ }
196
+ }
197
+
198
+ async onModuleDestroy(): Promise<void> {
199
+ // Tear down in reverse order so the most recently started worker
200
+ // drains first — keeps the SIGTERM handler graph predictable.
201
+ for (let i = this.workers.length - 1; i >= 0; i--) {
202
+ const worker = this.workers[i];
203
+ if (!worker) continue;
204
+ try {
205
+ await worker.onModuleDestroy();
206
+ } catch (err) {
207
+ this.logger.error(
208
+ `JobWorker shutdown failed: ${(err as Error).message}`,
209
+ );
210
+ }
211
+ }
212
+ this.workers.length = 0;
213
+ }
214
+
215
+ // ============================================================================
216
+ // Internals
217
+ // ============================================================================
218
+
219
+ /**
220
+ * Walk every registered handler; collect any whose declared `pool`
221
+ * targets a reserved pool from the resolved config. If non-empty,
222
+ * throw `ReservedPoolViolationError` with the offender list so the
223
+ * operator sees every violating class on a single boot.
224
+ */
225
+ private assertNoReservedPoolHandlers(
226
+ entries: HandlerRegistryEntry[],
227
+ poolConfig: PoolConfig,
228
+ ): void {
229
+ const offenders: Array<{ handlerClass: string; pool: string }> = [];
230
+ for (const entry of entries) {
231
+ // Framework-owned handlers (`@framework/*` job types) are allowed in
232
+ // reserved pools — that is in fact the entire point of the reserved
233
+ // `events_*` pools (ADR-022 + ADR-023). The reserved-pool guard
234
+ // exists to keep USER handlers out, not the framework's own
235
+ // bridge-delivery handler. BRIDGE-5 introduced this exemption.
236
+ if (entry.type.startsWith('@framework/')) continue;
237
+ const declaredPool = entry.meta.pool ?? 'batch';
238
+ const def = poolConfig.get(declaredPool);
239
+ if (def?.reserved) {
240
+ offenders.push({
241
+ handlerClass: entry.handlerClass.name,
242
+ pool: declaredPool,
243
+ });
244
+ }
245
+ }
246
+ if (offenders.length > 0) {
247
+ throw new ReservedPoolViolationError(offenders);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Production worker spawn. `JobWorker` requires `DRIZZLE` so this only
253
+ * succeeds when the module was booted with `backend: 'drizzle'`. Memory
254
+ * mode tests must supply `workerFactory` — the memory backend has no
255
+ * polling loop equivalent (`MemoryJobOrchestrator` is direct-invocation
256
+ * only).
257
+ *
258
+ * We instantiate outside the Nest container because the module spawns
259
+ * N workers from a single options shape, which doesn't fit Nest's
260
+ * "one provider per token" model. The dependencies are passed
261
+ * positionally; the constructor's `@Inject` decorators are unused on
262
+ * this path (Nest still uses them when `JobWorker` is a provider — e.g.
263
+ * in JOB-6's standalone `worker.ts` entrypoint).
264
+ */
265
+ private spawnWorker(workerOptions: JobWorkerOptions): JobWorker {
266
+ if (!this.db) {
267
+ throw new Error(
268
+ `JobWorkerModule: in-process worker spawning requires the Drizzle ` +
269
+ `backend (no DRIZZLE provider available). Memory-mode tests must ` +
270
+ `pass 'workerFactory' to inject a stub.`,
271
+ );
272
+ }
273
+ return new JobWorker(
274
+ this.db,
275
+ this.orchestrator,
276
+ this.runService,
277
+ this.stepService,
278
+ workerOptions,
279
+ );
280
+ }
281
+ }
282
+
283
+ @Module({})
284
+ export class JobWorkerModule {
285
+ static forRoot(opts: JobWorkerModuleOptions): DynamicModule {
286
+ return {
287
+ module: JobWorkerModule,
288
+ imports: [
289
+ JobsDomainModule.forRoot({
290
+ backend: opts.backend ?? 'drizzle',
291
+ extensions: opts.domainModuleExtensions,
292
+ multiTenant: opts.multiTenant,
293
+ }),
294
+ ],
295
+ providers: [
296
+ { provide: JOB_WORKER_MODULE_OPTIONS, useValue: opts },
297
+ JobWorkerOrchestrator,
298
+ ],
299
+ exports: [],
300
+ };
301
+ }
302
+ }