@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.
- package/package.json +2 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
- package/runtime/subsystems/jobs/job-worker.ts +615 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- 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
|
+
}
|