@pattern-stack/codegen 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/package.json +2 -1
  2. package/runtime/analytics/index.ts +31 -0
  3. package/runtime/analytics/metrics.ts +85 -0
  4. package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
  5. package/runtime/analytics/packs/index.ts +5 -0
  6. package/runtime/analytics/packs/monetary-measures.ts +20 -0
  7. package/runtime/analytics/specs.ts +54 -0
  8. package/runtime/analytics/types.ts +105 -0
  9. package/runtime/base-classes/activity-entity-repository.ts +50 -0
  10. package/runtime/base-classes/activity-entity-service.ts +48 -0
  11. package/runtime/base-classes/base-read-use-cases.ts +88 -0
  12. package/runtime/base-classes/base-repository.ts +289 -0
  13. package/runtime/base-classes/base-service.ts +183 -0
  14. package/runtime/base-classes/index.ts +38 -0
  15. package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
  16. package/runtime/base-classes/knowledge-entity-service.ts +14 -0
  17. package/runtime/base-classes/lifecycle-events.ts +152 -0
  18. package/runtime/base-classes/metadata-entity-repository.ts +80 -0
  19. package/runtime/base-classes/metadata-entity-service.ts +48 -0
  20. package/runtime/base-classes/synced-entity-repository.ts +57 -0
  21. package/runtime/base-classes/synced-entity-service.ts +50 -0
  22. package/runtime/base-classes/with-analytics.ts +22 -0
  23. package/runtime/constants/tokens.ts +29 -0
  24. package/runtime/eav-helpers.ts +74 -0
  25. package/runtime/pipes/zod-validation.pipe.ts +64 -0
  26. package/runtime/shared/openapi/error-response.dto.ts +24 -0
  27. package/runtime/shared/openapi/errors.ts +39 -0
  28. package/runtime/shared/openapi/index.ts +20 -0
  29. package/runtime/shared/openapi/registry.tokens.ts +13 -0
  30. package/runtime/shared/openapi/registry.ts +151 -0
  31. package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
  32. package/runtime/subsystems/analytics/analytics.module.ts +64 -0
  33. package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
  34. package/runtime/subsystems/analytics/cube-backend.ts +75 -0
  35. package/runtime/subsystems/analytics/index.ts +15 -0
  36. package/runtime/subsystems/analytics/noop-backend.ts +27 -0
  37. package/runtime/subsystems/auth/auth.module.ts +91 -0
  38. package/runtime/subsystems/auth/auth.tokens.ts +27 -0
  39. package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
  40. package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
  41. package/runtime/subsystems/auth/index.ts +77 -0
  42. package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
  43. package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
  44. package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
  45. package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
  46. package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
  47. package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
  48. package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
  49. package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
  50. package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
  51. package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
  52. package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
  53. package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
  54. package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
  55. package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
  56. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
  57. package/runtime/subsystems/bridge/bridge.module.ts +160 -0
  58. package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
  59. package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
  60. package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
  61. package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
  62. package/runtime/subsystems/bridge/generated/registry.ts +6 -0
  63. package/runtime/subsystems/bridge/index.ts +84 -0
  64. package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
  65. package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
  66. package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
  67. package/runtime/subsystems/cache/cache.module.ts +115 -0
  68. package/runtime/subsystems/cache/cache.protocol.ts +45 -0
  69. package/runtime/subsystems/cache/cache.schema.ts +27 -0
  70. package/runtime/subsystems/cache/cache.tokens.ts +17 -0
  71. package/runtime/subsystems/cache/index.ts +22 -0
  72. package/runtime/subsystems/events/domain-events.schema.ts +77 -0
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
  74. package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
  75. package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
  76. package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
  77. package/runtime/subsystems/events/events-errors.ts +30 -0
  78. package/runtime/subsystems/events/events.module.ts +230 -0
  79. package/runtime/subsystems/events/events.tokens.ts +62 -0
  80. package/runtime/subsystems/events/generated/bus.ts +103 -0
  81. package/runtime/subsystems/events/generated/index.ts +7 -0
  82. package/runtime/subsystems/events/generated/registry.ts +84 -0
  83. package/runtime/subsystems/events/generated/schemas.ts +59 -0
  84. package/runtime/subsystems/events/generated/types.ts +94 -0
  85. package/runtime/subsystems/events/index.ts +21 -0
  86. package/runtime/subsystems/index.ts +63 -0
  87. package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
  88. package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
  89. package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
  90. package/runtime/subsystems/jobs/index.ts +120 -0
  91. package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
  92. package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
  93. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
  94. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +850 -0
  95. package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
  96. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
  97. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
  98. package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
  99. package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
  100. package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
  101. package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
  102. package/runtime/subsystems/jobs/job-worker.module.ts +302 -0
  103. package/runtime/subsystems/jobs/job-worker.ts +615 -0
  104. package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
  105. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
  106. package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
  107. package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
  108. package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
  109. package/runtime/subsystems/storage/index.ts +18 -0
  110. package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
  111. package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
  112. package/runtime/subsystems/storage/storage.module.ts +60 -0
  113. package/runtime/subsystems/storage/storage.protocol.ts +78 -0
  114. package/runtime/subsystems/storage/storage.tokens.ts +9 -0
  115. package/runtime/subsystems/storage/storage.utils.ts +20 -0
  116. package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
  117. package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
  118. package/runtime/subsystems/sync/index.ts +98 -0
  119. package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
  120. package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
  121. package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
  122. package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
  123. package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
  124. package/runtime/subsystems/sync/sync-errors.ts +54 -0
  125. package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
  126. package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
  127. package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
  128. package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
  129. package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
  130. package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
  131. package/runtime/subsystems/sync/sync.module.ts +156 -0
  132. package/runtime/subsystems/sync/sync.tokens.ts +57 -0
  133. package/runtime/types/drizzle.ts +23 -0
@@ -0,0 +1,536 @@
1
+ /**
2
+ * DrizzleJobOrchestrator — Postgres-backed implementation of
3
+ * `IJobOrchestrator` (ADR-022, JOB-3).
4
+ *
5
+ * Single-layer architecture: `start` writes a single `job_run` row; the
6
+ * `JobWorker` polling loop claims it directly via `FOR UPDATE SKIP LOCKED`.
7
+ * No `job_queue` table, no executor port. See `docs/specs/JOB-3.md`.
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { Inject, Injectable, Logger } from '@nestjs/common';
11
+ import { and, desc, eq, gt, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
12
+ import type { DrizzleClient } from '../../types/drizzle';
13
+ import type { DrizzleTransaction } from '../events/event-bus.protocol';
14
+ import { DRIZZLE } from '../../constants/tokens';
15
+ import {
16
+ jobRuns,
17
+ jobs,
18
+ type JobDefinitionRow,
19
+ type JobRunRow,
20
+ } from './job-orchestration.schema';
21
+ import type {
22
+ CancelOptions,
23
+ IJobOrchestrator,
24
+ JobPoolDef,
25
+ JobRun,
26
+ JobUpsertEntry,
27
+ StartOptions,
28
+ } from './job-orchestrator.protocol';
29
+ import {
30
+ JobCollisionError,
31
+ JobNotReplayableError,
32
+ JobTemplateFieldMissingError,
33
+ JobTypeNotFoundError,
34
+ MissingTenantIdError,
35
+ } from './jobs-errors';
36
+ import { jobSteps } from './job-orchestration.schema';
37
+ import { JOBS_MULTI_TENANT } from './jobs-domain.tokens';
38
+
39
+ /**
40
+ * Terminal statuses — transitions into these are final. Used by `cancel`
41
+ * (to short-circuit idempotently) and by `replay` (as the guard gate).
42
+ */
43
+ export const TERMINAL_STATUSES = [
44
+ 'completed',
45
+ 'failed',
46
+ 'timed_out',
47
+ 'canceled',
48
+ ] as const;
49
+ type TerminalStatus = (typeof TERMINAL_STATUSES)[number];
50
+ type JobRunStatus = JobRunRow['status'];
51
+
52
+ /** Statuses excluded from dedupe window matches per ADR-022. */
53
+ const DEDUPE_EXCLUDED_STATUSES: JobRunStatus[] = ['canceled', 'failed'];
54
+ /** Statuses that count as in-flight for concurrency collision checks. */
55
+ const IN_FLIGHT_STATUSES: JobRunStatus[] = ['pending', 'running'];
56
+
57
+ /**
58
+ * Substitute `{{field}}` placeholders against the input payload.
59
+ *
60
+ * Implementation decision (JOB-3, 2026-04-19): simple `{{field}}` single-key
61
+ * substitution, no dotted paths, no Mustache/Handlebars dependency. A missing
62
+ * field throws `JobTemplateFieldMissingError` synchronously — cheaper than
63
+ * discovering the misconfiguration at claim time.
64
+ */
65
+ export function evaluateKeyTemplate(
66
+ template: string,
67
+ input: Record<string, unknown>,
68
+ ): string {
69
+ return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, field: string) => {
70
+ const value = input[field];
71
+ if (value === undefined || value === null) {
72
+ throw new JobTemplateFieldMissingError(template, field);
73
+ }
74
+ return String(value);
75
+ });
76
+ }
77
+
78
+ @Injectable()
79
+ export class DrizzleJobOrchestrator implements IJobOrchestrator {
80
+ // TODO(logging-subsystem): swap to ILogger once ADR-028 lands
81
+ private readonly logger = new Logger(DrizzleJobOrchestrator.name);
82
+
83
+ constructor(
84
+ @Inject(DRIZZLE) private readonly db: DrizzleClient,
85
+ @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
86
+ ) {}
87
+
88
+ /**
89
+ * JOB-8 — resolve `tenantId` for a mutating / targeted-read call.
90
+ * Returns the tenant value that should be written to the row (or compared
91
+ * against in a WHERE clause). When `multiTenant` is off, the column is
92
+ * forced to `null` regardless of what callers pass. When on, `undefined`
93
+ * throws; `null` and strings pass through untouched.
94
+ */
95
+ private resolveTenantId(
96
+ method: string,
97
+ tenantId: string | null | undefined,
98
+ ): string | null {
99
+ if (!this.multiTenant) return null;
100
+ if (tenantId === undefined) throw new MissingTenantIdError(method);
101
+ return tenantId;
102
+ }
103
+
104
+ // ==========================================================================
105
+ // start
106
+ // ==========================================================================
107
+
108
+ async start(
109
+ type: string,
110
+ input: unknown,
111
+ opts: StartOptions = {},
112
+ tx?: DrizzleTransaction,
113
+ ): Promise<JobRun> {
114
+ const payload = (input ?? {}) as Record<string, unknown>;
115
+
116
+ // JOB-8 — resolve tenant gate up front so `multi_tenant=true` +
117
+ // undefined surfaces before any row is touched.
118
+ const tenantId = this.resolveTenantId('start', opts.tenantId);
119
+
120
+ // BRIDGE-7: thread the optional caller tx through every read/write
121
+ // in this method so EventFlowService.publishAndStart can bundle the
122
+ // outbox insert, the eager job_run insert, and (for Case B) the
123
+ // bridge_delivery pre-write into a single transaction.
124
+ const client = (tx ?? this.db) as DrizzleClient;
125
+
126
+ // 1a. Load job definition.
127
+ const [def] = await client
128
+ .select()
129
+ .from(jobs)
130
+ .where(eq(jobs.type, type))
131
+ .limit(1);
132
+ if (!def) throw new JobTypeNotFoundError(type);
133
+ const definition = def as JobDefinitionRow;
134
+
135
+ // 1b. Dedupe check.
136
+ if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {
137
+ const dedupeKey = evaluateKeyTemplate(definition.dedupeKeyTemplate, payload);
138
+ const windowStart = new Date(Date.now() - definition.dedupeWindowMs);
139
+ const existing = await client
140
+ .select()
141
+ .from(jobRuns)
142
+ .where(
143
+ and(
144
+ eq(jobRuns.jobType, type),
145
+ eq(jobRuns.dedupeKey, dedupeKey),
146
+ gt(jobRuns.createdAt, windowStart),
147
+ // status NOT IN ('canceled', 'failed')
148
+ notInStatus(DEDUPE_EXCLUDED_STATUSES),
149
+ ),
150
+ )
151
+ .orderBy(desc(jobRuns.createdAt))
152
+ .limit(1);
153
+ if (existing.length > 0) {
154
+ return existing[0] as JobRun;
155
+ }
156
+ }
157
+
158
+ // 1c. Concurrency collision check.
159
+ let concurrencyKey: string | null = null;
160
+ if (definition.concurrencyKeyTemplate) {
161
+ concurrencyKey = evaluateKeyTemplate(
162
+ definition.concurrencyKeyTemplate,
163
+ payload,
164
+ );
165
+ const inFlight = await client
166
+ .select()
167
+ .from(jobRuns)
168
+ .where(
169
+ and(
170
+ eq(jobRuns.concurrencyKey, concurrencyKey),
171
+ inArray(jobRuns.status, IN_FLIGHT_STATUSES),
172
+ ),
173
+ )
174
+ .limit(1);
175
+ if (inFlight.length > 0) {
176
+ const incumbent = inFlight[0] as JobRun;
177
+ switch (definition.collisionMode) {
178
+ case 'reject':
179
+ throw new JobCollisionError(type, concurrencyKey, incumbent);
180
+ case 'replace':
181
+ // JOB-8 — thread the incumbent's own tenantId through the
182
+ // internal cascade. Without this, every `replace`-collision
183
+ // start() under multiTenant=true throws MissingTenantIdError
184
+ // from the inner cancel() call instead of cancelling the
185
+ // incumbent. Mirrors the memory backend's `cancelLocked(
186
+ // incumbent.id, ..., incumbent.tenantId)` pattern.
187
+ await this.cancel(incumbent.id, {
188
+ cascade: true,
189
+ reason: 'replaced',
190
+ tenantId: incumbent.tenantId,
191
+ });
192
+ break;
193
+ case 'queue':
194
+ // Fall through — row is inserted; claim query gates it until
195
+ // the incumbent transitions (see JobWorker.processRun queue gate).
196
+ break;
197
+ }
198
+ }
199
+ }
200
+
201
+ // 1d. Resolve id + rootRunId, INSERT.
202
+ const newId = randomUUID();
203
+ let rootRunId: string = newId;
204
+ if (opts.parentRunId) {
205
+ const [parent] = await client
206
+ .select({ rootRunId: jobRuns.rootRunId })
207
+ .from(jobRuns)
208
+ .where(eq(jobRuns.id, opts.parentRunId))
209
+ .limit(1);
210
+ if (!parent) {
211
+ throw new Error(
212
+ `parentRunId ${opts.parentRunId} does not reference an existing job_run`,
213
+ );
214
+ }
215
+ rootRunId = parent.rootRunId;
216
+ }
217
+
218
+ const dedupeKey =
219
+ definition.dedupeKeyTemplate
220
+ ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)
221
+ : null;
222
+
223
+ const [inserted] = await client
224
+ .insert(jobRuns)
225
+ .values({
226
+ id: newId,
227
+ jobType: type,
228
+ jobVersion: definition.version,
229
+ parentRunId: opts.parentRunId ?? null,
230
+ rootRunId,
231
+ parentClosePolicy: opts.parentClosePolicy ?? 'terminate',
232
+ scopeEntityType: opts.scope?.entityType ?? null,
233
+ scopeEntityId: opts.scope?.entityId ?? null,
234
+ tenantId,
235
+ tags: opts.tags ?? {},
236
+ pool: opts.pool ?? definition.pool,
237
+ priority: opts.priority ?? definition.priorityDefault,
238
+ concurrencyKey,
239
+ dedupeKey,
240
+ status: 'pending',
241
+ input: payload,
242
+ output: null,
243
+ error: null,
244
+ triggerSource: opts.triggerSource ?? 'manual',
245
+ triggerRef: opts.triggerRef ?? null,
246
+ runAt: opts.runAt ?? new Date(),
247
+ startedAt: null,
248
+ finishedAt: null,
249
+ claimedAt: null,
250
+ attempts: 0,
251
+ })
252
+ .returning();
253
+
254
+ return inserted as JobRun;
255
+ }
256
+
257
+ // ==========================================================================
258
+ // cancel
259
+ // ==========================================================================
260
+
261
+ async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {
262
+ // JOB-8 — resolve tenant gate up front (strict undefined-throws).
263
+ const tenantId = this.resolveTenantId('cancel', opts.tenantId);
264
+
265
+ // Load target.
266
+ const [target] = await this.db
267
+ .select()
268
+ .from(jobRuns)
269
+ .where(eq(jobRuns.id, runId))
270
+ .limit(1);
271
+ if (!target) return;
272
+ // JOB-8 — cross-tenant cancel is a silent no-op (no existence leak).
273
+ if (this.multiTenant && target.tenantId !== tenantId) return;
274
+ if (TERMINAL_STATUSES.includes(target.status as TerminalStatus)) {
275
+ return; // idempotent
276
+ }
277
+
278
+ // Atomic transition, guarded against concurrent terminal moves.
279
+ const [cancelled] = await this.db
280
+ .update(jobRuns)
281
+ .set({
282
+ status: 'canceled',
283
+ finishedAt: new Date(),
284
+ updatedAt: new Date(),
285
+ })
286
+ .where(
287
+ and(eq(jobRuns.id, runId), notInStatus([...TERMINAL_STATUSES])),
288
+ )
289
+ .returning();
290
+
291
+ if (!cancelled) return; // lost the race; already terminal
292
+
293
+ if (opts.cascade === false) return;
294
+
295
+ // Fetch descendants and branch on parent_close_policy.
296
+ const descendants = await this.db
297
+ .select()
298
+ .from(jobRuns)
299
+ .where(
300
+ and(
301
+ eq(jobRuns.rootRunId, target.rootRunId),
302
+ ne(jobRuns.id, runId),
303
+ notInStatus([...TERMINAL_STATUSES]),
304
+ ),
305
+ );
306
+
307
+ for (const child of descendants) {
308
+ const policy = (child as JobRunRow).parentClosePolicy;
309
+ if (policy === 'abandon') continue;
310
+ // 'terminate' | 'cancel' — both transition the child to canceled.
311
+ await this.db
312
+ .update(jobRuns)
313
+ .set({
314
+ status: 'canceled',
315
+ finishedAt: new Date(),
316
+ updatedAt: new Date(),
317
+ })
318
+ .where(
319
+ and(
320
+ eq(jobRuns.id, (child as JobRunRow).id),
321
+ notInStatus([...TERMINAL_STATUSES]),
322
+ ),
323
+ );
324
+ }
325
+
326
+ void opts.reason; // reserved for future audit logging
327
+ }
328
+
329
+ // ==========================================================================
330
+ // replay
331
+ // ==========================================================================
332
+
333
+ async replay(runId: string): Promise<JobRun> {
334
+ // Load target + its job definition (we need replay_from).
335
+ const [target] = await this.db
336
+ .select()
337
+ .from(jobRuns)
338
+ .where(eq(jobRuns.id, runId))
339
+ .limit(1);
340
+ if (!target) {
341
+ throw new Error(`replay: run ${runId} not found`);
342
+ }
343
+ const run = target as JobRunRow;
344
+ if (!TERMINAL_STATUSES.includes(run.status as TerminalStatus)) {
345
+ throw new JobNotReplayableError(runId, run.status);
346
+ }
347
+
348
+ const [def] = await this.db
349
+ .select()
350
+ .from(jobs)
351
+ .where(eq(jobs.type, run.jobType))
352
+ .limit(1);
353
+ if (!def) throw new JobTypeNotFoundError(run.jobType);
354
+ const mode = (def as JobDefinitionRow).replayFrom;
355
+
356
+ // Atomic: step reset + run reset must commit together.
357
+ const result = await this.db.transaction(async (tx) => {
358
+ if (mode === 'scratch') {
359
+ await tx.delete(jobSteps).where(eq(jobSteps.jobRunId, runId));
360
+ } else if (mode === 'last_step') {
361
+ // Delete only non-completed step rows — completed steps stay memoised.
362
+ await tx
363
+ .delete(jobSteps)
364
+ .where(
365
+ and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),
366
+ );
367
+ } else {
368
+ // 'last_checkpoint' — Phase 1 has no explicit checkpoint markers, so
369
+ // behaviour collapses to `last_step`. See docs/specs/JOB-3.md
370
+ // "Implementation Decisions" — planned divergence in a later phase.
371
+ await tx
372
+ .delete(jobSteps)
373
+ .where(
374
+ and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),
375
+ );
376
+ }
377
+
378
+ const [updated] = await tx
379
+ .update(jobRuns)
380
+ .set({
381
+ status: 'pending',
382
+ attempts: 0,
383
+ runAt: new Date(),
384
+ startedAt: null,
385
+ finishedAt: null,
386
+ claimedAt: null,
387
+ error: null,
388
+ output: null,
389
+ updatedAt: new Date(),
390
+ })
391
+ .where(eq(jobRuns.id, runId))
392
+ .returning();
393
+ return updated as JobRunRow;
394
+ });
395
+
396
+ return result as JobRun;
397
+ }
398
+
399
+ // ==========================================================================
400
+ // upsertJobRows — boot-time materialisation of `job` definitions
401
+ // ==========================================================================
402
+
403
+ /**
404
+ * Hash-gated `INSERT … ON CONFLICT (type) DO UPDATE … WHERE` per Q3
405
+ * resolution (2026-04-19): the `UPDATE` branch executes only when one
406
+ * of the persisted metadata fields differs from the incoming payload;
407
+ * `version` bumps only on real change; concurrent boots with identical
408
+ * content are idempotent no-ops.
409
+ *
410
+ * Why this shape (not `DO NOTHING`, not advisory locks):
411
+ * - `DO NOTHING` would let an old-version instance leave a stale row
412
+ * that a new-version instance can't overwrite during a rolling deploy.
413
+ * - Advisory locks add latency and leak risk under crashes.
414
+ * - The `WHERE … IS DISTINCT FROM …` clause makes the conditional
415
+ * atomic — no read-modify-write race on `version` between concurrent
416
+ * boots.
417
+ *
418
+ * Orphan detection: a single `SELECT type FROM job WHERE type NOT IN (...)`
419
+ * returns the types present in DB but absent from `entries`. Caller (boot
420
+ * validator) decides whether to throw `BootValidationError`.
421
+ */
422
+ async upsertJobRows(
423
+ entries: JobUpsertEntry[],
424
+ poolConfig: ReadonlyMap<string, JobPoolDef>,
425
+ ): Promise<{ orphaned: string[] }> {
426
+ void poolConfig; // pool validation is the module's responsibility; orchestrator just persists
427
+
428
+ for (const entry of entries) {
429
+ const meta = entry.meta;
430
+ const pool = meta.pool ?? 'batch';
431
+ const retryPolicy = meta.retry ?? {
432
+ attempts: 1,
433
+ backoff: 'fixed' as const,
434
+ baseMs: 0,
435
+ };
436
+ const concurrencyKeyTemplate =
437
+ (meta.concurrency as { key?: unknown } | undefined)?.key;
438
+ const concurrencyKeyTemplateStr =
439
+ typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null;
440
+ const collisionMode =
441
+ (meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??
442
+ 'queue';
443
+ const dedupeKeyTemplate =
444
+ (meta.dedupe as { key?: unknown } | undefined)?.key;
445
+ const dedupeKeyTemplateStr =
446
+ typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null;
447
+ const dedupeWindowMs = meta.dedupe?.windowMs ?? null;
448
+ const timeoutMs = meta.timeoutMs ?? null;
449
+ const replayFrom = meta.replayFrom ?? 'last_checkpoint';
450
+ const scopeEntityType = meta.scope?.entity ?? null;
451
+ // Q3 resolution: priority_default and replay_from are part of the
452
+ // hashed metadata even though they aren't currently set via decorator
453
+ // metadata above (priority_default has no `@JobHandler` field yet).
454
+ // Default to 0 to keep UPDATE branch quiet across deploys.
455
+ const priorityDefault = 0;
456
+
457
+ // Hash-gated upsert: every metadata column appears in the WHERE clause
458
+ // so the UPDATE branch only fires on a real change. `version` bumps
459
+ // exactly when the WHERE matches.
460
+ await this.db
461
+ .insert(jobs)
462
+ .values({
463
+ type: entry.type,
464
+ version: 1,
465
+ pool,
466
+ scopeEntityType,
467
+ retryPolicy,
468
+ timeoutMs,
469
+ concurrencyKeyTemplate: concurrencyKeyTemplateStr,
470
+ collisionMode,
471
+ dedupeKeyTemplate: dedupeKeyTemplateStr,
472
+ dedupeWindowMs,
473
+ priorityDefault,
474
+ replayFrom,
475
+ })
476
+ .onConflictDoUpdate({
477
+ target: jobs.type,
478
+ set: {
479
+ pool: sql`EXCLUDED.pool`,
480
+ scopeEntityType: sql`EXCLUDED.scope_entity_type`,
481
+ retryPolicy: sql`EXCLUDED.retry_policy`,
482
+ timeoutMs: sql`EXCLUDED.timeout_ms`,
483
+ concurrencyKeyTemplate: sql`EXCLUDED.concurrency_key_template`,
484
+ collisionMode: sql`EXCLUDED.collision_mode`,
485
+ dedupeKeyTemplate: sql`EXCLUDED.dedupe_key_template`,
486
+ dedupeWindowMs: sql`EXCLUDED.dedupe_window_ms`,
487
+ priorityDefault: sql`EXCLUDED.priority_default`,
488
+ replayFrom: sql`EXCLUDED.replay_from`,
489
+ version: sql`${jobs.version} + 1`,
490
+ updatedAt: sql`now()`,
491
+ },
492
+ // The hash gate: every field listed in the Q3 resolution appears
493
+ // here. `IS DISTINCT FROM` is the null-safe inequality operator;
494
+ // jsonb cast to text gives stable comparison without invoking a
495
+ // dedicated hash column (avoids a JOB-1 schema migration).
496
+ setWhere: sql`
497
+ ${jobs.pool} IS DISTINCT FROM EXCLUDED.pool OR
498
+ ${jobs.retryPolicy}::text IS DISTINCT FROM EXCLUDED.retry_policy::text OR
499
+ ${jobs.timeoutMs} IS DISTINCT FROM EXCLUDED.timeout_ms OR
500
+ ${jobs.concurrencyKeyTemplate} IS DISTINCT FROM EXCLUDED.concurrency_key_template OR
501
+ ${jobs.collisionMode} IS DISTINCT FROM EXCLUDED.collision_mode OR
502
+ ${jobs.dedupeKeyTemplate} IS DISTINCT FROM EXCLUDED.dedupe_key_template OR
503
+ ${jobs.dedupeWindowMs} IS DISTINCT FROM EXCLUDED.dedupe_window_ms OR
504
+ ${jobs.priorityDefault} IS DISTINCT FROM EXCLUDED.priority_default OR
505
+ ${jobs.replayFrom} IS DISTINCT FROM EXCLUDED.replay_from OR
506
+ ${jobs.scopeEntityType} IS DISTINCT FROM EXCLUDED.scope_entity_type
507
+ `,
508
+ });
509
+ }
510
+
511
+ // Orphan detection: any `job` row whose type is not in the registry.
512
+ const types = entries.map((e) => e.type);
513
+ const orphans =
514
+ types.length === 0
515
+ ? await this.db.select({ type: jobs.type }).from(jobs)
516
+ : await this.db
517
+ .select({ type: jobs.type })
518
+ .from(jobs)
519
+ .where(notInArray(jobs.type, types));
520
+
521
+ return { orphaned: orphans.map((o) => o.type) };
522
+ }
523
+ }
524
+
525
+ // ─── Helpers ────────────────────────────────────────────────────────────────
526
+
527
+ function notInStatus(statuses: JobRunStatus[]) {
528
+ // Drizzle's inArray composes with `not` via negation helper; use raw sql
529
+ // to stay readable. `inArray` + `.not()` isn't idiomatic in 0.45.
530
+ const negated = statuses.map((s) => ne(jobRuns.status, s));
531
+ return and(...negated);
532
+ }
533
+
534
+ // `isNotNull` + `gt` imports are retained for potential future use; silence
535
+ // unused-import lint by re-exporting via `void`.
536
+ void isNotNull;