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