@pattern-stack/codegen 0.4.1 → 0.4.3

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 (158) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
  3. package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
  4. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  5. package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
  6. package/dist/runtime/subsystems/bridge/index.js +29 -12
  7. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  8. package/dist/runtime/subsystems/index.js +31 -14
  9. package/dist/runtime/subsystems/index.js.map +1 -1
  10. package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
  11. package/dist/runtime/subsystems/jobs/index.js +27 -10
  12. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  13. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
  14. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
  15. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
  16. package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
  17. package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
  18. package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
  19. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
  20. package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
  21. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  22. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
  23. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  24. package/dist/src/cli/index.js +29 -2
  25. package/dist/src/cli/index.js.map +1 -1
  26. package/package.json +2 -1
  27. package/runtime/analytics/index.ts +31 -0
  28. package/runtime/analytics/metrics.ts +85 -0
  29. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  30. package/runtime/analytics/packs/index.ts +5 -0
  31. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  32. package/runtime/analytics/specs.ts +54 -0
  33. package/runtime/analytics/types.ts +105 -0
  34. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  35. package/runtime/base-classes/activity-entity-service.ts +48 -0
  36. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  37. package/runtime/base-classes/base-repository.ts +289 -0
  38. package/runtime/base-classes/base-service.ts +183 -0
  39. package/runtime/base-classes/index.ts +38 -0
  40. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  41. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  42. package/runtime/base-classes/lifecycle-events.ts +152 -0
  43. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  44. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  45. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  46. package/runtime/base-classes/synced-entity-service.ts +50 -0
  47. package/runtime/base-classes/with-analytics.ts +22 -0
  48. package/runtime/constants/tokens.ts +29 -0
  49. package/runtime/eav-helpers.ts +74 -0
  50. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  51. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  52. package/runtime/shared/openapi/errors.ts +39 -0
  53. package/runtime/shared/openapi/index.ts +20 -0
  54. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  55. package/runtime/shared/openapi/registry.ts +151 -0
  56. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  57. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  58. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  59. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  60. package/runtime/subsystems/analytics/index.ts +15 -0
  61. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  62. package/runtime/subsystems/auth/auth.module.ts +91 -0
  63. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  64. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  65. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  66. package/runtime/subsystems/auth/index.ts +77 -0
  67. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  68. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  69. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  70. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  71. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  72. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  73. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  74. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  75. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  76. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  77. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  78. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  79. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  80. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  81. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  82. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  83. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  84. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  85. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  86. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  87. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  88. package/runtime/subsystems/bridge/index.ts +84 -0
  89. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  90. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  91. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  92. package/runtime/subsystems/cache/cache.module.ts +115 -0
  93. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  94. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  95. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  96. package/runtime/subsystems/cache/index.ts +22 -0
  97. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  98. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  99. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  100. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  101. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  102. package/runtime/subsystems/events/events-errors.ts +30 -0
  103. package/runtime/subsystems/events/events.module.ts +230 -0
  104. package/runtime/subsystems/events/events.tokens.ts +62 -0
  105. package/runtime/subsystems/events/generated/bus.ts +103 -0
  106. package/runtime/subsystems/events/generated/index.ts +7 -0
  107. package/runtime/subsystems/events/generated/registry.ts +84 -0
  108. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  109. package/runtime/subsystems/events/generated/types.ts +94 -0
  110. package/runtime/subsystems/events/index.ts +21 -0
  111. package/runtime/subsystems/index.ts +63 -0
  112. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  113. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  114. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  115. package/runtime/subsystems/jobs/index.ts +120 -0
  116. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  117. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  118. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  119. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
  120. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  121. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  122. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  123. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  124. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  125. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  126. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  127. package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
  128. package/runtime/subsystems/jobs/job-worker.ts +624 -0
  129. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  130. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  131. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  132. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  133. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  134. package/runtime/subsystems/storage/index.ts +18 -0
  135. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  136. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  137. package/runtime/subsystems/storage/storage.module.ts +60 -0
  138. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  139. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  140. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  141. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  142. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  143. package/runtime/subsystems/sync/index.ts +98 -0
  144. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  145. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  146. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  147. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  148. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  149. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  150. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  151. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  152. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  153. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  154. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  155. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  156. package/runtime/subsystems/sync/sync.module.ts +156 -0
  157. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  158. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,860 @@
1
+ /**
2
+ * MemoryJobOrchestrator — in-process implementation of `IJobOrchestrator`
3
+ * (ADR-022, JOB-4).
4
+ *
5
+ * Exists solely for the unit test suite: reproduces the Drizzle backend's
6
+ * observable behaviour (claim ordering, collision modes, dedupe collapse,
7
+ * memoization cache, replay row-clearing, cascade cancel) without a
8
+ * database. Not production — the single-process mutex is a substitute for
9
+ * Postgres' `FOR UPDATE SKIP LOCKED`; acceptable non-parity is listed in
10
+ * `docs/specs/JOB-4.md` (fsync, query perf, multi-process claim).
11
+ *
12
+ * The `MemoryJobStore` is shared with `MemoryJobRunService` /
13
+ * `MemoryJobStepService` — all three services mutate the same Maps under
14
+ * the orchestrator's mutex.
15
+ */
16
+ import { randomUUID } from 'node:crypto';
17
+ import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
18
+ import { ModuleRef } from '@nestjs/core';
19
+ import type {
20
+ JobDefinitionRow,
21
+ JobRunRow,
22
+ } from './job-orchestration.schema';
23
+ import type {
24
+ CancelOptions,
25
+ IJobOrchestrator,
26
+ JobPoolDef,
27
+ JobRun,
28
+ JobUpsertEntry,
29
+ StartOptions,
30
+ } from './job-orchestrator.protocol';
31
+ import type {
32
+ JobContext,
33
+ JobHandlerBase,
34
+ JobHandlerMeta,
35
+ RetryPolicy,
36
+ SpawnChildOptions,
37
+ StepOptions,
38
+ } from './job-handler.base';
39
+ import { ParentClosePolicy } from './job-handler.base';
40
+ import {
41
+ JobCollisionError,
42
+ JobNotReplayableError,
43
+ JobTemplateFieldMissingError,
44
+ JobTypeNotFoundError,
45
+ MissingTenantIdError,
46
+ } from './jobs-errors';
47
+ import { MemoryJobStore } from './memory-job-store';
48
+ import { MemoryJobStepService } from './job-step-service.memory-backend';
49
+ import { JOBS_MULTI_TENANT } from './jobs-domain.tokens';
50
+
51
+ /**
52
+ * Sentinel `run_at` for runs that lost the `queue` collision — they stay
53
+ * unclaimable until the incumbent transitions terminal and the orchestrator
54
+ * advances their `run_at` back to `now()`. Mirrors the Drizzle backend's
55
+ * `claim-time gate` behaviour without requiring a separate claim query.
56
+ */
57
+ const QUEUED_RUN_AT = new Date(8_640_000_000_000_000); // "distant future"
58
+ const TERMINAL_STATUSES: JobRunRow['status'][] = [
59
+ 'completed',
60
+ 'failed',
61
+ 'timed_out',
62
+ 'canceled',
63
+ ];
64
+ const DEDUPE_EXCLUDED_STATUSES: JobRunRow['status'][] = ['canceled', 'failed'];
65
+ const IN_FLIGHT_STATUSES: JobRunRow['status'][] = ['pending', 'running'];
66
+
67
+ function isTerminal(status: JobRunRow['status']): boolean {
68
+ return TERMINAL_STATUSES.includes(status);
69
+ }
70
+
71
+ /**
72
+ * Mirror of `evaluateKeyTemplate` in the Drizzle backend. Kept private here
73
+ * rather than exported so the memory backend has no dependency on the
74
+ * Drizzle module.
75
+ */
76
+ function evaluateKeyTemplate(
77
+ template: string,
78
+ input: Record<string, unknown>,
79
+ ): string {
80
+ return template.replace(
81
+ /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,
82
+ (_m, field: string) => {
83
+ const value = input[field];
84
+ if (value === undefined || value === null) {
85
+ throw new JobTemplateFieldMissingError(template, field);
86
+ }
87
+ return String(value);
88
+ },
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Single-promise-chain mutex. Every mutating op on the store goes through
94
+ * `run(...)` so two concurrent `start` calls observe the same sequential
95
+ * consistency Postgres gives us via `FOR UPDATE SKIP LOCKED`. Error
96
+ * swallowing on the chain pointer prevents one failed call from poisoning
97
+ * the queue for subsequent callers.
98
+ *
99
+ * Kept private to this file on purpose — the spec explicitly forbids
100
+ * exporting this; it exists only for the memory backend's internal
101
+ * serialisation.
102
+ */
103
+ class PromiseMutex {
104
+ private queue: Promise<void> = Promise.resolve();
105
+
106
+ async run<T>(fn: () => Promise<T>): Promise<T> {
107
+ const next = this.queue.then(() => fn());
108
+ // Swallow errors on the chain pointer so a throwing `fn` doesn't
109
+ // permanently reject every future caller.
110
+ this.queue = next.then(
111
+ () => undefined,
112
+ () => undefined,
113
+ );
114
+ return next;
115
+ }
116
+ }
117
+
118
+ /** Handler registry entry — class + frozen metadata. */
119
+ interface HandlerRegistration {
120
+ type: string;
121
+ meta: JobHandlerMeta<unknown>;
122
+ handlerClass: new (...args: unknown[]) => JobHandlerBase<unknown>;
123
+ }
124
+
125
+ @Injectable()
126
+ export class MemoryJobOrchestrator implements IJobOrchestrator {
127
+ private readonly logger = new Logger(MemoryJobOrchestrator.name);
128
+ private readonly mutex = new PromiseMutex();
129
+ private readonly handlerRegistry = new Map<string, HandlerRegistration>();
130
+
131
+ /**
132
+ * `runId → dependent runId[]` — when a run with `concurrencyKey = K`
133
+ * blocks on an incumbent, its id is added here under the incumbent's id.
134
+ * On incumbent terminal transition we advance every dependent's `runAt`
135
+ * back to `now()` so it becomes claimable.
136
+ */
137
+ private readonly queueBlockers = new Map<string, string[]>();
138
+
139
+ constructor(
140
+ private readonly store: MemoryJobStore,
141
+ private readonly stepService: MemoryJobStepService,
142
+ @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
143
+ @Optional() private readonly moduleRef?: ModuleRef,
144
+ ) {}
145
+
146
+ /**
147
+ * JOB-8 — mirror of the Drizzle backend's `resolveTenantId`. Returns the
148
+ * value to stamp on `tenant_id` / compare against in memory predicates.
149
+ * Off → always `null`. On + `undefined` → throw. On + `null`/string → pass.
150
+ */
151
+ private resolveTenantId(
152
+ method: string,
153
+ tenantId: string | null | undefined,
154
+ ): string | null {
155
+ if (!this.multiTenant) return null;
156
+ if (tenantId === undefined) throw new MissingTenantIdError(method);
157
+ return tenantId;
158
+ }
159
+
160
+ // ==========================================================================
161
+ // registerHandler — replaces Drizzle's `job` table upsert
162
+ // ==========================================================================
163
+
164
+ /**
165
+ * Populate the in-memory job definition row plus handler class lookup.
166
+ * Called by `JobWorkerModule.onModuleInit` in memory mode, or directly by
167
+ * unit tests that want to seed the registry without NestJS.
168
+ */
169
+ registerHandler<TInput>(
170
+ type: string,
171
+ meta: JobHandlerMeta<TInput>,
172
+ handlerClass: new (...args: unknown[]) => JobHandlerBase<TInput>,
173
+ ): void {
174
+ const concurrencyKeyTemplate =
175
+ (meta.concurrency as { key?: string } | undefined)?.key ?? null;
176
+ const dedupeKeyTemplate =
177
+ (meta.dedupe as { key?: string } | undefined)?.key ?? null;
178
+ const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
179
+ const now = new Date();
180
+
181
+ const def: JobDefinitionRow = {
182
+ type,
183
+ version: 1,
184
+ pool: meta.pool ?? 'batch',
185
+ scopeEntityType: meta.scope?.entity ?? null,
186
+ retryPolicy: meta.retry ?? {
187
+ attempts: 1,
188
+ backoff: 'fixed',
189
+ baseMs: 0,
190
+ },
191
+ timeoutMs: meta.timeoutMs ?? null,
192
+ concurrencyKeyTemplate:
193
+ typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null,
194
+ collisionMode:
195
+ (meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
196
+ 'queue',
197
+ dedupeKeyTemplate:
198
+ typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null,
199
+ dedupeWindowMs,
200
+ priorityDefault: 0,
201
+ replayFrom: meta.replayFrom ?? 'last_checkpoint',
202
+ createdAt: now,
203
+ updatedAt: now,
204
+ };
205
+
206
+ this.store.jobs.set(type, def);
207
+ this.handlerRegistry.set(type, {
208
+ type,
209
+ meta: meta as JobHandlerMeta<unknown>,
210
+ handlerClass: handlerClass as unknown as new (
211
+ ...args: unknown[]
212
+ ) => JobHandlerBase<unknown>,
213
+ });
214
+ }
215
+
216
+ /** Test helper — look up a registered handler without exposing the map. */
217
+ getHandlerRegistration(type: string): HandlerRegistration | undefined {
218
+ return this.handlerRegistry.get(type);
219
+ }
220
+
221
+ /**
222
+ * Boot-time upsert per `IJobOrchestrator.upsertJobRows`. Memory backend
223
+ * just funnels each entry through `registerHandler`. The validator is
224
+ * skipped entirely in memory mode (Q4 resolution 2026-04-19), so the
225
+ * orphaned list is always empty — there are no DB rows to compare against.
226
+ */
227
+ async upsertJobRows(
228
+ entries: JobUpsertEntry[],
229
+ poolConfig: ReadonlyMap<string, JobPoolDef>,
230
+ ): Promise<{ orphaned: string[] }> {
231
+ void poolConfig; // pool validation is the module's responsibility
232
+ for (const entry of entries) {
233
+ this.registerHandler(
234
+ entry.type,
235
+ entry.meta as JobHandlerMeta<unknown>,
236
+ entry.handlerClass as new (...args: unknown[]) => JobHandlerBase<unknown>,
237
+ );
238
+ }
239
+ return { orphaned: [] };
240
+ }
241
+
242
+ // ==========================================================================
243
+ // start
244
+ // ==========================================================================
245
+
246
+ async start(
247
+ type: string,
248
+ input: unknown,
249
+ opts: StartOptions = {},
250
+ // BRIDGE-7: signature parity with Drizzle backend. The memory backend
251
+ // has no real transactions (its "atomic" boundary is a process-wide
252
+ // mutex acquired by the body below), so the parameter is intentionally
253
+ // ignored. Accepting it lets EventFlowService unit tests exercise the
254
+ // same code path without two stub orchestrators.
255
+ _tx?: unknown,
256
+ ): Promise<JobRun> {
257
+ // JOB-8 — resolve tenant gate outside the mutex so the error throws
258
+ // synchronously-ish from the caller's stack rather than via the mutex's
259
+ // deferred chain (matches Drizzle backend's pre-transaction guard).
260
+ const tenantId = this.resolveTenantId('start', opts.tenantId);
261
+
262
+ return this.mutex.run(async () => {
263
+ const payload = (input ?? {}) as Record<string, unknown>;
264
+ const definition = this.store.jobs.get(type);
265
+ if (!definition) throw new JobTypeNotFoundError(type);
266
+
267
+ // 1. Dedupe — return existing non-excluded run within the window.
268
+ if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
269
+ const dedupeKey = evaluateKeyTemplate(
270
+ definition.dedupeKeyTemplate,
271
+ payload,
272
+ );
273
+ const windowStart = Date.now() - definition.dedupeWindowMs;
274
+ const existing = this.findDedupeCandidate(type, dedupeKey, windowStart);
275
+ if (existing) return existing;
276
+ }
277
+
278
+ // 2. Concurrency collision check.
279
+ let concurrencyKey: string | null = null;
280
+ let queueBlockedBy: string | null = null;
281
+ if (definition.concurrencyKeyTemplate) {
282
+ concurrencyKey = evaluateKeyTemplate(
283
+ definition.concurrencyKeyTemplate,
284
+ payload,
285
+ );
286
+ const incumbent = this.findInFlightByConcurrencyKey(concurrencyKey);
287
+ if (incumbent) {
288
+ switch (definition.collisionMode) {
289
+ case 'reject':
290
+ throw new JobCollisionError(type, concurrencyKey, incumbent);
291
+ case 'replace':
292
+ // Cancel incumbent (cascading children). Must happen inside
293
+ // the mutex — call the internal helper, not public `cancel()`
294
+ // (public `cancel` would re-enter the mutex and deadlock).
295
+ // Internal replace path sidesteps the tenant gate — it uses
296
+ // the incumbent's own tenant (same concurrency key implies
297
+ // same tenant in practice, but the gate is bypassed via
298
+ // `incumbent.tenantId` to avoid accidental cross-tenant
299
+ // MissingTenantIdError bubbling from the user's `start` call).
300
+ this.cancelLocked(
301
+ incumbent.id,
302
+ { cascade: true, reason: 'replaced' },
303
+ incumbent.tenantId,
304
+ );
305
+ break;
306
+ case 'queue':
307
+ queueBlockedBy = incumbent.id;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+
313
+ // 3. Resolve lineage.
314
+ const newId = randomUUID();
315
+ let rootRunId: string = newId;
316
+ if (opts.parentRunId) {
317
+ const parent = this.store.runs.get(opts.parentRunId);
318
+ if (!parent) {
319
+ throw new Error(
320
+ `parentRunId ${opts.parentRunId} does not reference an existing job_run`,
321
+ );
322
+ }
323
+ rootRunId = parent.rootRunId;
324
+ }
325
+
326
+ // 4. Compute dedupe key for the persisted row (separate from dedupe
327
+ // short-circuit above — we store it even when no prior run matched
328
+ // so future dedupe checks see it).
329
+ const dedupeKey = definition.dedupeKeyTemplate
330
+ ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)
331
+ : null;
332
+
333
+ const now = new Date();
334
+ const runAt = queueBlockedBy
335
+ ? QUEUED_RUN_AT
336
+ : (opts.runAt ?? now);
337
+
338
+ const row: JobRunRow = {
339
+ id: newId,
340
+ jobType: type,
341
+ jobVersion: definition.version,
342
+ parentRunId: opts.parentRunId ?? null,
343
+ rootRunId,
344
+ parentClosePolicy: opts.parentClosePolicy ?? 'terminate',
345
+ scopeEntityType: opts.scope?.entityType ?? null,
346
+ scopeEntityId: opts.scope?.entityId ?? null,
347
+ tenantId,
348
+ tags: opts.tags ?? {},
349
+ pool: opts.pool ?? definition.pool,
350
+ priority: opts.priority ?? definition.priorityDefault,
351
+ concurrencyKey,
352
+ dedupeKey,
353
+ status: 'pending',
354
+ input: payload,
355
+ output: null,
356
+ error: null,
357
+ triggerSource: opts.triggerSource ?? 'manual',
358
+ triggerRef: opts.triggerRef ?? null,
359
+ runAt,
360
+ startedAt: null,
361
+ finishedAt: null,
362
+ claimedAt: null,
363
+ attempts: 0,
364
+ waitKind: null,
365
+ resumeToken: null,
366
+ waitDeadline: null,
367
+ createdAt: now,
368
+ updatedAt: now,
369
+ };
370
+
371
+ this.store.runs.set(newId, row);
372
+ if (queueBlockedBy) {
373
+ const list = this.queueBlockers.get(queueBlockedBy) ?? [];
374
+ list.push(newId);
375
+ this.queueBlockers.set(queueBlockedBy, list);
376
+ }
377
+ return row;
378
+ });
379
+ }
380
+
381
+ // ==========================================================================
382
+ // cancel
383
+ // ==========================================================================
384
+
385
+ async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {
386
+ // JOB-8 — strict tenant gate outside the mutex (matches Drizzle path).
387
+ const tenantId = this.resolveTenantId('cancel', opts.tenantId);
388
+ await this.mutex.run(async () => {
389
+ this.cancelLocked(runId, opts, tenantId);
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Internal cancel that assumes the caller already holds the mutex.
395
+ * Synchronous because all store ops are in-memory. Idempotent.
396
+ *
397
+ * `tenantForGate` is the already-validated tenant id (or `null`). When
398
+ * non-null it gates the initial cancellation to that tenant's run; the
399
+ * cascade step then sweeps descendants on the same `rootRunId` without
400
+ * re-checking — children of a tenant-gated parent always share the
401
+ * tenant (enforced at `start` time).
402
+ */
403
+ private cancelLocked(
404
+ runId: string,
405
+ opts: CancelOptions,
406
+ tenantForGate: string | null,
407
+ ): void {
408
+ const run = this.store.runs.get(runId);
409
+ if (!run) return;
410
+ // JOB-8 — cross-tenant cancel is silent no-op.
411
+ if (this.multiTenant && run.tenantId !== tenantForGate) return;
412
+ if (isTerminal(run.status)) return;
413
+
414
+ const now = new Date();
415
+
416
+ // Collect descendants up front so Cancel-policy parents can wait on
417
+ // children (their `finished_at` is set after children transition).
418
+ const descendants =
419
+ opts.cascade === false
420
+ ? []
421
+ : Array.from(this.store.runs.values()).filter(
422
+ (r) =>
423
+ r.rootRunId === run.rootRunId &&
424
+ r.id !== runId &&
425
+ !isTerminal(r.status),
426
+ );
427
+
428
+ // Group by policy stored on the child.
429
+ const terminateChildren = descendants.filter(
430
+ (d) => d.parentClosePolicy === ParentClosePolicy.Terminate,
431
+ );
432
+ const cancelChildren = descendants.filter(
433
+ (d) => d.parentClosePolicy === ParentClosePolicy.Cancel,
434
+ );
435
+ // 'abandon' → do nothing.
436
+
437
+ // Terminate policy: cancel children, then parent.
438
+ for (const child of terminateChildren) {
439
+ this.transitionToCanceled(child.id, now);
440
+ }
441
+
442
+ // Cancel policy: cancel children first, then parent (so parent's
443
+ // finished_at is set only after children transitioned).
444
+ for (const child of cancelChildren) {
445
+ this.transitionToCanceled(child.id, now);
446
+ }
447
+
448
+ this.transitionToCanceled(runId, now);
449
+
450
+ void opts.reason; // reserved for future audit logging
451
+ }
452
+
453
+ private transitionToCanceled(runId: string, at: Date): void {
454
+ const run = this.store.runs.get(runId);
455
+ if (!run) return;
456
+ if (isTerminal(run.status)) return;
457
+ const next: JobRunRow = {
458
+ ...run,
459
+ status: 'canceled',
460
+ finishedAt: at,
461
+ updatedAt: at,
462
+ };
463
+ this.store.runs.set(runId, next);
464
+ this.unblockQueuedDependents(runId);
465
+ }
466
+
467
+ /**
468
+ * When `runId` transitions to a terminal state, advance every dependent
469
+ * `queue`-blocked run's `run_at` back to `now()` so `claimNext` picks
470
+ * them up.
471
+ */
472
+ private unblockQueuedDependents(runId: string): void {
473
+ const dependents = this.queueBlockers.get(runId);
474
+ if (!dependents || dependents.length === 0) return;
475
+ const now = new Date();
476
+ for (const dep of dependents) {
477
+ const depRun = this.store.runs.get(dep);
478
+ if (!depRun) continue;
479
+ if (depRun.status !== 'pending') continue;
480
+ this.store.runs.set(dep, { ...depRun, runAt: now, updatedAt: now });
481
+ }
482
+ this.queueBlockers.delete(runId);
483
+ }
484
+
485
+ // ==========================================================================
486
+ // claimNext — consumed by JobWorker in memory mode (tests exercise directly)
487
+ // ==========================================================================
488
+
489
+ async claimNext(pool: string): Promise<JobRunRow | null> {
490
+ return this.mutex.run(async () => {
491
+ const now = Date.now();
492
+ const candidates = Array.from(this.store.runs.values()).filter(
493
+ (r) =>
494
+ r.status === 'pending' &&
495
+ r.pool === pool &&
496
+ r.runAt.getTime() <= now,
497
+ );
498
+ if (candidates.length === 0) return null;
499
+
500
+ // ORDER BY priority DESC, run_at ASC (Drizzle parity).
501
+ candidates.sort((a, b) => {
502
+ if (a.priority !== b.priority) return b.priority - a.priority;
503
+ return a.runAt.getTime() - b.runAt.getTime();
504
+ });
505
+
506
+ const winner = candidates[0]!;
507
+ const claimedAt = new Date();
508
+ const next: JobRunRow = {
509
+ ...winner,
510
+ status: 'running',
511
+ claimedAt,
512
+ startedAt: claimedAt,
513
+ updatedAt: claimedAt,
514
+ };
515
+ this.store.runs.set(winner.id, next);
516
+ return next;
517
+ });
518
+ }
519
+
520
+ // ==========================================================================
521
+ // replay
522
+ // ==========================================================================
523
+
524
+ async replay(runId: string): Promise<JobRun> {
525
+ return this.mutex.run(async () => {
526
+ const run = this.store.runs.get(runId);
527
+ if (!run) throw new Error(`replay: run ${runId} not found`);
528
+ if (!isTerminal(run.status)) {
529
+ throw new JobNotReplayableError(runId, run.status);
530
+ }
531
+ const def = this.store.jobs.get(run.jobType);
532
+ if (!def) throw new JobTypeNotFoundError(run.jobType);
533
+
534
+ const mode = def.replayFrom;
535
+ if (mode === 'scratch') {
536
+ this.stepService.clearStepsForRun(runId);
537
+ } else {
538
+ // `last_step` and `last_checkpoint` collapse to the same semantic
539
+ // in Phase 1 — delete non-completed rows, preserve memoized ones.
540
+ // Matches the Drizzle backend exactly (see JOB-3 notes).
541
+ this.stepService.clearIncompleteSteps(runId);
542
+ }
543
+
544
+ const now = new Date();
545
+ const next: JobRunRow = {
546
+ ...run,
547
+ status: 'pending',
548
+ attempts: 0,
549
+ runAt: now,
550
+ startedAt: null,
551
+ finishedAt: null,
552
+ claimedAt: null,
553
+ error: null,
554
+ output: null,
555
+ updatedAt: now,
556
+ };
557
+ this.store.runs.set(runId, next);
558
+ return next;
559
+ });
560
+ }
561
+
562
+ // ==========================================================================
563
+ // tick — used by unit tests + memory-mode JobWorker
564
+ // ==========================================================================
565
+
566
+ /**
567
+ * Execute a single claimed run to completion, retry, or failure. Not on
568
+ * `IJobOrchestrator` — it's the memory equivalent of the Drizzle
569
+ * `JobWorker.processRun` code path. The unit tests drive it directly so
570
+ * they can assert memoization across ticks without spinning up a worker.
571
+ */
572
+ async tick(runId: string): Promise<void> {
573
+ // We load state outside the mutex because handler execution cannot
574
+ // hold the serialisation lock — `fn()` inside `ctx.step` can call back
575
+ // into `start` / `spawnChild` which would deadlock. Mutation points
576
+ // (recordStep, status transition) go through the services or the
577
+ // orchestrator entry points and re-enter the mutex there.
578
+ const run = this.store.runs.get(runId);
579
+ if (!run) throw new Error(`tick: run ${runId} not found`);
580
+ if (run.status !== 'running') {
581
+ throw new Error(
582
+ `tick: run ${runId} must be 'running' (got '${run.status}')`,
583
+ );
584
+ }
585
+
586
+ const registration = this.handlerRegistry.get(run.jobType);
587
+ if (!registration) {
588
+ await this.markFailed(run, new Error(
589
+ `No handler registered for jobType='${run.jobType}'`,
590
+ ), (run.attempts ?? 0) + 1);
591
+ return;
592
+ }
593
+ const meta = registration.meta;
594
+ const HandlerClass = registration.handlerClass;
595
+ // Match the Drizzle backend: resolve the handler through Nest's
596
+ // ModuleRef so `@Inject` constructor params work. ModuleRef is
597
+ // @Optional() — zero-dep test stubs that construct this orchestrator
598
+ // manually still hit the legacy `new HandlerClass()` path.
599
+ const handler = this.moduleRef
600
+ ? ((await this.moduleRef.create(
601
+ HandlerClass as unknown as new (...args: unknown[]) => unknown,
602
+ )) as JobHandlerBase<unknown>)
603
+ : new HandlerClass();
604
+
605
+ const ctx: JobContext<unknown> = {
606
+ input: run.input,
607
+ run: run as JobRun,
608
+ step: this.makeStepFn(run),
609
+ spawnChild: this.makeSpawnFn(run),
610
+ logger: new Logger(`JobRun:${run.id}`),
611
+ };
612
+
613
+ const attemptsBefore = run.attempts ?? 0;
614
+ try {
615
+ const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;
616
+ await this.markCompleted(run, output ?? {}, attemptsBefore + 1);
617
+ } catch (err) {
618
+ const policy = meta.retry;
619
+ const decision = classifyError(err, policy, attemptsBefore);
620
+ const nextAttempts = attemptsBefore + 1;
621
+ if (decision === 'retry' && policy) {
622
+ const delay = computeBackoff(policy, nextAttempts);
623
+ await this.rescheduleForRetry(run, err, nextAttempts, delay);
624
+ } else {
625
+ await this.markFailed(run, err, nextAttempts);
626
+ }
627
+ }
628
+ }
629
+
630
+ private makeStepFn(run: JobRunRow) {
631
+ return async <TOutput>(
632
+ stepId: string,
633
+ fn: () => Promise<TOutput>,
634
+ _opts?: StepOptions,
635
+ ): Promise<TOutput> => {
636
+ void _opts;
637
+ const existing = await this.stepService.findStep(run.id, stepId);
638
+ if (existing?.status === 'completed') {
639
+ return existing.output as TOutput;
640
+ }
641
+ const seq = this.nextStepSeq(run.id);
642
+ const startedAt = new Date();
643
+ const nextAttempts = (existing?.attempts ?? 0) + 1;
644
+ await this.stepService.recordStep({
645
+ jobRunId: run.id,
646
+ stepId,
647
+ kind: 'task',
648
+ seq,
649
+ status: 'running',
650
+ startedAt,
651
+ attempts: nextAttempts,
652
+ });
653
+ try {
654
+ const output = await fn();
655
+ await this.stepService.recordStep({
656
+ jobRunId: run.id,
657
+ stepId,
658
+ kind: 'task',
659
+ seq,
660
+ status: 'completed',
661
+ output: output as Record<string, unknown> | undefined,
662
+ finishedAt: new Date(),
663
+ attempts: nextAttempts,
664
+ });
665
+ return output;
666
+ } catch (err) {
667
+ await this.stepService.recordStep({
668
+ jobRunId: run.id,
669
+ stepId,
670
+ kind: 'task',
671
+ seq,
672
+ status: 'failed',
673
+ error: serialiseError(err, nextAttempts, false),
674
+ finishedAt: new Date(),
675
+ attempts: nextAttempts,
676
+ });
677
+ throw err;
678
+ }
679
+ };
680
+ }
681
+
682
+ private makeSpawnFn(run: JobRunRow) {
683
+ return async (
684
+ type: string,
685
+ input: unknown,
686
+ opts?: SpawnChildOptions,
687
+ ): Promise<JobRun> => {
688
+ return this.start(type, input, {
689
+ parentRunId: run.id,
690
+ parentClosePolicy: opts?.closePolicy,
691
+ runAt: opts?.runAt,
692
+ priority: opts?.priority,
693
+ tags: opts?.tags,
694
+ triggerSource: 'parent',
695
+ triggerRef: run.id,
696
+ });
697
+ };
698
+ }
699
+
700
+ private nextStepSeq(runId: string): number {
701
+ const rows = this.store.steps.get(runId);
702
+ if (!rows || rows.length === 0) return 1;
703
+ let max = 0;
704
+ for (const r of rows) if (r.seq > max) max = r.seq;
705
+ return max + 1;
706
+ }
707
+
708
+ private async markCompleted(
709
+ run: JobRunRow,
710
+ output: Record<string, unknown>,
711
+ attempts: number,
712
+ ): Promise<void> {
713
+ await this.mutex.run(async () => {
714
+ const current = this.store.runs.get(run.id);
715
+ if (!current || isTerminal(current.status)) return;
716
+ const now = new Date();
717
+ this.store.runs.set(run.id, {
718
+ ...current,
719
+ status: 'completed',
720
+ output,
721
+ finishedAt: now,
722
+ updatedAt: now,
723
+ attempts,
724
+ });
725
+ this.unblockQueuedDependents(run.id);
726
+ });
727
+ }
728
+
729
+ private async markFailed(
730
+ run: JobRunRow,
731
+ err: unknown,
732
+ attempts: number,
733
+ ): Promise<void> {
734
+ await this.mutex.run(async () => {
735
+ const current = this.store.runs.get(run.id);
736
+ if (!current || isTerminal(current.status)) return;
737
+ const now = new Date();
738
+ this.store.runs.set(run.id, {
739
+ ...current,
740
+ status: 'failed',
741
+ finishedAt: now,
742
+ updatedAt: now,
743
+ attempts,
744
+ error: serialiseError(err, attempts, false),
745
+ });
746
+ this.unblockQueuedDependents(run.id);
747
+ });
748
+
749
+ // parent_close_policy = 'terminate' cascade mirrors the Drizzle worker
750
+ // (cancel runs outside its own terminal transition). We pass the run's
751
+ // own `tenantId` so the cancel passes the multi-tenant gate — this is
752
+ // system-internal cascade, not a user-initiated call.
753
+ if (run.parentClosePolicy === 'terminate') {
754
+ try {
755
+ await this.cancel(run.id, {
756
+ cascade: true,
757
+ reason: 'parent-failed',
758
+ tenantId: run.tenantId,
759
+ });
760
+ } catch (cascadeErr) {
761
+ this.logger.warn(
762
+ `cascade on failed run ${run.id}: ${(cascadeErr as Error).message}`,
763
+ );
764
+ }
765
+ }
766
+ }
767
+
768
+ private async rescheduleForRetry(
769
+ run: JobRunRow,
770
+ err: unknown,
771
+ attempts: number,
772
+ delayMs: number,
773
+ ): Promise<void> {
774
+ await this.mutex.run(async () => {
775
+ const current = this.store.runs.get(run.id);
776
+ if (!current || isTerminal(current.status)) return;
777
+ const now = new Date();
778
+ this.store.runs.set(run.id, {
779
+ ...current,
780
+ status: 'pending',
781
+ attempts,
782
+ runAt: new Date(Date.now() + delayMs),
783
+ startedAt: null,
784
+ claimedAt: null,
785
+ updatedAt: now,
786
+ error: serialiseError(err, attempts, true),
787
+ });
788
+ });
789
+ }
790
+
791
+ // ==========================================================================
792
+ // Internal queries — used by start / cancel
793
+ // ==========================================================================
794
+
795
+ private findDedupeCandidate(
796
+ jobType: string,
797
+ dedupeKey: string,
798
+ windowStartMs: number,
799
+ ): JobRunRow | null {
800
+ let best: JobRunRow | null = null;
801
+ for (const r of this.store.runs.values()) {
802
+ if (r.jobType !== jobType) continue;
803
+ if (r.dedupeKey !== dedupeKey) continue;
804
+ if (DEDUPE_EXCLUDED_STATUSES.includes(r.status)) continue;
805
+ if (r.createdAt.getTime() <= windowStartMs) continue;
806
+ if (!best || r.createdAt.getTime() > best.createdAt.getTime()) {
807
+ best = r;
808
+ }
809
+ }
810
+ return best;
811
+ }
812
+
813
+ private findInFlightByConcurrencyKey(key: string): JobRunRow | null {
814
+ for (const r of this.store.runs.values()) {
815
+ if (r.concurrencyKey !== key) continue;
816
+ if (!IN_FLIGHT_STATUSES.includes(r.status)) continue;
817
+ return r;
818
+ }
819
+ return null;
820
+ }
821
+ }
822
+
823
+ // ─── Pure helpers (mirrored from JobWorker so memory mode is standalone) ────
824
+
825
+ function classifyError(
826
+ err: unknown,
827
+ policy: RetryPolicy | undefined,
828
+ currentAttempts: number,
829
+ ): 'retry' | 'fail' {
830
+ if (!policy) return 'fail';
831
+ const errObj = err as { name?: string; code?: string } | undefined;
832
+ const name = errObj?.name;
833
+ const code = errObj?.code;
834
+ const nonRetryable = policy.nonRetryableErrors ?? [];
835
+ if (nonRetryable.some((n) => n === name || n === code)) return 'fail';
836
+ if (currentAttempts + 1 >= policy.attempts) return 'fail';
837
+ return 'retry';
838
+ }
839
+
840
+ function computeBackoff(policy: RetryPolicy, attempts: number): number {
841
+ const base = Math.max(policy.baseMs, 0);
842
+ if (policy.backoff === 'fixed') return base;
843
+ const exponent = Math.max(attempts - 1, 0);
844
+ if (exponent >= 53) return Number.MAX_SAFE_INTEGER;
845
+ const raw = base * Math.pow(2, exponent);
846
+ if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {
847
+ return Number.MAX_SAFE_INTEGER;
848
+ }
849
+ return raw;
850
+ }
851
+
852
+ function serialiseError(err: unknown, attempt: number, retryable: boolean) {
853
+ const e = err as { message?: string; stack?: string } | undefined;
854
+ return {
855
+ message: (e?.message ?? String(err)) as string,
856
+ stack: e?.stack,
857
+ retryable,
858
+ attempt,
859
+ };
860
+ }