@pattern-stack/codegen 0.17.0 → 0.17.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 (141) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/consumer-skills/integration/audit-and-detection.md +29 -4
  3. package/dist/{chunk-GJDEPTPY.js → chunk-235ZMMJR.js} +8 -8
  4. package/dist/{chunk-DTXH24LR.js → chunk-65MO75WM.js} +9 -9
  5. package/dist/{chunk-5RT7JGKT.js → chunk-7OVCARTQ.js} +4 -4
  6. package/dist/{chunk-CO6LUM72.js → chunk-7P5ODGLA.js} +34 -2
  7. package/dist/chunk-7P5ODGLA.js.map +1 -0
  8. package/dist/{chunk-P3AYBRP6.js → chunk-ATVGYF3D.js} +11 -5
  9. package/dist/chunk-ATVGYF3D.js.map +1 -0
  10. package/dist/{chunk-3MAZ4TQH.js → chunk-AZLUWG5S.js} +9 -9
  11. package/dist/{chunk-UTNWFHJF.js → chunk-B34G6PHD.js} +10 -10
  12. package/dist/{chunk-CDLWYZVQ.js → chunk-BHZP6LOV.js} +7 -7
  13. package/dist/{chunk-W2UIDI3R.js → chunk-CLWBNXKF.js} +4 -4
  14. package/dist/{chunk-OTR44OH6.js → chunk-E6PLM6QG.js} +34 -13
  15. package/dist/chunk-E6PLM6QG.js.map +1 -0
  16. package/dist/{chunk-43SBT72G.js → chunk-I6UXRJ3Q.js} +4 -4
  17. package/dist/{chunk-36U5UGIO.js → chunk-JEINYUJH.js} +8 -5
  18. package/dist/chunk-JEINYUJH.js.map +1 -0
  19. package/dist/{chunk-K2I6XIK5.js → chunk-KSTZIULO.js} +4 -4
  20. package/dist/{chunk-L3VJ47BU.js → chunk-KZDHMZ45.js} +6 -6
  21. package/dist/{chunk-RHYNACZS.js → chunk-OZEPJGMA.js} +2 -2
  22. package/dist/{chunk-MYQIQ27N.js → chunk-Q6LRJ4VI.js} +51 -2
  23. package/dist/chunk-Q6LRJ4VI.js.map +1 -0
  24. package/dist/{chunk-NXNVTXKG.js → chunk-R6F6KFIL.js} +5 -5
  25. package/dist/{chunk-7LKAMLV4.js → chunk-T6SCOJF4.js} +4 -4
  26. package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
  27. package/dist/{chunk-OITTYGJS.js → chunk-VDL5CJ5C.js} +24 -14
  28. package/dist/chunk-VDL5CJ5C.js.map +1 -0
  29. package/dist/{chunk-3VEVGL74.js → chunk-VNBC3VXM.js} +4 -4
  30. package/dist/{chunk-BULPAAD3.js → chunk-VQOAATIG.js} +42 -11
  31. package/dist/chunk-VQOAATIG.js.map +1 -0
  32. package/dist/{chunk-E45CSC33.js → chunk-XKWOJZZ4.js} +2 -2
  33. package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
  34. package/dist/{chunk-4GLNY5V6.js → chunk-Y7GDG744.js} +5 -5
  35. package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
  36. package/dist/{job-orchestrator.protocol-DubMVbm9.d.ts → job-orchestrator.protocol-ZuJ3ow-O.d.ts} +77 -3
  37. package/dist/runtime/base-classes/index.js +12 -12
  38. package/dist/runtime/shared/openapi/index.js +5 -5
  39. package/dist/runtime/shared/openapi/registry.js +2 -2
  40. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  41. package/dist/runtime/subsystems/auth/index.js +12 -12
  42. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.d.ts +1 -1
  43. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  44. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +3 -3
  45. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +7 -7
  46. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -1
  47. package/dist/runtime/subsystems/bridge/bridge.module.js +19 -19
  48. package/dist/runtime/subsystems/bridge/event-flow.service.d.ts +1 -1
  49. package/dist/runtime/subsystems/bridge/event-flow.service.js +2 -2
  50. package/dist/runtime/subsystems/bridge/index.d.ts +1 -1
  51. package/dist/runtime/subsystems/bridge/index.js +21 -21
  52. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -4
  53. package/dist/runtime/subsystems/events/events.module.js +5 -5
  54. package/dist/runtime/subsystems/events/index.js +7 -7
  55. package/dist/runtime/subsystems/index.d.ts +1 -1
  56. package/dist/runtime/subsystems/index.js +100 -100
  57. package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
  58. package/dist/runtime/subsystems/integration/deep-equal.differ.d.ts +19 -0
  59. package/dist/runtime/subsystems/integration/deep-equal.differ.js +1 -1
  60. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  61. package/dist/runtime/subsystems/integration/index.js +39 -39
  62. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  63. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  64. package/dist/runtime/subsystems/integration/integration.module.d.ts +20 -0
  65. package/dist/runtime/subsystems/integration/integration.module.js +6 -6
  66. package/dist/runtime/subsystems/jobs/index.d.ts +1 -1
  67. package/dist/runtime/subsystems/jobs/index.js +27 -27
  68. package/dist/runtime/subsystems/jobs/job-handler.base.d.ts +1 -1
  69. package/dist/runtime/subsystems/jobs/job-handler.base.js +11 -3
  70. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +1 -1
  71. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +6 -5
  72. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  73. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +1 -1
  74. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +3 -2
  75. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +11 -1
  76. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
  77. package/dist/runtime/subsystems/jobs/job-orchestrator.protocol.d.ts +1 -1
  78. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +1 -1
  79. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +1 -1
  80. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
  81. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +1 -1
  82. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  83. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +1 -1
  84. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +1 -1
  85. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +1 -1
  86. package/dist/runtime/subsystems/jobs/job-worker.d.ts +9 -1
  87. package/dist/runtime/subsystems/jobs/job-worker.js +3 -3
  88. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +1 -1
  89. package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -11
  90. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -9
  91. package/dist/runtime/subsystems/jobs/jobs-errors.d.ts +1 -1
  92. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +25 -1
  93. package/dist/runtime/subsystems/jobs/pg-notify.js +1 -1
  94. package/dist/runtime/subsystems/observability/index.d.ts +1 -1
  95. package/dist/runtime/subsystems/observability/index.js +3 -3
  96. package/dist/runtime/subsystems/observability/observability.module.js +3 -3
  97. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +1 -1
  98. package/dist/runtime/subsystems/observability/observability.service.d.ts +1 -1
  99. package/dist/runtime/subsystems/observability/observability.service.js +2 -2
  100. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -1
  101. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -1
  102. package/dist/runtime/subsystems/storage/index.js +1 -1
  103. package/dist/runtime/subsystems/storage/storage.module.js +1 -1
  104. package/dist/src/cli/index.js +38 -16
  105. package/dist/src/cli/index.js.map +1 -1
  106. package/dist/src/index.js +13 -13
  107. package/package.json +1 -1
  108. package/runtime/subsystems/integration/deep-equal.differ.ts +34 -5
  109. package/runtime/subsystems/integration/integration.module.ts +26 -2
  110. package/runtime/subsystems/jobs/job-handler.base.ts +115 -2
  111. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +43 -16
  112. package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +58 -18
  113. package/runtime/subsystems/jobs/job-worker.ts +29 -11
  114. package/runtime/subsystems/jobs/pg-notify.ts +63 -3
  115. package/templates/subsystem/integration-config/codegen-config-integration-block.ejs.t +17 -0
  116. package/dist/chunk-36U5UGIO.js.map +0 -1
  117. package/dist/chunk-BULPAAD3.js.map +0 -1
  118. package/dist/chunk-CO6LUM72.js.map +0 -1
  119. package/dist/chunk-MYQIQ27N.js.map +0 -1
  120. package/dist/chunk-OITTYGJS.js.map +0 -1
  121. package/dist/chunk-OTR44OH6.js.map +0 -1
  122. package/dist/chunk-P3AYBRP6.js.map +0 -1
  123. /package/dist/{chunk-GJDEPTPY.js.map → chunk-235ZMMJR.js.map} +0 -0
  124. /package/dist/{chunk-DTXH24LR.js.map → chunk-65MO75WM.js.map} +0 -0
  125. /package/dist/{chunk-5RT7JGKT.js.map → chunk-7OVCARTQ.js.map} +0 -0
  126. /package/dist/{chunk-3MAZ4TQH.js.map → chunk-AZLUWG5S.js.map} +0 -0
  127. /package/dist/{chunk-UTNWFHJF.js.map → chunk-B34G6PHD.js.map} +0 -0
  128. /package/dist/{chunk-CDLWYZVQ.js.map → chunk-BHZP6LOV.js.map} +0 -0
  129. /package/dist/{chunk-W2UIDI3R.js.map → chunk-CLWBNXKF.js.map} +0 -0
  130. /package/dist/{chunk-43SBT72G.js.map → chunk-I6UXRJ3Q.js.map} +0 -0
  131. /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
  132. /package/dist/{chunk-L3VJ47BU.js.map → chunk-KZDHMZ45.js.map} +0 -0
  133. /package/dist/{chunk-RHYNACZS.js.map → chunk-OZEPJGMA.js.map} +0 -0
  134. /package/dist/{chunk-NXNVTXKG.js.map → chunk-R6F6KFIL.js.map} +0 -0
  135. /package/dist/{chunk-7LKAMLV4.js.map → chunk-T6SCOJF4.js.map} +0 -0
  136. /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
  137. /package/dist/{chunk-3VEVGL74.js.map → chunk-VNBC3VXM.js.map} +0 -0
  138. /package/dist/{chunk-E45CSC33.js.map → chunk-XKWOJZZ4.js.map} +0 -0
  139. /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
  140. /package/dist/{chunk-4GLNY5V6.js.map → chunk-Y7GDG744.js.map} +0 -0
  141. /package/dist/{chunk-SR7F3TJY.js.map → chunk-YK5JEVLX.js.map} +0 -0
@@ -111,6 +111,17 @@ export class PgNotifyListener {
111
111
  private readonly backoffMaxMs: number;
112
112
  /** WARN-once gate so a flapping listener doesn't spam the log. */
113
113
  private warnedDown = false;
114
+ /**
115
+ * LISTEN-NOTIFY-2 — the in-flight `connect()` promise, set while a checkout is
116
+ * mid-`await`. `stop()` awaits it so a `stop()` that races a still-resolving
117
+ * `connect()` can't return before the connect either assigns `this.client`
118
+ * (then released by `releaseClient`) or self-releases the checked-out client.
119
+ * Without this, a `stop()` arriving during `pool.connect()`'s await saw
120
+ * `this.client === null` (nothing to release), then `connect()` resumed,
121
+ * assigned the client, and issued `LISTEN` — leaking an ESTABLISHED socket
122
+ * holding `LISTEN <channel>` forever past `app.close()`.
123
+ */
124
+ private connecting: Promise<void> | null = null;
114
125
 
115
126
  constructor(private readonly opts: PgNotifyListenerOptions) {
116
127
  this.logger = new Logger(`PgNotifyListener(${opts.label})`);
@@ -125,20 +136,56 @@ export class PgNotifyListener {
125
136
  await this.connect();
126
137
  }
127
138
 
128
- /** Stop listening + release the connection. Safe to call repeatedly. */
139
+ /**
140
+ * Stop listening + release the connection. Safe to call repeatedly and
141
+ * race-safe against an in-flight `connect()` (LISTEN-NOTIFY-2): it sets
142
+ * `stopped` first (so a resuming `connect()` self-releases its checkout),
143
+ * then awaits any in-flight connect, then releases whatever client landed.
144
+ */
129
145
  async stop(): Promise<void> {
130
146
  this.stopped = true;
131
147
  if (this.reconnectTimer) {
132
148
  clearTimeout(this.reconnectTimer);
133
149
  this.reconnectTimer = null;
134
150
  }
151
+ // Await an in-flight checkout so we don't return while a client is still
152
+ // mid-`pool.connect()`. The resuming `connect()` sees `stopped` and either
153
+ // self-releases its checkout or assigns `this.client`; either way the
154
+ // `releaseClient()` below mops up.
155
+ const inflight = this.connecting;
156
+ if (inflight) {
157
+ try {
158
+ await inflight;
159
+ } catch {
160
+ // connect failures are handled inside connect(); ignore here.
161
+ }
162
+ }
135
163
  await this.releaseClient();
136
164
  }
137
165
 
138
166
  private async connect(): Promise<void> {
139
167
  if (this.stopped) return;
168
+ // Track this checkout so a racing stop() can await it (LISTEN-NOTIFY-2).
169
+ const attempt = this.doConnect();
170
+ this.connecting = attempt;
171
+ try {
172
+ await attempt;
173
+ } finally {
174
+ if (this.connecting === attempt) this.connecting = null;
175
+ }
176
+ }
177
+
178
+ private async doConnect(): Promise<void> {
140
179
  try {
141
180
  const client = await this.opts.pool.connect();
181
+ // Re-check AFTER the await resolves: a stop() may have fired while this
182
+ // checkout was in flight. If so, release the just-checked-out client
183
+ // right here and bail BEFORE wiring handlers / issuing LISTEN — otherwise
184
+ // we'd leak an ESTABLISHED listener socket past shutdown (LISTEN-NOTIFY-2).
185
+ if (this.stopped) {
186
+ await this.releaseRawClient(client);
187
+ return;
188
+ }
142
189
  client.on('notification', (msg) => {
143
190
  if (msg.channel !== this.opts.channel) return;
144
191
  try {
@@ -154,6 +201,11 @@ export class PgNotifyListener {
154
201
  this.handleDrop();
155
202
  });
156
203
  await client.query(`LISTEN ${this.opts.channel}`);
204
+ // A stop() could have fired during the LISTEN round-trip too — same guard.
205
+ if (this.stopped) {
206
+ await this.releaseRawClient(client);
207
+ return;
208
+ }
157
209
  this.client = client;
158
210
  // Recovery: only announce if we had previously warned about being down.
159
211
  if (this.warnedDown) {
@@ -202,11 +254,19 @@ export class PgNotifyListener {
202
254
  const client = this.client;
203
255
  this.client = null;
204
256
  if (!client) return;
257
+ await this.releaseRawClient(client);
258
+ }
259
+
260
+ /**
261
+ * Tear down a raw checked-out client (LISTEN-NOTIFY-2). Used both by the
262
+ * normal `releaseClient()` path and by the connect-vs-stop race bail-outs,
263
+ * where the client was checked out but never assigned to `this.client`.
264
+ * Destroys (`release(true)`) so a half-listening socket is never reused.
265
+ */
266
+ private async releaseRawClient(client: PgListenClient): Promise<void> {
205
267
  try {
206
268
  client.removeAllListeners?.('notification');
207
269
  client.removeAllListeners?.('error');
208
- // A listener client is a checked-out pool connection; release it back
209
- // with `release(true)` (destroy) so a half-broken socket isn't reused.
210
270
  if (client.release) client.release(true);
211
271
  else if (client.end) await client.end();
212
272
  } catch {
@@ -27,3 +27,20 @@ integration:
27
27
  # Enabling post-install requires a reinstall (`subsystem install integration
28
28
  # --force --force-config`) plus an Atlas migration.
29
29
  multi_tenant: false
30
+
31
+ # ── Default-differ tuning (DIFFER-UNIGNORE) ──
32
+ # Threaded into the `DeepEqualDiffer` bound to INTEGRATION_FIELD_DIFFER.
33
+ # Off-by-default — omit the whole `differ:` block for the historical
34
+ # behaviour (the built-in ignore list, unchanged). A feature module that
35
+ # binds its own `IFieldDiffer<T>` overrides this entirely.
36
+ #
37
+ # differ:
38
+ # # Extra field names to ALSO ignore (merged with the defaults).
39
+ # ignore: [internalSeq]
40
+ # # Default-ignored field names to RE-INCLUDE as domain data. The canonical
41
+ # # case: an entity with `softDelete: false` whose `deletedAt` carries a
42
+ # # vendor-observed retraction tombstone ON the canonical record. Without
43
+ # # this the tombstone overlay diffs to 'noop', the upsert is skipped, and
44
+ # # `deleted_at` never lands. `unignore` is subtracted after `ignore`, so it
45
+ # # wins on a field listed in both.
46
+ # unignore: [deletedAt]
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/integration/deep-equal.differ.ts"],"sourcesContent":["/**\n * DeepEqualDiffer — default `IFieldDiffer<T>` for the integration subsystem (SYNC-5).\n *\n * Walks every field of `incoming` against `existing`, emitting a structured\n * per-field diff (`{ from, to }`) for every field whose value changed.\n * Returns `'noop'` when the record is unchanged.\n *\n * Design decisions (extracted from the upstream consumer + HS-9 findings):\n *\n * 1. **Ignore list** — row metadata that sinks/services stamp unconditionally\n * so upstream cannot reasonably disagree:\n * `id`, `createdAt`, `updatedAt`, `deletedAt`, `type`,\n * `lastModifiedAt`, `fields`, `providerMetadata`\n * (`fields` is the EAV bag — it's diffed by the sink's EAV dual-write\n * path, not at the canonical-record layer.)\n *\n * 2. **`providerChangedFields` hint (CDC)** — when present, restricts the\n * comparison to the hinted field set. The hint is advisory; fields in\n * the ignore list are still filtered out even when hinted. Provider\n * hints are field-NAME-level; they don't override the ignore rules.\n *\n * 3. **Date → ISO string** — `Date` instances are normalized to\n * `toISOString()` before comparison. Sinks return `Date` from the DB\n * driver; adapters typically deliver strings. Direct `===` would\n * always say \"changed.\"\n *\n * 4. **Decimal-string vs number** — Postgres `numeric` columns return as\n * strings through Drizzle; adapters deliver numbers. When one side is a\n * number and the other is a numeric string that parses to the same\n * number, they're equal. The normalizer does NOT coerce non-numeric\n * strings, and it preserves zero-vs-null distinction.\n *\n * 5. **null-existing path** — `diff(null, incoming)` produces a full\n * created-shape diff (`{from: null, to: <value>}` for every non-ignored\n * field). Orchestrator sees this and records `operation: 'created'`.\n */\nimport { Injectable } from '@nestjs/common';\nimport type {\n DiffResult,\n FieldDiff,\n IFieldDiffer,\n} from './integration-field-diff.protocol';\n\n/**\n * Default ignore list. Keep in integration with consumer canonical-record shapes —\n * adding a row-metadata field here means no integration will ever mark it changed.\n *\n * Includes the columns contributed by the `external_id_tracking` behavior\n * (`external_id`/`externalId`, `provider`, `provider_metadata`/`providerMetadata`).\n * These are integration-tracking metadata, not domain attributes: they ride on the\n * canonical record but must never register as a field change (the external id\n * is the record's identity, not a mutable value). Listed in both snake_case\n * and camelCase so the differ ignores them regardless of the consumer's\n * canonical projection casing.\n */\nconst DEFAULT_IGNORE_FIELDS: ReadonlySet<string> = new Set([\n 'id',\n 'createdAt',\n 'updatedAt',\n 'deletedAt',\n 'type',\n 'lastModifiedAt',\n 'fields',\n 'external_id',\n 'externalId',\n 'provider',\n 'provider_metadata',\n 'providerMetadata',\n]);\n\nexport interface DeepEqualDifferOptions {\n /**\n * Extra field names to ignore in addition to the defaults. Consumers can\n * pass `['integration_version']` etc. to augment the base list; values here are\n * merged (not replaced) with `DEFAULT_IGNORE_FIELDS`.\n */\n readonly ignore?: readonly string[];\n}\n\n@Injectable()\nexport class DeepEqualDiffer<T extends Record<string, unknown>>\n implements IFieldDiffer<T>\n{\n private readonly ignore: ReadonlySet<string>;\n\n constructor(opts: DeepEqualDifferOptions = {}) {\n if (opts.ignore && opts.ignore.length > 0) {\n this.ignore = new Set([...DEFAULT_IGNORE_FIELDS, ...opts.ignore]);\n } else {\n this.ignore = DEFAULT_IGNORE_FIELDS;\n }\n }\n\n diff(\n existing: T | null,\n incoming: T,\n providerChangedFields?: string[],\n ): DiffResult {\n // Created-shape: every non-ignored field becomes `{from: null, to}`.\n if (existing === null) {\n const out: FieldDiff = {};\n for (const key of Object.keys(incoming)) {\n if (this.ignore.has(key)) continue;\n const value = (incoming as Record<string, unknown>)[key];\n // Skip fields that are themselves null/undefined — a created record\n // doesn't need to declare \"this field is null now\" for every\n // untouched column.\n if (value === null || value === undefined) continue;\n out[key] = { from: null, to: value };\n }\n return Object.keys(out).length === 0 ? 'noop' : out;\n }\n\n // Field set to compare. `providerChangedFields` narrows to a hint set;\n // ignored fields are filtered out regardless of hint.\n const candidates = new Set<string>();\n if (providerChangedFields && providerChangedFields.length > 0) {\n for (const key of providerChangedFields) {\n if (!this.ignore.has(key)) candidates.add(key);\n }\n } else {\n for (const key of Object.keys(incoming)) {\n if (!this.ignore.has(key)) candidates.add(key);\n }\n // Also include keys that exist on existing but not on incoming —\n // e.g. a field that was cleared. This would otherwise be missed when\n // incoming carries an undefined column we drop from the iteration.\n for (const key of Object.keys(existing)) {\n if (this.ignore.has(key)) continue;\n if (!(key in (incoming as Record<string, unknown>))) continue;\n candidates.add(key);\n }\n }\n\n const out: FieldDiff = {};\n for (const key of candidates) {\n const before = (existing as Record<string, unknown>)[key];\n const after = (incoming as Record<string, unknown>)[key];\n if (!isEqual(before, after)) {\n out[key] = { from: before ?? null, to: after ?? null };\n }\n }\n\n return Object.keys(out).length === 0 ? 'noop' : out;\n }\n}\n\n// ─── equality helpers ───────────────────────────────────────────────────────\n\n/**\n * Field-level equality with the canonical-integration normalizations:\n * - Date → toISOString (adapters deliver strings)\n * - numeric-string vs number → numeric equality when both parse\n * - deep equality for plain objects/arrays (single-level is enough for\n * canonical records; nested records travel as jsonb columns where the\n * sink already owns the comparison)\n */\nfunction isEqual(a: unknown, b: unknown): boolean {\n if (a === b) return true;\n\n const na = normalize(a);\n const nb = normalize(b);\n if (na === nb) return true;\n\n // After normalization: both may still be non-primitive objects.\n if (\n typeof na === 'object' &&\n typeof nb === 'object' &&\n na !== null &&\n nb !== null\n ) {\n return deepEqualObject(na as Record<string, unknown>, nb as Record<string, unknown>);\n }\n\n // Numeric string ↔ number: when one side is a number and the other is a\n // string that parses to the same finite number.\n const numericEqual = maybeNumericEqual(na, nb) || maybeNumericEqual(nb, na);\n return numericEqual;\n}\n\nfunction normalize(value: unknown): unknown {\n if (value instanceof Date) return value.toISOString();\n return value;\n}\n\nfunction maybeNumericEqual(a: unknown, b: unknown): boolean {\n // a is string-shape, b is number — parse a and compare. Only when the\n // string looks numeric AND the parse round-trips (no silent NaN pass-\n // through on non-numeric strings).\n if (typeof a !== 'string' || typeof b !== 'number') return false;\n if (a.trim() === '') return false;\n const parsed = Number(a);\n if (!Number.isFinite(parsed)) return false;\n return parsed === b;\n}\n\nfunction deepEqualObject(\n a: Record<string, unknown>,\n b: Record<string, unknown>,\n): boolean {\n if (Array.isArray(a) !== Array.isArray(b)) return false;\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n if (aKeys.length !== bKeys.length) return false;\n for (const key of aKeys) {\n if (!(key in b)) return false;\n if (!isEqual(a[key], b[key])) return false;\n }\n return true;\n}\n"],"mappings":"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","names":["out"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-orchestrator.memory-backend.ts"],"sourcesContent":["/**\n * MemoryJobOrchestrator — in-process implementation of `IJobOrchestrator`\n * (ADR-022, JOB-4).\n *\n * Exists solely for the unit test suite: reproduces the Drizzle backend's\n * observable behaviour (claim ordering, collision modes, dedupe collapse,\n * memoization cache, replay row-clearing, cascade cancel) without a\n * database. Not production — the single-process mutex is a substitute for\n * Postgres' `FOR UPDATE SKIP LOCKED`; acceptable non-parity is listed in\n * `docs/specs/JOB-4.md` (fsync, query perf, multi-process claim).\n *\n * The `MemoryJobStore` is shared with `MemoryJobRunService` /\n * `MemoryJobStepService` — all three services mutate the same Maps under\n * the orchestrator's mutex.\n */\nimport { randomUUID } from 'node:crypto';\nimport { Inject, Injectable, Logger, Optional } from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport type {\n JobDefinitionRow,\n JobRunRow,\n} from './job-orchestration.schema';\nimport type {\n CancelOptions,\n IJobOrchestrator,\n JobPoolDef,\n JobRun,\n JobUpsertEntry,\n StartOptions,\n} from './job-orchestrator.protocol';\nimport type {\n JobContext,\n JobHandlerBase,\n JobHandlerMeta,\n RetryPolicy,\n SpawnChildOptions,\n StepOptions,\n} from './job-handler.base';\nimport { ParentClosePolicy } from './job-handler.base';\nimport {\n JobCollisionError,\n JobNotReplayableError,\n JobTemplateFieldMissingError,\n JobTypeNotFoundError,\n MissingTenantIdError,\n} from './jobs-errors';\nimport { MemoryJobStore } from './memory-job-store';\nimport { MemoryJobStepService } from './job-step-service.memory-backend';\nimport { JOBS_MULTI_TENANT } from './jobs-domain.tokens';\n\n/**\n * Sentinel `run_at` for runs that lost the `queue` collision — they stay\n * unclaimable until the incumbent transitions terminal and the orchestrator\n * advances their `run_at` back to `now()`. Mirrors the Drizzle backend's\n * `claim-time gate` behaviour without requiring a separate claim query.\n */\nconst QUEUED_RUN_AT = new Date(8_640_000_000_000_000); // \"distant future\"\nconst TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'completed',\n 'failed',\n 'timed_out',\n 'canceled',\n];\nconst DEDUPE_EXCLUDED_STATUSES: JobRunRow['status'][] = ['canceled', 'failed'];\nconst IN_FLIGHT_STATUSES: JobRunRow['status'][] = ['pending', 'running'];\n\nfunction isTerminal(status: JobRunRow['status']): boolean {\n return TERMINAL_STATUSES.includes(status);\n}\n\n/**\n * Mirror of `evaluateKeyTemplate` in the Drizzle backend. Kept private here\n * rather than exported so the memory backend has no dependency on the\n * Drizzle module.\n */\nfunction evaluateKeyTemplate(\n template: string,\n input: Record<string, unknown>,\n): string {\n return template.replace(\n /\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g,\n (_m, field: string) => {\n const value = input[field];\n if (value === undefined || value === null) {\n throw new JobTemplateFieldMissingError(template, field);\n }\n return String(value);\n },\n );\n}\n\n/**\n * Single-promise-chain mutex. Every mutating op on the store goes through\n * `run(...)` so two concurrent `start` calls observe the same sequential\n * consistency Postgres gives us via `FOR UPDATE SKIP LOCKED`. Error\n * swallowing on the chain pointer prevents one failed call from poisoning\n * the queue for subsequent callers.\n *\n * Kept private to this file on purpose — the spec explicitly forbids\n * exporting this; it exists only for the memory backend's internal\n * serialisation.\n */\nclass PromiseMutex {\n private queue: Promise<void> = Promise.resolve();\n\n async run<T>(fn: () => Promise<T>): Promise<T> {\n const next = this.queue.then(() => fn());\n // Swallow errors on the chain pointer so a throwing `fn` doesn't\n // permanently reject every future caller.\n this.queue = next.then(\n () => undefined,\n () => undefined,\n );\n return next;\n }\n}\n\n/** Handler registry entry — class + frozen metadata. */\ninterface HandlerRegistration {\n type: string;\n meta: JobHandlerMeta<unknown>;\n handlerClass: new (...args: unknown[]) => JobHandlerBase<unknown>;\n}\n\n@Injectable()\nexport class MemoryJobOrchestrator implements IJobOrchestrator {\n private readonly logger = new Logger(MemoryJobOrchestrator.name);\n private readonly mutex = new PromiseMutex();\n private readonly handlerRegistry = new Map<string, HandlerRegistration>();\n\n /**\n * `runId → dependent runId[]` — when a run with `concurrencyKey = K`\n * blocks on an incumbent, its id is added here under the incumbent's id.\n * On incumbent terminal transition we advance every dependent's `runAt`\n * back to `now()` so it becomes claimable.\n */\n private readonly queueBlockers = new Map<string, string[]>();\n\n constructor(\n // ADR-037 (package-mode DI): explicit `@Inject` tokens on every param —\n // the published bundle has no `design:paramtypes` metadata (built without\n // `emitDecoratorMetadata`), so by-type injection would resolve to\n // `undefined` in package mode. Class tokens (`MemoryJobStore`,\n // `MemoryJobStepService`, `ModuleRef`) are passed to `@Inject` explicitly.\n @Inject(MemoryJobStore) private readonly store: MemoryJobStore,\n @Inject(MemoryJobStepService) private readonly stepService: MemoryJobStepService,\n @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,\n @Optional() @Inject(ModuleRef) private readonly moduleRef?: ModuleRef,\n ) {}\n\n /**\n * JOB-8 — mirror of the Drizzle backend's `resolveTenantId`. Returns the\n * value to stamp on `tenant_id` / compare against in memory predicates.\n * Off → always `null`. On + `undefined` → throw. On + `null`/string → pass.\n */\n private resolveTenantId(\n method: string,\n tenantId: string | null | undefined,\n ): string | null {\n if (!this.multiTenant) return null;\n if (tenantId === undefined) throw new MissingTenantIdError(method);\n return tenantId;\n }\n\n // ==========================================================================\n // registerHandler — replaces Drizzle's `job` table upsert\n // ==========================================================================\n\n /**\n * Populate the in-memory job definition row plus handler class lookup.\n * Called by `JobWorkerModule.onModuleInit` in memory mode, or directly by\n * unit tests that want to seed the registry without NestJS.\n */\n registerHandler<TInput>(\n type: string,\n meta: JobHandlerMeta<TInput>,\n handlerClass: new (...args: unknown[]) => JobHandlerBase<TInput>,\n ): void {\n const concurrencyKeyTemplate =\n (meta.concurrency as { key?: string } | undefined)?.key ?? null;\n const dedupeKeyTemplate =\n (meta.dedupe as { key?: string } | undefined)?.key ?? null;\n const dedupeWindowMs = meta.dedupe?.windowMs ?? null;\n const now = new Date();\n\n const def: JobDefinitionRow = {\n type,\n version: 1,\n pool: meta.pool ?? 'batch',\n scopeEntityType: meta.scope?.entity ?? null,\n retryPolicy: meta.retry ?? {\n attempts: 1,\n backoff: 'fixed',\n baseMs: 0,\n },\n timeoutMs: meta.timeoutMs ?? null,\n concurrencyKeyTemplate:\n typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null,\n collisionMode:\n (meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??\n 'queue',\n dedupeKeyTemplate:\n typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null,\n dedupeWindowMs,\n priorityDefault: 0,\n replayFrom: meta.replayFrom ?? 'last_checkpoint',\n createdAt: now,\n updatedAt: now,\n };\n\n this.store.jobs.set(type, def);\n this.handlerRegistry.set(type, {\n type,\n meta: meta as JobHandlerMeta<unknown>,\n handlerClass: handlerClass as unknown as new (\n ...args: unknown[]\n ) => JobHandlerBase<unknown>,\n });\n }\n\n /** Test helper — look up a registered handler without exposing the map. */\n getHandlerRegistration(type: string): HandlerRegistration | undefined {\n return this.handlerRegistry.get(type);\n }\n\n /**\n * Boot-time upsert per `IJobOrchestrator.upsertJobRows`. Memory backend\n * just funnels each entry through `registerHandler`. The validator is\n * skipped entirely in memory mode (Q4 resolution 2026-04-19), so the\n * orphaned list is always empty — there are no DB rows to compare against.\n */\n async upsertJobRows(\n entries: JobUpsertEntry[],\n poolConfig: ReadonlyMap<string, JobPoolDef>,\n ): Promise<{ orphaned: string[] }> {\n void poolConfig; // pool validation is the module's responsibility\n for (const entry of entries) {\n this.registerHandler(\n entry.type,\n entry.meta as JobHandlerMeta<unknown>,\n entry.handlerClass as new (...args: unknown[]) => JobHandlerBase<unknown>,\n );\n }\n return { orphaned: [] };\n }\n\n // ==========================================================================\n // start\n // ==========================================================================\n\n async start(\n type: string,\n input: unknown,\n opts: StartOptions = {},\n // BRIDGE-7: signature parity with Drizzle backend. The memory backend\n // has no real transactions (its \"atomic\" boundary is a process-wide\n // mutex acquired by the body below), so the parameter is intentionally\n // ignored. Accepting it lets EventFlowService unit tests exercise the\n // same code path without two stub orchestrators.\n _tx?: unknown,\n ): Promise<JobRun> {\n // JOB-8 — resolve tenant gate outside the mutex so the error throws\n // synchronously-ish from the caller's stack rather than via the mutex's\n // deferred chain (matches Drizzle backend's pre-transaction guard).\n const tenantId = this.resolveTenantId('start', opts.tenantId);\n\n return this.mutex.run(async () => {\n const payload = (input ?? {}) as Record<string, unknown>;\n const definition = this.store.jobs.get(type);\n if (!definition) throw new JobTypeNotFoundError(type);\n\n // 1. Dedupe — return existing non-excluded run within the window.\n if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {\n const dedupeKey = evaluateKeyTemplate(\n definition.dedupeKeyTemplate,\n payload,\n );\n const windowStart = Date.now() - definition.dedupeWindowMs;\n const existing = this.findDedupeCandidate(type, dedupeKey, windowStart);\n if (existing) return existing;\n }\n\n // 2. Concurrency collision check.\n let concurrencyKey: string | null = null;\n let queueBlockedBy: string | null = null;\n if (definition.concurrencyKeyTemplate) {\n concurrencyKey = evaluateKeyTemplate(\n definition.concurrencyKeyTemplate,\n payload,\n );\n const incumbent = this.findInFlightByConcurrencyKey(concurrencyKey);\n if (incumbent) {\n switch (definition.collisionMode) {\n case 'reject':\n throw new JobCollisionError(type, concurrencyKey, incumbent);\n case 'replace':\n // Cancel incumbent (cascading children). Must happen inside\n // the mutex — call the internal helper, not public `cancel()`\n // (public `cancel` would re-enter the mutex and deadlock).\n // Internal replace path sidesteps the tenant gate — it uses\n // the incumbent's own tenant (same concurrency key implies\n // same tenant in practice, but the gate is bypassed via\n // `incumbent.tenantId` to avoid accidental cross-tenant\n // MissingTenantIdError bubbling from the user's `start` call).\n this.cancelLocked(\n incumbent.id,\n { cascade: true, reason: 'replaced' },\n incumbent.tenantId,\n );\n break;\n case 'queue':\n queueBlockedBy = incumbent.id;\n break;\n }\n }\n }\n\n // 3. Resolve lineage.\n const newId = randomUUID();\n let rootRunId: string = newId;\n if (opts.parentRunId) {\n const parent = this.store.runs.get(opts.parentRunId);\n if (!parent) {\n throw new Error(\n `parentRunId ${opts.parentRunId} does not reference an existing job_run`,\n );\n }\n rootRunId = parent.rootRunId;\n }\n\n // 4. Compute dedupe key for the persisted row (separate from dedupe\n // short-circuit above — we store it even when no prior run matched\n // so future dedupe checks see it).\n const dedupeKey = definition.dedupeKeyTemplate\n ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)\n : null;\n\n const now = new Date();\n const runAt = queueBlockedBy\n ? QUEUED_RUN_AT\n : (opts.runAt ?? now);\n\n const row: JobRunRow = {\n id: newId,\n jobType: type,\n jobVersion: definition.version,\n parentRunId: opts.parentRunId ?? null,\n rootRunId,\n parentClosePolicy: opts.parentClosePolicy ?? 'terminate',\n scopeEntityType: opts.scope?.entityType ?? null,\n scopeEntityId: opts.scope?.entityId ?? null,\n tenantId,\n tags: opts.tags ?? {},\n pool: opts.pool ?? definition.pool,\n priority: opts.priority ?? definition.priorityDefault,\n concurrencyKey,\n dedupeKey,\n status: 'pending',\n input: payload,\n output: null,\n error: null,\n triggerSource: opts.triggerSource ?? 'manual',\n triggerRef: opts.triggerRef ?? null,\n runAt,\n startedAt: null,\n finishedAt: null,\n claimedAt: null,\n attempts: 0,\n waitKind: null,\n resumeToken: null,\n waitDeadline: null,\n createdAt: now,\n updatedAt: now,\n };\n\n this.store.runs.set(newId, row);\n if (queueBlockedBy) {\n const list = this.queueBlockers.get(queueBlockedBy) ?? [];\n list.push(newId);\n this.queueBlockers.set(queueBlockedBy, list);\n }\n return row;\n });\n }\n\n // ==========================================================================\n // cancel\n // ==========================================================================\n\n async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {\n // JOB-8 — strict tenant gate outside the mutex (matches Drizzle path).\n const tenantId = this.resolveTenantId('cancel', opts.tenantId);\n await this.mutex.run(async () => {\n this.cancelLocked(runId, opts, tenantId);\n });\n }\n\n /**\n * Internal cancel that assumes the caller already holds the mutex.\n * Synchronous because all store ops are in-memory. Idempotent.\n *\n * `tenantForGate` is the already-validated tenant id (or `null`). When\n * non-null it gates the initial cancellation to that tenant's run; the\n * cascade step then sweeps descendants on the same `rootRunId` without\n * re-checking — children of a tenant-gated parent always share the\n * tenant (enforced at `start` time).\n */\n private cancelLocked(\n runId: string,\n opts: CancelOptions,\n tenantForGate: string | null,\n ): void {\n const run = this.store.runs.get(runId);\n if (!run) return;\n // JOB-8 — cross-tenant cancel is silent no-op.\n if (this.multiTenant && run.tenantId !== tenantForGate) return;\n if (isTerminal(run.status)) return;\n\n const now = new Date();\n\n // Collect descendants up front so Cancel-policy parents can wait on\n // children (their `finished_at` is set after children transition).\n const descendants =\n opts.cascade === false\n ? []\n : Array.from(this.store.runs.values()).filter(\n (r) =>\n r.rootRunId === run.rootRunId &&\n r.id !== runId &&\n !isTerminal(r.status),\n );\n\n // Group by policy stored on the child.\n const terminateChildren = descendants.filter(\n (d) => d.parentClosePolicy === ParentClosePolicy.Terminate,\n );\n const cancelChildren = descendants.filter(\n (d) => d.parentClosePolicy === ParentClosePolicy.Cancel,\n );\n // 'abandon' → do nothing.\n\n // Terminate policy: cancel children, then parent.\n for (const child of terminateChildren) {\n this.transitionToCanceled(child.id, now);\n }\n\n // Cancel policy: cancel children first, then parent (so parent's\n // finished_at is set only after children transitioned).\n for (const child of cancelChildren) {\n this.transitionToCanceled(child.id, now);\n }\n\n this.transitionToCanceled(runId, now);\n\n void opts.reason; // reserved for future audit logging\n }\n\n private transitionToCanceled(runId: string, at: Date): void {\n const run = this.store.runs.get(runId);\n if (!run) return;\n if (isTerminal(run.status)) return;\n const next: JobRunRow = {\n ...run,\n status: 'canceled',\n finishedAt: at,\n updatedAt: at,\n };\n this.store.runs.set(runId, next);\n this.unblockQueuedDependents(runId);\n }\n\n /**\n * When `runId` transitions to a terminal state, advance every dependent\n * `queue`-blocked run's `run_at` back to `now()` so `claimNext` picks\n * them up.\n */\n private unblockQueuedDependents(runId: string): void {\n const dependents = this.queueBlockers.get(runId);\n if (!dependents || dependents.length === 0) return;\n const now = new Date();\n for (const dep of dependents) {\n const depRun = this.store.runs.get(dep);\n if (!depRun) continue;\n if (depRun.status !== 'pending') continue;\n this.store.runs.set(dep, { ...depRun, runAt: now, updatedAt: now });\n }\n this.queueBlockers.delete(runId);\n }\n\n // ==========================================================================\n // claimNext — consumed by JobWorker in memory mode (tests exercise directly)\n // ==========================================================================\n\n async claimNext(pool: string): Promise<JobRunRow | null> {\n return this.mutex.run(async () => {\n const now = Date.now();\n const candidates = Array.from(this.store.runs.values()).filter(\n (r) =>\n r.status === 'pending' &&\n r.pool === pool &&\n r.runAt.getTime() <= now,\n );\n if (candidates.length === 0) return null;\n\n // ORDER BY priority DESC, run_at ASC (Drizzle parity).\n candidates.sort((a, b) => {\n if (a.priority !== b.priority) return b.priority - a.priority;\n return a.runAt.getTime() - b.runAt.getTime();\n });\n\n const winner = candidates[0]!;\n const claimedAt = new Date();\n const next: JobRunRow = {\n ...winner,\n status: 'running',\n claimedAt,\n startedAt: claimedAt,\n updatedAt: claimedAt,\n };\n this.store.runs.set(winner.id, next);\n return next;\n });\n }\n\n // ==========================================================================\n // replay\n // ==========================================================================\n\n async replay(runId: string): Promise<JobRun> {\n return this.mutex.run(async () => {\n const run = this.store.runs.get(runId);\n if (!run) throw new Error(`replay: run ${runId} not found`);\n if (!isTerminal(run.status)) {\n throw new JobNotReplayableError(runId, run.status);\n }\n const def = this.store.jobs.get(run.jobType);\n if (!def) throw new JobTypeNotFoundError(run.jobType);\n\n const mode = def.replayFrom;\n if (mode === 'scratch') {\n this.stepService.clearStepsForRun(runId);\n } else {\n // `last_step` and `last_checkpoint` collapse to the same semantic\n // in Phase 1 — delete non-completed rows, preserve memoized ones.\n // Matches the Drizzle backend exactly (see JOB-3 notes).\n this.stepService.clearIncompleteSteps(runId);\n }\n\n const now = new Date();\n const next: JobRunRow = {\n ...run,\n status: 'pending',\n attempts: 0,\n runAt: now,\n startedAt: null,\n finishedAt: null,\n claimedAt: null,\n error: null,\n output: null,\n updatedAt: now,\n };\n this.store.runs.set(runId, next);\n return next;\n });\n }\n\n // ==========================================================================\n // tick — used by unit tests + memory-mode JobWorker\n // ==========================================================================\n\n /**\n * Execute a single claimed run to completion, retry, or failure. Not on\n * `IJobOrchestrator` — it's the memory equivalent of the Drizzle\n * `JobWorker.processRun` code path. The unit tests drive it directly so\n * they can assert memoization across ticks without spinning up a worker.\n */\n async tick(runId: string): Promise<void> {\n // We load state outside the mutex because handler execution cannot\n // hold the serialisation lock — `fn()` inside `ctx.step` can call back\n // into `start` / `spawnChild` which would deadlock. Mutation points\n // (recordStep, status transition) go through the services or the\n // orchestrator entry points and re-enter the mutex there.\n const run = this.store.runs.get(runId);\n if (!run) throw new Error(`tick: run ${runId} not found`);\n if (run.status !== 'running') {\n throw new Error(\n `tick: run ${runId} must be 'running' (got '${run.status}')`,\n );\n }\n\n const registration = this.handlerRegistry.get(run.jobType);\n if (!registration) {\n await this.markFailed(run, new Error(\n `No handler registered for jobType='${run.jobType}'`,\n ), (run.attempts ?? 0) + 1);\n return;\n }\n const meta = registration.meta;\n const HandlerClass = registration.handlerClass;\n // Match the Drizzle backend: resolve the handler through Nest's\n // ModuleRef so `@Inject` constructor params work. ModuleRef is\n // @Optional() — zero-dep test stubs that construct this orchestrator\n // manually still hit the legacy `new HandlerClass()` path.\n // `get({ strict: false })` (not `create()`) — the handler must be a\n // provider in its owning module so cross-module @Inject dependencies\n // resolve. See job-worker.ts for the full rationale.\n const handler = this.moduleRef\n ? (this.moduleRef.get(\n HandlerClass as unknown as new (...args: unknown[]) => unknown,\n { strict: false },\n ) as JobHandlerBase<unknown>)\n : new HandlerClass();\n\n const ctx: JobContext<unknown> = {\n input: run.input,\n run: run as JobRun,\n step: this.makeStepFn(run),\n spawnChild: this.makeSpawnFn(run),\n logger: new Logger(`JobRun:${run.id}`),\n };\n\n const attemptsBefore = run.attempts ?? 0;\n try {\n const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;\n await this.markCompleted(run, output ?? {}, attemptsBefore + 1);\n } catch (err) {\n const policy = meta.retry;\n const decision = classifyError(err, policy, attemptsBefore);\n const nextAttempts = attemptsBefore + 1;\n if (decision === 'retry' && policy) {\n const delay = computeBackoff(policy, nextAttempts);\n await this.rescheduleForRetry(run, err, nextAttempts, delay);\n } else {\n await this.markFailed(run, err, nextAttempts);\n }\n }\n }\n\n private makeStepFn(run: JobRunRow) {\n return async <TOutput>(\n stepId: string,\n fn: () => Promise<TOutput>,\n _opts?: StepOptions,\n ): Promise<TOutput> => {\n void _opts;\n const existing = await this.stepService.findStep(run.id, stepId);\n if (existing?.status === 'completed') {\n return existing.output as TOutput;\n }\n const seq = this.nextStepSeq(run.id);\n const startedAt = new Date();\n const nextAttempts = (existing?.attempts ?? 0) + 1;\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'running',\n startedAt,\n attempts: nextAttempts,\n });\n try {\n const output = await fn();\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'completed',\n output: output as Record<string, unknown> | undefined,\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n return output;\n } catch (err) {\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'failed',\n error: serialiseError(err, nextAttempts, false),\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n throw err;\n }\n };\n }\n\n private makeSpawnFn(run: JobRunRow) {\n return async (\n type: string,\n input: unknown,\n opts?: SpawnChildOptions,\n ): Promise<JobRun> => {\n return this.start(type, input, {\n parentRunId: run.id,\n parentClosePolicy: opts?.closePolicy,\n runAt: opts?.runAt,\n priority: opts?.priority,\n tags: opts?.tags,\n triggerSource: 'parent',\n triggerRef: run.id,\n });\n };\n }\n\n private nextStepSeq(runId: string): number {\n const rows = this.store.steps.get(runId);\n if (!rows || rows.length === 0) return 1;\n let max = 0;\n for (const r of rows) if (r.seq > max) max = r.seq;\n return max + 1;\n }\n\n private async markCompleted(\n run: JobRunRow,\n output: Record<string, unknown>,\n attempts: number,\n ): Promise<void> {\n await this.mutex.run(async () => {\n const current = this.store.runs.get(run.id);\n if (!current || isTerminal(current.status)) return;\n const now = new Date();\n this.store.runs.set(run.id, {\n ...current,\n status: 'completed',\n output,\n finishedAt: now,\n updatedAt: now,\n attempts,\n });\n this.unblockQueuedDependents(run.id);\n });\n }\n\n private async markFailed(\n run: JobRunRow,\n err: unknown,\n attempts: number,\n ): Promise<void> {\n await this.mutex.run(async () => {\n const current = this.store.runs.get(run.id);\n if (!current || isTerminal(current.status)) return;\n const now = new Date();\n this.store.runs.set(run.id, {\n ...current,\n status: 'failed',\n finishedAt: now,\n updatedAt: now,\n attempts,\n error: serialiseError(err, attempts, false),\n });\n this.unblockQueuedDependents(run.id);\n });\n\n // parent_close_policy = 'terminate' cascade mirrors the Drizzle worker\n // (cancel runs outside its own terminal transition). We pass the run's\n // own `tenantId` so the cancel passes the multi-tenant gate — this is\n // system-internal cascade, not a user-initiated call.\n if (run.parentClosePolicy === 'terminate') {\n try {\n await this.cancel(run.id, {\n cascade: true,\n reason: 'parent-failed',\n tenantId: run.tenantId,\n });\n } catch (cascadeErr) {\n this.logger.warn(\n `cascade on failed run ${run.id}: ${(cascadeErr as Error).message}`,\n );\n }\n }\n }\n\n private async rescheduleForRetry(\n run: JobRunRow,\n err: unknown,\n attempts: number,\n delayMs: number,\n ): Promise<void> {\n await this.mutex.run(async () => {\n const current = this.store.runs.get(run.id);\n if (!current || isTerminal(current.status)) return;\n const now = new Date();\n this.store.runs.set(run.id, {\n ...current,\n status: 'pending',\n attempts,\n runAt: new Date(Date.now() + delayMs),\n startedAt: null,\n claimedAt: null,\n updatedAt: now,\n error: serialiseError(err, attempts, true),\n });\n });\n }\n\n // ==========================================================================\n // Internal queries — used by start / cancel\n // ==========================================================================\n\n private findDedupeCandidate(\n jobType: string,\n dedupeKey: string,\n windowStartMs: number,\n ): JobRunRow | null {\n let best: JobRunRow | null = null;\n for (const r of this.store.runs.values()) {\n if (r.jobType !== jobType) continue;\n if (r.dedupeKey !== dedupeKey) continue;\n if (DEDUPE_EXCLUDED_STATUSES.includes(r.status)) continue;\n if (r.createdAt.getTime() <= windowStartMs) continue;\n if (!best || r.createdAt.getTime() > best.createdAt.getTime()) {\n best = r;\n }\n }\n return best;\n }\n\n private findInFlightByConcurrencyKey(key: string): JobRunRow | null {\n for (const r of this.store.runs.values()) {\n if (r.concurrencyKey !== key) continue;\n if (!IN_FLIGHT_STATUSES.includes(r.status)) continue;\n return r;\n }\n return null;\n }\n}\n\n// ─── Pure helpers (mirrored from JobWorker so memory mode is standalone) ────\n\nfunction classifyError(\n err: unknown,\n policy: RetryPolicy | undefined,\n currentAttempts: number,\n): 'retry' | 'fail' {\n if (!policy) return 'fail';\n const errObj = err as { name?: string; code?: string } | undefined;\n const name = errObj?.name;\n const code = errObj?.code;\n const nonRetryable = policy.nonRetryableErrors ?? [];\n if (nonRetryable.some((n) => n === name || n === code)) return 'fail';\n if (currentAttempts + 1 >= policy.attempts) return 'fail';\n return 'retry';\n}\n\nfunction computeBackoff(policy: RetryPolicy, attempts: number): number {\n const base = Math.max(policy.baseMs, 0);\n if (policy.backoff === 'fixed') return base;\n const exponent = Math.max(attempts - 1, 0);\n if (exponent >= 53) return Number.MAX_SAFE_INTEGER;\n const raw = base * Math.pow(2, exponent);\n if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return raw;\n}\n\nfunction serialiseError(err: unknown, attempt: number, retryable: boolean) {\n const e = err as { message?: string; stack?: string } | undefined;\n return {\n message: (e?.message ?? String(err)) as string,\n stack: e?.stack,\n retryable,\n attempt,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAeA,SAAS,kBAAkB;AAC3B,SAAS,QAAQ,YAAY,QAAQ,gBAAgB;AACrD,SAAS,iBAAiB;AAuC1B,IAAM,gBAAgB,oBAAI,KAAK,MAAqB;AACpD,IAAM,oBAA2C;AAAA,EAC/C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AACA,IAAM,2BAAkD,CAAC,YAAY,QAAQ;AAC7E,IAAM,qBAA4C,CAAC,WAAW,SAAS;AAEvE,SAAS,WAAW,QAAsC;AACxD,SAAO,kBAAkB,SAAS,MAAM;AAC1C;AAOA,SAAS,oBACP,UACA,OACQ;AACR,SAAO,SAAS;AAAA,IACd;AAAA,IACA,CAAC,IAAI,UAAkB;AACrB,YAAM,QAAQ,MAAM,KAAK;AACzB,UAAI,UAAU,UAAa,UAAU,MAAM;AACzC,cAAM,IAAI,6BAA6B,UAAU,KAAK;AAAA,MACxD;AACA,aAAO,OAAO,KAAK;AAAA,IACrB;AAAA,EACF;AACF;AAaA,IAAM,eAAN,MAAmB;AAAA,EACT,QAAuB,QAAQ,QAAQ;AAAA,EAE/C,MAAM,IAAO,IAAkC;AAC7C,UAAM,OAAO,KAAK,MAAM,KAAK,MAAM,GAAG,CAAC;AAGvC,SAAK,QAAQ,KAAK;AAAA,MAChB,MAAM;AAAA,MACN,MAAM;AAAA,IACR;AACA,WAAO;AAAA,EACT;AACF;AAUO,IAAM,wBAAN,MAAwD;AAAA,EAa7D,YAM2C,OACM,aACH,aACI,WAChD;AAJyC;AACM;AACH;AACI;AAAA,EAC/C;AAAA,EAJwC;AAAA,EACM;AAAA,EACH;AAAA,EACI;AAAA,EArBjC,SAAS,IAAI,OAAO,sBAAsB,IAAI;AAAA,EAC9C,QAAQ,IAAI,aAAa;AAAA,EACzB,kBAAkB,oBAAI,IAAiC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQvD,gBAAgB,oBAAI,IAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBnD,gBACN,QACA,UACe;AACf,QAAI,CAAC,KAAK,YAAa,QAAO;AAC9B,QAAI,aAAa,OAAW,OAAM,IAAI,qBAAqB,MAAM;AACjE,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWA,gBACE,MACA,MACA,cACM;AACN,UAAM,yBACH,KAAK,aAA8C,OAAO;AAC7D,UAAM,oBACH,KAAK,QAAyC,OAAO;AACxD,UAAM,iBAAiB,KAAK,QAAQ,YAAY;AAChD,UAAM,MAAM,oBAAI,KAAK;AAErB,UAAM,MAAwB;AAAA,MAC5B;AAAA,MACA,SAAS;AAAA,MACT,MAAM,KAAK,QAAQ;AAAA,MACnB,iBAAiB,KAAK,OAAO,UAAU;AAAA,MACvC,aAAa,KAAK,SAAS;AAAA,QACzB,UAAU;AAAA,QACV,SAAS;AAAA,QACT,QAAQ;AAAA,MACV;AAAA,MACA,WAAW,KAAK,aAAa;AAAA,MAC7B,wBACE,OAAO,2BAA2B,WAAW,yBAAyB;AAAA,MACxE,eACG,KAAK,aAAa,iBACnB;AAAA,MACF,mBACE,OAAO,sBAAsB,WAAW,oBAAoB;AAAA,MAC9D;AAAA,MACA,iBAAiB;AAAA,MACjB,YAAY,KAAK,cAAc;AAAA,MAC/B,WAAW;AAAA,MACX,WAAW;AAAA,IACb;AAEA,SAAK,MAAM,KAAK,IAAI,MAAM,GAAG;AAC7B,SAAK,gBAAgB,IAAI,MAAM;AAAA,MAC7B;AAAA,MACA;AAAA,MACA;AAAA,IAGF,CAAC;AAAA,EACH;AAAA;AAAA,EAGA,uBAAuB,MAA+C;AACpE,WAAO,KAAK,gBAAgB,IAAI,IAAI;AAAA,EACtC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,cACJ,SACA,YACiC;AACjC,SAAK;AACL,eAAW,SAAS,SAAS;AAC3B,WAAK;AAAA,QACH,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,IACF;AACA,WAAO,EAAE,UAAU,CAAC,EAAE;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,MACJ,MACA,OACA,OAAqB,CAAC,GAMtB,KACiB;AAIjB,UAAM,WAAW,KAAK,gBAAgB,SAAS,KAAK,QAAQ;AAE5D,WAAO,KAAK,MAAM,IAAI,YAAY;AAChC,YAAM,UAAW,SAAS,CAAC;AAC3B,YAAM,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI;AAC3C,UAAI,CAAC,WAAY,OAAM,IAAI,qBAAqB,IAAI;AAGpD,UAAI,WAAW,qBAAqB,WAAW,gBAAgB;AAC7D,cAAMA,aAAY;AAAA,UAChB,WAAW;AAAA,UACX;AAAA,QACF;AACA,cAAM,cAAc,KAAK,IAAI,IAAI,WAAW;AAC5C,cAAM,WAAW,KAAK,oBAAoB,MAAMA,YAAW,WAAW;AACtE,YAAI,SAAU,QAAO;AAAA,MACvB;AAGA,UAAI,iBAAgC;AACpC,UAAI,iBAAgC;AACpC,UAAI,WAAW,wBAAwB;AACrC,yBAAiB;AAAA,UACf,WAAW;AAAA,UACX;AAAA,QACF;AACA,cAAM,YAAY,KAAK,6BAA6B,cAAc;AAClE,YAAI,WAAW;AACb,kBAAQ,WAAW,eAAe;AAAA,YAChC,KAAK;AACH,oBAAM,IAAI,kBAAkB,MAAM,gBAAgB,SAAS;AAAA,YAC7D,KAAK;AASH,mBAAK;AAAA,gBACH,UAAU;AAAA,gBACV,EAAE,SAAS,MAAM,QAAQ,WAAW;AAAA,gBACpC,UAAU;AAAA,cACZ;AACA;AAAA,YACF,KAAK;AACH,+BAAiB,UAAU;AAC3B;AAAA,UACJ;AAAA,QACF;AAAA,MACF;AAGA,YAAM,QAAQ,WAAW;AACzB,UAAI,YAAoB;AACxB,UAAI,KAAK,aAAa;AACpB,cAAM,SAAS,KAAK,MAAM,KAAK,IAAI,KAAK,WAAW;AACnD,YAAI,CAAC,QAAQ;AACX,gBAAM,IAAI;AAAA,YACR,eAAe,KAAK,WAAW;AAAA,UACjC;AAAA,QACF;AACA,oBAAY,OAAO;AAAA,MACrB;AAKA,YAAM,YAAY,WAAW,oBACzB,oBAAoB,WAAW,mBAAmB,OAAO,IACzD;AAEJ,YAAM,MAAM,oBAAI,KAAK;AACrB,YAAM,QAAQ,iBACV,gBACC,KAAK,SAAS;AAEnB,YAAM,MAAiB;AAAA,QACrB,IAAI;AAAA,QACJ,SAAS;AAAA,QACT,YAAY,WAAW;AAAA,QACvB,aAAa,KAAK,eAAe;AAAA,QACjC;AAAA,QACA,mBAAmB,KAAK,qBAAqB;AAAA,QAC7C,iBAAiB,KAAK,OAAO,cAAc;AAAA,QAC3C,eAAe,KAAK,OAAO,YAAY;AAAA,QACvC;AAAA,QACA,MAAM,KAAK,QAAQ,CAAC;AAAA,QACpB,MAAM,KAAK,QAAQ,WAAW;AAAA,QAC9B,UAAU,KAAK,YAAY,WAAW;AAAA,QACtC;AAAA,QACA;AAAA,QACA,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,eAAe,KAAK,iBAAiB;AAAA,QACrC,YAAY,KAAK,cAAc;AAAA,QAC/B;AAAA,QACA,WAAW;AAAA,QACX,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,UAAU;AAAA,QACV,UAAU;AAAA,QACV,aAAa;AAAA,QACb,cAAc;AAAA,QACd,WAAW;AAAA,QACX,WAAW;AAAA,MACb;AAEA,WAAK,MAAM,KAAK,IAAI,OAAO,GAAG;AAC9B,UAAI,gBAAgB;AAClB,cAAM,OAAO,KAAK,cAAc,IAAI,cAAc,KAAK,CAAC;AACxD,aAAK,KAAK,KAAK;AACf,aAAK,cAAc,IAAI,gBAAgB,IAAI;AAAA,MAC7C;AACA,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAO,OAAe,OAAsB,CAAC,GAAkB;AAEnE,UAAM,WAAW,KAAK,gBAAgB,UAAU,KAAK,QAAQ;AAC7D,UAAM,KAAK,MAAM,IAAI,YAAY;AAC/B,WAAK,aAAa,OAAO,MAAM,QAAQ;AAAA,IACzC,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYQ,aACN,OACA,MACA,eACM;AACN,UAAM,MAAM,KAAK,MAAM,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,IAAK;AAEV,QAAI,KAAK,eAAe,IAAI,aAAa,cAAe;AACxD,QAAI,WAAW,IAAI,MAAM,EAAG;AAE5B,UAAM,MAAM,oBAAI,KAAK;AAIrB,UAAM,cACJ,KAAK,YAAY,QACb,CAAC,IACD,MAAM,KAAK,KAAK,MAAM,KAAK,OAAO,CAAC,EAAE;AAAA,MACnC,CAAC,MACC,EAAE,cAAc,IAAI,aACpB,EAAE,OAAO,SACT,CAAC,WAAW,EAAE,MAAM;AAAA,IACxB;AAGN,UAAM,oBAAoB,YAAY;AAAA,MACpC,CAAC,MAAM,EAAE;AAAA,IACX;AACA,UAAM,iBAAiB,YAAY;AAAA,MACjC,CAAC,MAAM,EAAE;AAAA,IACX;AAIA,eAAW,SAAS,mBAAmB;AACrC,WAAK,qBAAqB,MAAM,IAAI,GAAG;AAAA,IACzC;AAIA,eAAW,SAAS,gBAAgB;AAClC,WAAK,qBAAqB,MAAM,IAAI,GAAG;AAAA,IACzC;AAEA,SAAK,qBAAqB,OAAO,GAAG;AAEpC,SAAK,KAAK;AAAA,EACZ;AAAA,EAEQ,qBAAqB,OAAe,IAAgB;AAC1D,UAAM,MAAM,KAAK,MAAM,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,IAAK;AACV,QAAI,WAAW,IAAI,MAAM,EAAG;AAC5B,UAAM,OAAkB;AAAA,MACtB,GAAG;AAAA,MACH,QAAQ;AAAA,MACR,YAAY;AAAA,MACZ,WAAW;AAAA,IACb;AACA,SAAK,MAAM,KAAK,IAAI,OAAO,IAAI;AAC/B,SAAK,wBAAwB,KAAK;AAAA,EACpC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ,wBAAwB,OAAqB;AACnD,UAAM,aAAa,KAAK,cAAc,IAAI,KAAK;AAC/C,QAAI,CAAC,cAAc,WAAW,WAAW,EAAG;AAC5C,UAAM,MAAM,oBAAI,KAAK;AACrB,eAAW,OAAO,YAAY;AAC5B,YAAM,SAAS,KAAK,MAAM,KAAK,IAAI,GAAG;AACtC,UAAI,CAAC,OAAQ;AACb,UAAI,OAAO,WAAW,UAAW;AACjC,WAAK,MAAM,KAAK,IAAI,KAAK,EAAE,GAAG,QAAQ,OAAO,KAAK,WAAW,IAAI,CAAC;AAAA,IACpE;AACA,SAAK,cAAc,OAAO,KAAK;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,UAAU,MAAyC;AACvD,WAAO,KAAK,MAAM,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,IAAI;AACrB,YAAM,aAAa,MAAM,KAAK,KAAK,MAAM,KAAK,OAAO,CAAC,EAAE;AAAA,QACtD,CAAC,MACC,EAAE,WAAW,aACb,EAAE,SAAS,QACX,EAAE,MAAM,QAAQ,KAAK;AAAA,MACzB;AACA,UAAI,WAAW,WAAW,EAAG,QAAO;AAGpC,iBAAW,KAAK,CAAC,GAAG,MAAM;AACxB,YAAI,EAAE,aAAa,EAAE,SAAU,QAAO,EAAE,WAAW,EAAE;AACrD,eAAO,EAAE,MAAM,QAAQ,IAAI,EAAE,MAAM,QAAQ;AAAA,MAC7C,CAAC;AAED,YAAM,SAAS,WAAW,CAAC;AAC3B,YAAM,YAAY,oBAAI,KAAK;AAC3B,YAAM,OAAkB;AAAA,QACtB,GAAG;AAAA,QACH,QAAQ;AAAA,QACR;AAAA,QACA,WAAW;AAAA,QACX,WAAW;AAAA,MACb;AACA,WAAK,MAAM,KAAK,IAAI,OAAO,IAAI,IAAI;AACnC,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAO,OAAgC;AAC3C,WAAO,KAAK,MAAM,IAAI,YAAY;AAChC,YAAM,MAAM,KAAK,MAAM,KAAK,IAAI,KAAK;AACrC,UAAI,CAAC,IAAK,OAAM,IAAI,MAAM,eAAe,KAAK,YAAY;AAC1D,UAAI,CAAC,WAAW,IAAI,MAAM,GAAG;AAC3B,cAAM,IAAI,sBAAsB,OAAO,IAAI,MAAM;AAAA,MACnD;AACA,YAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,OAAO;AAC3C,UAAI,CAAC,IAAK,OAAM,IAAI,qBAAqB,IAAI,OAAO;AAEpD,YAAM,OAAO,IAAI;AACjB,UAAI,SAAS,WAAW;AACtB,aAAK,YAAY,iBAAiB,KAAK;AAAA,MACzC,OAAO;AAIL,aAAK,YAAY,qBAAqB,KAAK;AAAA,MAC7C;AAEA,YAAM,MAAM,oBAAI,KAAK;AACrB,YAAM,OAAkB;AAAA,QACtB,GAAG;AAAA,QACH,QAAQ;AAAA,QACR,UAAU;AAAA,QACV,OAAO;AAAA,QACP,WAAW;AAAA,QACX,YAAY;AAAA,QACZ,WAAW;AAAA,QACX,OAAO;AAAA,QACP,QAAQ;AAAA,QACR,WAAW;AAAA,MACb;AACA,WAAK,MAAM,KAAK,IAAI,OAAO,IAAI;AAC/B,aAAO;AAAA,IACT,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYA,MAAM,KAAK,OAA8B;AAMvC,UAAM,MAAM,KAAK,MAAM,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,IAAK,OAAM,IAAI,MAAM,aAAa,KAAK,YAAY;AACxD,QAAI,IAAI,WAAW,WAAW;AAC5B,YAAM,IAAI;AAAA,QACR,aAAa,KAAK,4BAA4B,IAAI,MAAM;AAAA,MAC1D;AAAA,IACF;AAEA,UAAM,eAAe,KAAK,gBAAgB,IAAI,IAAI,OAAO;AACzD,QAAI,CAAC,cAAc;AACjB,YAAM,KAAK,WAAW,KAAK,IAAI;AAAA,QAC7B,sCAAsC,IAAI,OAAO;AAAA,MACnD,IAAI,IAAI,YAAY,KAAK,CAAC;AAC1B;AAAA,IACF;AACA,UAAM,OAAO,aAAa;AAC1B,UAAM,eAAe,aAAa;AAQlC,UAAM,UAAU,KAAK,YAChB,KAAK,UAAU;AAAA,MACd;AAAA,MACA,EAAE,QAAQ,MAAM;AAAA,IAClB,IACA,IAAI,aAAa;AAErB,UAAM,MAA2B;AAAA,MAC/B,OAAO,IAAI;AAAA,MACX;AAAA,MACA,MAAM,KAAK,WAAW,GAAG;AAAA,MACzB,YAAY,KAAK,YAAY,GAAG;AAAA,MAChC,QAAQ,IAAI,OAAO,UAAU,IAAI,EAAE,EAAE;AAAA,IACvC;AAEA,UAAM,iBAAiB,IAAI,YAAY;AACvC,QAAI;AACF,YAAM,SAAU,MAAM,QAAQ,IAAI,GAAG;AACrC,YAAM,KAAK,cAAc,KAAK,UAAU,CAAC,GAAG,iBAAiB,CAAC;AAAA,IAChE,SAAS,KAAK;AACZ,YAAM,SAAS,KAAK;AACpB,YAAM,WAAW,cAAc,KAAK,QAAQ,cAAc;AAC1D,YAAM,eAAe,iBAAiB;AACtC,UAAI,aAAa,WAAW,QAAQ;AAClC,cAAM,QAAQ,eAAe,QAAQ,YAAY;AACjD,cAAM,KAAK,mBAAmB,KAAK,KAAK,cAAc,KAAK;AAAA,MAC7D,OAAO;AACL,cAAM,KAAK,WAAW,KAAK,KAAK,YAAY;AAAA,MAC9C;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,WAAW,KAAgB;AACjC,WAAO,OACL,QACA,IACA,UACqB;AACrB,WAAK;AACL,YAAM,WAAW,MAAM,KAAK,YAAY,SAAS,IAAI,IAAI,MAAM;AAC/D,UAAI,UAAU,WAAW,aAAa;AACpC,eAAO,SAAS;AAAA,MAClB;AACA,YAAM,MAAM,KAAK,YAAY,IAAI,EAAE;AACnC,YAAM,YAAY,oBAAI,KAAK;AAC3B,YAAM,gBAAgB,UAAU,YAAY,KAAK;AACjD,YAAM,KAAK,YAAY,WAAW;AAAA,QAChC,UAAU,IAAI;AAAA,QACd;AAAA,QACA,MAAM;AAAA,QACN;AAAA,QACA,QAAQ;AAAA,QACR;AAAA,QACA,UAAU;AAAA,MACZ,CAAC;AACD,UAAI;AACF,cAAM,SAAS,MAAM,GAAG;AACxB,cAAM,KAAK,YAAY,WAAW;AAAA,UAChC,UAAU,IAAI;AAAA,UACd;AAAA,UACA,MAAM;AAAA,UACN;AAAA,UACA,QAAQ;AAAA,UACR;AAAA,UACA,YAAY,oBAAI,KAAK;AAAA,UACrB,UAAU;AAAA,QACZ,CAAC;AACD,eAAO;AAAA,MACT,SAAS,KAAK;AACZ,cAAM,KAAK,YAAY,WAAW;AAAA,UAChC,UAAU,IAAI;AAAA,UACd;AAAA,UACA,MAAM;AAAA,UACN;AAAA,UACA,QAAQ;AAAA,UACR,OAAO,eAAe,KAAK,cAAc,KAAK;AAAA,UAC9C,YAAY,oBAAI,KAAK;AAAA,UACrB,UAAU;AAAA,QACZ,CAAC;AACD,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAAA,EAEQ,YAAY,KAAgB;AAClC,WAAO,OACL,MACA,OACA,SACoB;AACpB,aAAO,KAAK,MAAM,MAAM,OAAO;AAAA,QAC7B,aAAa,IAAI;AAAA,QACjB,mBAAmB,MAAM;AAAA,QACzB,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB,MAAM,MAAM;AAAA,QACZ,eAAe;AAAA,QACf,YAAY,IAAI;AAAA,MAClB,CAAC;AAAA,IACH;AAAA,EACF;AAAA,EAEQ,YAAY,OAAuB;AACzC,UAAM,OAAO,KAAK,MAAM,MAAM,IAAI,KAAK;AACvC,QAAI,CAAC,QAAQ,KAAK,WAAW,EAAG,QAAO;AACvC,QAAI,MAAM;AACV,eAAW,KAAK,KAAM,KAAI,EAAE,MAAM,IAAK,OAAM,EAAE;AAC/C,WAAO,MAAM;AAAA,EACf;AAAA,EAEA,MAAc,cACZ,KACA,QACA,UACe;AACf,UAAM,KAAK,MAAM,IAAI,YAAY;AAC/B,YAAM,UAAU,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE;AAC1C,UAAI,CAAC,WAAW,WAAW,QAAQ,MAAM,EAAG;AAC5C,YAAM,MAAM,oBAAI,KAAK;AACrB,WAAK,MAAM,KAAK,IAAI,IAAI,IAAI;AAAA,QAC1B,GAAG;AAAA,QACH,QAAQ;AAAA,QACR;AAAA,QACA,YAAY;AAAA,QACZ,WAAW;AAAA,QACX;AAAA,MACF,CAAC;AACD,WAAK,wBAAwB,IAAI,EAAE;AAAA,IACrC,CAAC;AAAA,EACH;AAAA,EAEA,MAAc,WACZ,KACA,KACA,UACe;AACf,UAAM,KAAK,MAAM,IAAI,YAAY;AAC/B,YAAM,UAAU,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE;AAC1C,UAAI,CAAC,WAAW,WAAW,QAAQ,MAAM,EAAG;AAC5C,YAAM,MAAM,oBAAI,KAAK;AACrB,WAAK,MAAM,KAAK,IAAI,IAAI,IAAI;AAAA,QAC1B,GAAG;AAAA,QACH,QAAQ;AAAA,QACR,YAAY;AAAA,QACZ,WAAW;AAAA,QACX;AAAA,QACA,OAAO,eAAe,KAAK,UAAU,KAAK;AAAA,MAC5C,CAAC;AACD,WAAK,wBAAwB,IAAI,EAAE;AAAA,IACrC,CAAC;AAMD,QAAI,IAAI,sBAAsB,aAAa;AACzC,UAAI;AACF,cAAM,KAAK,OAAO,IAAI,IAAI;AAAA,UACxB,SAAS;AAAA,UACT,QAAQ;AAAA,UACR,UAAU,IAAI;AAAA,QAChB,CAAC;AAAA,MACH,SAAS,YAAY;AACnB,aAAK,OAAO;AAAA,UACV,yBAAyB,IAAI,EAAE,KAAM,WAAqB,OAAO;AAAA,QACnE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAc,mBACZ,KACA,KACA,UACA,SACe;AACf,UAAM,KAAK,MAAM,IAAI,YAAY;AAC/B,YAAM,UAAU,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE;AAC1C,UAAI,CAAC,WAAW,WAAW,QAAQ,MAAM,EAAG;AAC5C,YAAM,MAAM,oBAAI,KAAK;AACrB,WAAK,MAAM,KAAK,IAAI,IAAI,IAAI;AAAA,QAC1B,GAAG;AAAA,QACH,QAAQ;AAAA,QACR;AAAA,QACA,OAAO,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO;AAAA,QACpC,WAAW;AAAA,QACX,WAAW;AAAA,QACX,WAAW;AAAA,QACX,OAAO,eAAe,KAAK,UAAU,IAAI;AAAA,MAC3C,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAMQ,oBACN,SACA,WACA,eACkB;AAClB,QAAI,OAAyB;AAC7B,eAAW,KAAK,KAAK,MAAM,KAAK,OAAO,GAAG;AACxC,UAAI,EAAE,YAAY,QAAS;AAC3B,UAAI,EAAE,cAAc,UAAW;AAC/B,UAAI,yBAAyB,SAAS,EAAE,MAAM,EAAG;AACjD,UAAI,EAAE,UAAU,QAAQ,KAAK,cAAe;AAC5C,UAAI,CAAC,QAAQ,EAAE,UAAU,QAAQ,IAAI,KAAK,UAAU,QAAQ,GAAG;AAC7D,eAAO;AAAA,MACT;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAAA,EAEQ,6BAA6B,KAA+B;AAClE,eAAW,KAAK,KAAK,MAAM,KAAK,OAAO,GAAG;AACxC,UAAI,EAAE,mBAAmB,IAAK;AAC9B,UAAI,CAAC,mBAAmB,SAAS,EAAE,MAAM,EAAG;AAC5C,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AACF;AAhsBa,wBAAN;AAAA,EADN,WAAW;AAAA,EAoBP,0BAAO,cAAc;AAAA,EACrB,0BAAO,oBAAoB;AAAA,EAC3B,0BAAO,iBAAiB;AAAA,EACxB,4BAAS;AAAA,EAAG,0BAAO,SAAS;AAAA,GAtBpB;AAosBb,SAAS,cACP,KACA,QACA,iBACkB;AAClB,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,SAAS;AACf,QAAM,OAAO,QAAQ;AACrB,QAAM,OAAO,QAAQ;AACrB,QAAM,eAAe,OAAO,sBAAsB,CAAC;AACnD,MAAI,aAAa,KAAK,CAAC,MAAM,MAAM,QAAQ,MAAM,IAAI,EAAG,QAAO;AAC/D,MAAI,kBAAkB,KAAK,OAAO,SAAU,QAAO;AACnD,SAAO;AACT;AAEA,SAAS,eAAe,QAAqB,UAA0B;AACrE,QAAM,OAAO,KAAK,IAAI,OAAO,QAAQ,CAAC;AACtC,MAAI,OAAO,YAAY,QAAS,QAAO;AACvC,QAAM,WAAW,KAAK,IAAI,WAAW,GAAG,CAAC;AACzC,MAAI,YAAY,GAAI,QAAO,OAAO;AAClC,QAAM,MAAM,OAAO,KAAK,IAAI,GAAG,QAAQ;AACvC,MAAI,CAAC,OAAO,SAAS,GAAG,KAAK,OAAO,OAAO,kBAAkB;AAC3D,WAAO,OAAO;AAAA,EAChB;AACA,SAAO;AACT;AAEA,SAAS,eAAe,KAAc,SAAiB,WAAoB;AACzE,QAAM,IAAI;AACV,SAAO;AAAA,IACL,SAAU,GAAG,WAAW,OAAO,GAAG;AAAA,IAClC,OAAO,GAAG;AAAA,IACV;AAAA,IACA;AAAA,EACF;AACF;","names":["dedupeKey"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-handler.base.ts"],"sourcesContent":["/**\n * Handler base class, JobContext, @JobHandler decorator, and policy types\n * for the job orchestration domain (ADR-022, JOB-2).\n *\n * User-authored jobs subclass `JobHandlerBase<TInput, TOutput>` and decorate\n * the class with `@JobHandler<TInput>('job_type', meta)`. The decorator\n * 1. stores metadata via `Reflect.defineMetadata` so Nest's reflector can\n * pick it up at module boot, and\n * 2. populates `JOB_HANDLER_REGISTRY` — a module-singleton map consumed by\n * `JobWorkerModule` (JOB-5) to materialise `job` rows and resolve\n * handler classes during claim/execute.\n *\n * No runtime orchestration lives here; this file is a pure type + decorator\n * surface so downstream PRs (JOB-3..JOB-5) can implement against a stable\n * shape.\n */\n// TODO(logging-subsystem): swap to ILogger once ADR-028 lands\nimport type { Logger } from '@nestjs/common';\nimport { tokenKey } from '../token-key';\nimport type { EventOfType, EventTypeName } from '../events/event-registry';\nimport type { JobRun } from './job-orchestrator.protocol';\n\n// ─── ParentClosePolicy ──────────────────────────────────────────────────────\n\n/**\n * What happens to running child runs when a parent enters a terminal state.\n * Stored on the child at spawn; changes to the parent after spawn do NOT\n * retroactively rewrite children.\n */\nexport enum ParentClosePolicy {\n Terminate = 'terminate',\n Cancel = 'cancel',\n Abandon = 'abandon',\n}\n\n// ─── Policy types ───────────────────────────────────────────────────────────\n\nexport interface RetryPolicy {\n attempts: number;\n backoff: 'fixed' | 'exponential';\n baseMs: number;\n nonRetryableErrors?: string[];\n}\n\nexport interface ConcurrencyPolicy<TInput> {\n key: (input: TInput) => string;\n collisionMode: 'queue' | 'reject' | 'replace';\n}\n\nexport interface DedupePolicy<TInput> {\n key: (input: TInput) => string;\n windowMs: number;\n}\n\n/**\n * Declarative scope reference. `TScope` is parameterised so JOB-7 can narrow\n * `entity` to the generated `ScopeEntityType` union at the call site without\n * modifying this file (OQ-1 resolution, 2026-04-20).\n */\nexport interface ScopeRef<TInput, TScope extends string = string> {\n entity: TScope;\n from: (input: TInput) => string;\n}\n\n/**\n * Bridge trigger authoring shape (BRIDGE-6 follow-up — BRIDGE-6 shipped the\n * generator + runtime for `@JobHandler({ triggers })` but never added the\n * authoring field to this type; the generator's tests scan source as strings,\n * so a real decorator was never compiled and the gap went uncaught).\n *\n * Declared on `@JobHandler({ triggers })`; the codegen bridge-registry\n * generator (`src/cli/shared/bridge-registry-generator.ts`) scans these from\n * source and emits `bridge/generated/registry.ts`, validating each `event`\n * against the generated `eventRegistry` at `gen-all`. The distributed union\n * narrows `map`/`when` per `event`, so callbacks are typed against the event\n * payload (ADR-023, \"typed against PayloadOfType<T>\").\n *\n * Typed against events' generated types — the same `import type` coupling the\n * bridge already has (erased at runtime). `jobs` must NOT import `bridge`, so\n * the post-gen `BridgeTriggerEntry` is deliberately not referenced here;\n * `triggerId`/`jobType` are computed by the generator, not authored.\n */\nexport type JobTrigger<TInput> = {\n [T in EventTypeName]: {\n /** Event type that fires this trigger. Validated against `eventRegistry`. */\n event: T;\n /** Maps the event to the job input. Inlined verbatim into the registry. */\n map: (event: EventOfType<T>) => TInput;\n /** Optional guard; `false` → wrapper records `status='skipped'`. */\n when?: (event: EventOfType<T>) => boolean;\n };\n}[EventTypeName];\n\nexport interface JobHandlerMeta<TInput> {\n pool?: string;\n scope?: ScopeRef<TInput>;\n retry?: RetryPolicy;\n concurrency?: ConcurrencyPolicy<TInput>;\n dedupe?: DedupePolicy<TInput>;\n timeoutMs?: number;\n replayFrom?: 'scratch' | 'last_step' | 'last_checkpoint';\n /**\n * Bridge triggers (ADR-023 Tier 3). Codegen scans these into `bridgeRegistry`;\n * the framework `BridgeDeliveryHandler` starts this job per matched event.\n * Absent for jobs started directly or via `IEventFlow.publishAndStart`.\n */\n triggers?: readonly JobTrigger<TInput>[];\n}\n\n// ─── Runtime option shapes ──────────────────────────────────────────────────\n\nexport interface StepOptions {\n retry?: RetryPolicy;\n timeoutMs?: number;\n}\n\nexport interface SpawnChildOptions {\n closePolicy?: ParentClosePolicy;\n runAt?: Date;\n priority?: number;\n tags?: Record<string, string>;\n}\n\n// ─── JobContext ─────────────────────────────────────────────────────────────\n\nexport interface JobContext<TInput> {\n readonly input: TInput;\n readonly run: JobRun;\n step<TOutput>(\n stepId: string,\n fn: () => Promise<TOutput>,\n opts?: StepOptions,\n ): Promise<TOutput>;\n spawnChild(type: string, input: unknown, opts?: SpawnChildOptions): Promise<JobRun>;\n readonly logger: Logger;\n // NOT in Phase 1 — deferred to ADR-025:\n // waitFor(kind, token, opts)\n // signal(token, payload)\n // sleep(ms)\n}\n\n// ─── JobHandlerBase ─────────────────────────────────────────────────────────\n\nexport abstract class JobHandlerBase<TInput, TOutput = unknown> {\n abstract run(ctx: JobContext<TInput>): Promise<TOutput>;\n}\n\n// ─── Registry + decorator ───────────────────────────────────────────────────\n\n/**\n * Module-singleton map keyed by job type. Populated by the `@JobHandler`\n * decorator at class definition time; consumed by `JobWorkerModule` (JOB-5)\n * to upsert `job` rows and resolve handler classes during claim/execute.\n */\nexport const JOB_HANDLER_REGISTRY = new Map<\n string,\n {\n type: string;\n meta: JobHandlerMeta<unknown>;\n handlerClass: new (...args: unknown[]) => JobHandlerBase<unknown>;\n }\n>();\n\n// ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) so the reflection-metadata\n// key matches by value across import boundaries (the @JobHandler decorator and the\n// reader may resolve different runtime copies). Distinct from the DI tokens but\n// subject to the same dual-package identity hazard.\nexport const JOB_HANDLER_METADATA_KEY = Symbol.for(tokenKey('jobs', 'handler-metadata'));\n\n/**\n * Class decorator that registers a handler with the job type, the full\n * metadata shape, and the target class constructor.\n *\n * Duplicate-type behaviour (OQ-3, resolved 2026-04-18):\n * - `NODE_ENV === 'production'` → throw; silent overwrite in prod is a\n * correctness bug.\n * - `NODE_ENV === 'test'` → silent overwrite (tests intentionally\n * re-register handlers).\n * - otherwise (dev) → `console.warn` + overwrite. `console`\n * is used intentionally instead of the Nest `Logger` — decorators run\n * at module-load time before any Nest container exists.\n */\nexport function JobHandler<TInput>(\n type: string,\n meta: JobHandlerMeta<TInput>,\n): ClassDecorator {\n return (target) => {\n if (JOB_HANDLER_REGISTRY.has(type)) {\n const env = process.env.NODE_ENV;\n if (env === 'production') {\n throw new Error(\n `[JobHandler] Duplicate registration for job type '${type}'. ` +\n `Each @JobHandler must declare a unique type.`,\n );\n }\n if (env !== 'test') {\n // eslint-disable-next-line no-console\n console.warn(\n `[JobHandler] Duplicate registration for job type '${type}'. ` +\n `Overwriting previous handler — this is almost certainly a bug.`,\n );\n }\n }\n\n Reflect.defineMetadata(JOB_HANDLER_METADATA_KEY, { type, meta }, target);\n JOB_HANDLER_REGISTRY.set(type, {\n type,\n meta: meta as JobHandlerMeta<unknown>,\n handlerClass: target as unknown as new (\n ...args: unknown[]\n ) => JobHandlerBase<unknown>,\n });\n };\n}\n\n// ─── HandlerRegistry — read helpers consumed by JobWorkerModule (JOB-5) ─────\n\n/**\n * Single entry shape returned by `HandlerRegistry.getAll()` / `.get()` and\n * exposed to `JobWorkerModule.onModuleInit` for boot-time upserts.\n *\n * Structurally compatible with `IJobOrchestrator.upsertJobRows`'s\n * `JobUpsertEntry` so the worker module can pass entries through verbatim\n * without re-mapping.\n */\nexport interface HandlerRegistryEntry {\n type: string;\n meta: JobHandlerMeta<unknown>;\n handlerClass: new (...args: unknown[]) => JobHandlerBase<unknown>;\n}\n\n/**\n * Read facade over `JOB_HANDLER_REGISTRY`. The decorator's write path is\n * unchanged; this namespace exists so consumers (the worker module, tests)\n * don't import the raw `Map` and accidentally mutate it.\n */\nexport namespace HandlerRegistry {\n /** All registered entries in insertion order. */\n export function getAll(): HandlerRegistryEntry[] {\n return Array.from(JOB_HANDLER_REGISTRY.values());\n }\n\n /** Lookup by job type, or `undefined` if no `@JobHandler` is registered. */\n export function get(type: string): HandlerRegistryEntry | undefined {\n return JOB_HANDLER_REGISTRY.get(type);\n }\n}\n"],"mappings":";;;;;AA6BO,IAAK,oBAAL,kBAAKA,uBAAL;AACL,EAAAA,mBAAA,eAAY;AACZ,EAAAA,mBAAA,YAAS;AACT,EAAAA,mBAAA,aAAU;AAHA,SAAAA;AAAA,GAAA;AAkHL,IAAe,iBAAf,MAAyD;AAEhE;AASO,IAAM,uBAAuB,oBAAI,IAOtC;AAMK,IAAM,2BAA2B,OAAO,IAAI,SAAS,QAAQ,kBAAkB,CAAC;AAehF,SAAS,WACd,MACA,MACgB;AAChB,SAAO,CAAC,WAAW;AACjB,QAAI,qBAAqB,IAAI,IAAI,GAAG;AAClC,YAAM,MAAM,QAAQ,IAAI;AACxB,UAAI,QAAQ,cAAc;AACxB,cAAM,IAAI;AAAA,UACR,qDAAqD,IAAI;AAAA,QAE3D;AAAA,MACF;AACA,UAAI,QAAQ,QAAQ;AAElB,gBAAQ;AAAA,UACN,qDAAqD,IAAI;AAAA,QAE3D;AAAA,MACF;AAAA,IACF;AAEA,YAAQ,eAAe,0BAA0B,EAAE,MAAM,KAAK,GAAG,MAAM;AACvE,yBAAqB,IAAI,MAAM;AAAA,MAC7B;AAAA,MACA;AAAA,MACA,cAAc;AAAA,IAGhB,CAAC;AAAA,EACH;AACF;AAuBO,IAAU;AAAA,CAAV,CAAUC,qBAAV;AAEE,WAAS,SAAiC;AAC/C,WAAO,MAAM,KAAK,qBAAqB,OAAO,CAAC;AAAA,EACjD;AAFO,EAAAA,iBAAS;AAKT,WAAS,IAAI,MAAgD;AAClE,WAAO,qBAAqB,IAAI,IAAI;AAAA,EACtC;AAFO,EAAAA,iBAAS;AAAA,GAPD;","names":["ParentClosePolicy","HandlerRegistry"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/pg-notify.ts"],"sourcesContent":["/**\n * PgNotifyListener + pgNotify — Postgres LISTEN/NOTIFY wakeups\n * (LISTEN-NOTIFY-1, dogfood gap #7).\n *\n * The drizzle jobs worker and events outbox drainer poll on an interval today\n * (default 1 s/hop). With `listen_notify` enabled, a row write that makes work\n * claimable emits an in-transaction `pg_notify(...)`; a dedicated listener\n * connection wakes the polling loop the moment the writing transaction commits.\n *\n * Two halves:\n * - `pgNotify(tx, channel, payload)` — fire an in-tx `pg_notify`. MUST be\n * called with the SAME transaction handle as the row write it announces, so\n * Postgres delivers it only on commit (the transactional-outbox guarantee).\n * - `PgNotifyListener` — owns a single long-lived `pg.PoolClient`, issues\n * `LISTEN <channel>`, forwards each notification's payload to an owner\n * callback, debounces bursts, and reconnects with capped backoff on drop.\n *\n * **Polling never stops.** This is a wake-early optimisation layered ON TOP of\n * interval polling. A lost notification (listener down, pooler eats the LISTEN,\n * etc.) degrades to today's poll latency, never to lost work — the claim/drain\n * query remains the source of truth.\n *\n * **PgBouncer caveat:** session-scoped `LISTEN` does not survive a\n * transaction-mode pooler. `listen_notify` requires a direct (or session-mode)\n * connection; behind a transaction pooler notifies are simply never received and\n * the system degrades to polling. See the jobs config block / skill.\n */\n// TODO(logging-subsystem): swap to ILogger once ADR-028 lands\nimport { Logger } from '@nestjs/common';\nimport { sql } from 'drizzle-orm';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport type { DrizzleTransaction } from '../events/event-bus.protocol';\n\n/** Channel the jobs worker LISTENs on; payload = pool name. */\nexport const JOBS_WAKE_CHANNEL = 'codegen_jobs_wake';\n/** Channel the events drainer LISTENs on; payload = event pool (or ''). */\nexport const EVENTS_WAKE_CHANNEL = 'codegen_events_wake';\n\n/**\n * Emit an in-transaction `pg_notify`. Call with the SAME `tx`/client handle as\n * the row write being announced so delivery is gated on commit. `payload` is a\n * short plain string (a pool name); it is NOT JSON — the wake is a hint and the\n * subsequent claim/drain query is authoritative. Channel names are framework\n * constants (never user input), so the `set_config`-free literal-channel form is\n * safe; the payload is bound as a parameter.\n */\nexport async function pgNotify(\n tx: DrizzleClient | DrizzleTransaction,\n channel: string,\n payload: string,\n): Promise<void> {\n const client = tx as DrizzleClient;\n // `pg_notify(channel, payload)` is the function form (vs the `NOTIFY chan,\n // 'payload'` statement form) precisely because it accepts bound parameters —\n // the payload is parameterised, never string-concatenated.\n await client.execute(sql`select pg_notify(${channel}, ${payload})`);\n}\n\n/** Minimal structural view of the `pg` Client/PoolClient surface we touch. */\ninterface PgListenClient {\n query(text: string): Promise<unknown>;\n on(event: 'notification', cb: (msg: { channel: string; payload?: string }) => void): void;\n on(event: 'error', cb: (err: Error) => void): void;\n removeAllListeners?: (event?: string) => void;\n release?: (err?: boolean) => void;\n end?: () => Promise<void>;\n}\n\n/** Minimal structural view of the `pg` Pool's `connect()`. */\ninterface PgPoolish {\n connect(): Promise<PgListenClient>;\n}\n\nconst DEFAULT_BACKOFF_MIN_MS = 100;\nconst DEFAULT_BACKOFF_MAX_MS = 5_000;\n\nexport interface PgNotifyListenerOptions {\n /** Channel to LISTEN on. */\n channel: string;\n /**\n * The underlying `pg.Pool` — obtained from `drizzleClient.$client`. A\n * dedicated `PoolClient` is checked out and held for the listener's lifetime\n * (separate from the query pool so a slow query never delays a wake).\n */\n pool: PgPoolish;\n /**\n * Called for every notification on `channel`, with the raw payload string\n * (`''` when Postgres delivers an empty payload). The owner decides whether\n * the payload is relevant (e.g. \"is this one of my pools?\") and debounces its\n * own claim cycle.\n */\n onNotify: (payload: string) => void;\n /** Label used in log lines (e.g. 'jobs:interactive', 'events'). */\n label: string;\n backoffMinMs?: number;\n backoffMaxMs?: number;\n}\n\n/**\n * Holds a dedicated listener connection and forwards notifications to `onNotify`.\n * Reconnects with capped exponential backoff on drop; logs the first failure +\n * the recovery exactly once each so a flapping connection doesn't flood logs.\n */\nexport class PgNotifyListener {\n private readonly logger: Logger;\n private client: PgListenClient | null = null;\n private stopped = false;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n private backoffMs: number;\n private readonly backoffMinMs: number;\n private readonly backoffMaxMs: number;\n /** WARN-once gate so a flapping listener doesn't spam the log. */\n private warnedDown = false;\n\n constructor(private readonly opts: PgNotifyListenerOptions) {\n this.logger = new Logger(`PgNotifyListener(${opts.label})`);\n this.backoffMinMs = opts.backoffMinMs ?? DEFAULT_BACKOFF_MIN_MS;\n this.backoffMaxMs = opts.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;\n this.backoffMs = this.backoffMinMs;\n }\n\n /** Begin listening. Idempotent-ish: a second call while connected is a no-op. */\n async start(): Promise<void> {\n this.stopped = false;\n await this.connect();\n }\n\n /** Stop listening + release the connection. Safe to call repeatedly. */\n async stop(): Promise<void> {\n this.stopped = true;\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n await this.releaseClient();\n }\n\n private async connect(): Promise<void> {\n if (this.stopped) return;\n try {\n const client = await this.opts.pool.connect();\n client.on('notification', (msg) => {\n if (msg.channel !== this.opts.channel) return;\n try {\n this.opts.onNotify(msg.payload ?? '');\n } catch (err) {\n this.logger.error(`onNotify threw: ${(err as Error).message}`);\n }\n });\n client.on('error', (err) => {\n // A connection-level error is the signal to reconnect. Don't double-log\n // here — scheduleReconnect owns the WARN-once.\n this.logger.debug?.(`listener connection error: ${err.message}`);\n this.handleDrop();\n });\n await client.query(`LISTEN ${this.opts.channel}`);\n this.client = client;\n // Recovery: only announce if we had previously warned about being down.\n if (this.warnedDown) {\n this.logger.log(\n `listener reconnected; LISTEN ${this.opts.channel} re-established`,\n );\n this.warnedDown = false;\n }\n this.backoffMs = this.backoffMinMs;\n } catch (err) {\n this.handleConnectFailure(err);\n }\n }\n\n /** Connection dropped after being established → reconnect. */\n private handleDrop(): void {\n if (this.stopped) return;\n void this.releaseClient().finally(() => this.scheduleReconnect());\n }\n\n /** Initial / reconnect `connect()` threw. */\n private handleConnectFailure(err: unknown): void {\n this.scheduleReconnect(err);\n }\n\n private scheduleReconnect(err?: unknown): void {\n if (this.stopped) return;\n if (!this.warnedDown) {\n this.warnedDown = true;\n this.logger.warn(\n `listener down — falling back to interval polling until reconnect. ` +\n `Cause: ${err instanceof Error ? err.message : 'connection lost'}. ` +\n `(This degrades latency, not durability — polling still drives all work.)`,\n );\n }\n if (this.reconnectTimer) clearTimeout(this.reconnectTimer);\n const delay = this.backoffMs;\n this.backoffMs = Math.min(this.backoffMs * 2, this.backoffMaxMs);\n this.reconnectTimer = setTimeout(() => {\n this.reconnectTimer = null;\n void this.connect();\n }, delay);\n }\n\n private async releaseClient(): Promise<void> {\n const client = this.client;\n this.client = null;\n if (!client) return;\n try {\n client.removeAllListeners?.('notification');\n client.removeAllListeners?.('error');\n // A listener client is a checked-out pool connection; release it back\n // with `release(true)` (destroy) so a half-broken socket isn't reused.\n if (client.release) client.release(true);\n else if (client.end) await client.end();\n } catch {\n // best-effort teardown\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-worker.ts"],"sourcesContent":["/**\n * JobWorker — backend-agnostic tick loop for the job orchestration domain\n * (ADR-022, JOB-3).\n *\n * One worker instance per active pool. On `onModuleInit` it starts two\n * intervals: the poll loop (claim → process → repeat) and the stale-claim\n * sweeper. On `onModuleDestroy` / SIGTERM it drains in-flight work and\n * releases still-`running` rows back to `pending` so a replacement worker\n * can resume with step memoization intact.\n *\n * The claim query is the beating heart: `SELECT … FOR UPDATE SKIP LOCKED`\n * inside a single transaction. Multiple worker processes share the table\n * without serialising on row locks.\n */\n// TODO(logging-subsystem): swap to ILogger once ADR-028 lands\nimport { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport { and, asc, desc, eq, inArray, lt, lte, sql } from 'drizzle-orm';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { tokenKey } from '../token-key';\nimport { DRIZZLE } from '../../constants/tokens';\nimport { jobRuns, type JobRunRow } from './job-orchestration.schema';\nimport type { IJobOrchestrator, JobRun } from './job-orchestrator.protocol';\nimport type { IJobRunService } from './job-run-service.protocol';\nimport type { IJobStepService } from './job-step-service.protocol';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n} from './jobs-domain.tokens';\nimport {\n JOB_HANDLER_REGISTRY,\n JobHandlerBase,\n type JobContext,\n type JobHandlerMeta,\n type RetryPolicy,\n type SpawnChildOptions,\n type StepOptions,\n} from './job-handler.base';\nimport { JOBS_WAKE_CHANNEL, PgNotifyListener } from './pg-notify';\n\n/**\n * Options accepted by `JobWorker`. JOB-5 threads these through module\n * `.forRoot()` config; supplied here as a plain DI-constructor argument\n * so the worker compiles standalone.\n */\nexport interface JobWorkerOptions {\n /** Pool name this worker claims from. Matches `job.pool`. */\n pool: string;\n /** Max concurrent in-flight `processRun` calls. */\n concurrency: number;\n /** Poll interval in ms. Default 1000. */\n pollIntervalMs?: number;\n /** Stale sweep interval in ms. Default 60_000. */\n staleSweeperIntervalMs?: number;\n /**\n * Threshold beyond which a `running` row is presumed stranded by a\n * crashed worker. Default 5 min. Must be >= 2× max handler duration.\n */\n staleThresholdMs?: number;\n /** Max ms to wait for in-flight drain on SIGTERM. Default 30_000. */\n shutdownTimeoutMs?: number;\n /**\n * LISTEN-NOTIFY-1 — when true, hold a dedicated listener connection and\n * LISTEN on `codegen_jobs_wake`. A notification naming this worker's `pool`\n * triggers an immediate (debounced) claim cycle, so an enqueue is claimed in\n * milliseconds instead of waiting for the next `pollIntervalMs` tick. Polling\n * continues unchanged as the fallback heartbeat. Default false.\n */\n listenNotify?: boolean;\n}\n\n// ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value\n// across runtime copies.\nexport const JOB_WORKER_OPTIONS = Symbol.for(tokenKey('jobs', 'worker-options'));\n\nconst DEFAULT_POLL_INTERVAL_MS = 1_000;\nconst DEFAULT_STALE_SWEEPER_INTERVAL_MS = 60_000;\nconst DEFAULT_STALE_THRESHOLD_MS = 5 * 60_000;\nconst DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;\n\nconst TERMINAL_STATUSES: JobRunRow['status'][] = [\n 'completed',\n 'failed',\n 'timed_out',\n 'canceled',\n];\n\n// ─── Pure helpers (exported for unit tests) ────────────────────────────────\n\n/**\n * Backoff delay in ms for the Nth attempt (1-indexed). Supports both\n * policy modes. Exponential is capped at `Number.MAX_SAFE_INTEGER` so\n * pathological attempt counts don't overflow.\n */\nexport function computeBackoff(policy: RetryPolicy, attempts: number): number {\n const base = Math.max(policy.baseMs, 0);\n if (policy.backoff === 'fixed') {\n return base;\n }\n // exponential: baseMs * 2^(attempts-1)\n const exponent = Math.max(attempts - 1, 0);\n if (exponent >= 53) return Number.MAX_SAFE_INTEGER; // 2^53 overflow guard\n const raw = base * Math.pow(2, exponent);\n if (!Number.isFinite(raw) || raw >= Number.MAX_SAFE_INTEGER) {\n return Number.MAX_SAFE_INTEGER;\n }\n return raw;\n}\n\n/**\n * Decide whether an error should be retried under the given policy.\n * Matches `nonRetryableErrors` by `.name` OR `.code`. Returns\n * - `'retry'` if attempts remain and the error isn't blacklisted,\n * - `'fail'` otherwise (terminal failure).\n */\nexport function classifyError(\n err: unknown,\n policy: RetryPolicy | undefined,\n currentAttempts: number,\n): 'retry' | 'fail' {\n if (!policy) return 'fail';\n const errObj = err as { name?: string; code?: string } | undefined;\n const name = errObj?.name;\n const code = errObj?.code;\n const nonRetryable = policy.nonRetryableErrors ?? [];\n if (nonRetryable.some((n) => n === name || n === code)) return 'fail';\n if (currentAttempts + 1 >= policy.attempts) return 'fail';\n return 'retry';\n}\n\n/**\n * Build the raw claim-candidate select. Exported so tests can inspect\n * `.toSQL()` without spinning up the full worker. Matches JOB-3 §4 and\n * ADR-022 \"Claim query (Drizzle backend)\".\n */\nexport function buildClaimQuery(db: DrizzleClient, pool: string) {\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n}\n\n/**\n * Build the stale-claim sweep candidate select. `FOR UPDATE SKIP LOCKED`\n * per OQ-2 resolution (2026-04-19): per-worker sweeper, safe without\n * leader election because the update is self-gating.\n */\nexport function buildStaleSweepQuery(\n db: DrizzleClient,\n staleThresholdMs: number,\n) {\n const threshold = new Date(Date.now() - staleThresholdMs);\n return db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'running'),\n lt(jobRuns.claimedAt, threshold),\n ),\n )\n .for('update', { skipLocked: true });\n}\n\n// ─── Error serialisation ───────────────────────────────────────────────────\n\nfunction serialiseError(err: unknown, attempt: number, retryable: boolean) {\n const e = err as { message?: string; stack?: string; code?: string } | undefined;\n return {\n message: (e?.message ?? String(err)) as string,\n stack: e?.stack,\n retryable,\n attempt,\n };\n}\n\n// ─── JobWorker ─────────────────────────────────────────────────────────────\n\n@Injectable()\nexport class JobWorker implements OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(JobWorker.name);\n private shuttingDown = false;\n private readonly inFlight = new Set<Promise<void>>();\n private pollTimer: ReturnType<typeof setInterval> | null = null;\n private sweeperTimer: ReturnType<typeof setInterval> | null = null;\n private sigtermHandled = false;\n private readonly sigtermHandler: () => void;\n\n private readonly pollIntervalMs: number;\n private readonly staleSweeperIntervalMs: number;\n private readonly staleThresholdMs: number;\n private readonly shutdownTimeoutMs: number;\n\n // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when\n // `listenNotify` is off (the common case); polling is the only driver then.\n private readonly listenNotifyEnabled: boolean;\n private notifyListener: PgNotifyListener | null = null;\n /** True while a wake-driven claim cycle is in flight (debounce gate). */\n private wakeDraining = false;\n /** A notify arrived mid-cycle → re-check once when the cycle ends. */\n private wakeRecheckPending = false;\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,\n @Inject(JOB_RUN_SERVICE) private readonly runService: IJobRunService,\n @Inject(JOB_STEP_SERVICE) private readonly stepService: IJobStepService,\n @Inject(JOB_WORKER_OPTIONS) private readonly options: JobWorkerOptions,\n private readonly moduleRef: ModuleRef,\n ) {\n this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n this.staleSweeperIntervalMs =\n options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;\n this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;\n this.shutdownTimeoutMs =\n options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;\n this.listenNotifyEnabled = options.listenNotify ?? false;\n\n this.sigtermHandler = () => {\n if (this.sigtermHandled) return;\n this.sigtermHandled = true;\n void this.onModuleDestroy();\n };\n void this.runService; // reserved for future scope-aware cancellation paths\n }\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n onModuleInit(): void {\n this.pollTimer = setInterval(() => {\n void this.pollAndProcess();\n }, this.pollIntervalMs);\n this.sweeperTimer = setInterval(() => {\n void this.sweepStaleClaims();\n }, this.staleSweeperIntervalMs);\n process.on('SIGTERM', this.sigtermHandler);\n\n // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer (never\n // instead). A notify for this worker's pool drives an immediate claim cycle;\n // the interval timer above stays the durability heartbeat. Listener startup\n // is fire-and-forget: a connect failure self-heals via the listener's own\n // backoff, and until it's up the poll loop is the sole driver.\n if (this.listenNotifyEnabled) {\n // The DRIZZLE provider wraps a `pg.Pool`, exposed by drizzle as `$client`.\n const pool = (this.db as unknown as { $client?: unknown }).$client;\n if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {\n this.logger.warn(\n `listen_notify enabled but the Drizzle client exposes no pg Pool ` +\n `($client.connect missing) — falling back to interval polling only.`,\n );\n } else {\n this.notifyListener = new PgNotifyListener({\n channel: JOBS_WAKE_CHANNEL,\n pool: pool as { connect(): Promise<never> },\n label: `jobs:${this.options.pool}`,\n onNotify: (payload) => this.onWake(payload),\n });\n void this.notifyListener.start();\n }\n }\n }\n\n /**\n * Wake handler — a `codegen_jobs_wake` notification arrived. Only payloads\n * naming THIS worker's pool are relevant (other pools have their own workers).\n * Debounced: if a claim cycle is already running we just flag a re-check so a\n * burst of N enqueues collapses to at most one extra cycle (D3).\n */\n private onWake(payload: string): void {\n if (this.shuttingDown) return;\n if (payload !== this.options.pool) return;\n if (this.wakeDraining) {\n this.wakeRecheckPending = true;\n return;\n }\n void this.drainOnWake();\n }\n\n /**\n * Claim-until-empty on a wake. Unlike the interval `pollAndProcess` (one\n * claim per tick), a wake drains greedily up to the concurrency ceiling so a\n * burst that arrived together is dispatched without waiting for N ticks. The\n * `wakeRecheckPending` flag coalesces notifies that land mid-drain.\n */\n private async drainOnWake(): Promise<void> {\n this.wakeDraining = true;\n try {\n do {\n this.wakeRecheckPending = false;\n // Claim while there's capacity; pollAndProcess no-ops at the ceiling.\n let progressed = true;\n while (\n progressed &&\n !this.shuttingDown &&\n this.inFlight.size < this.options.concurrency\n ) {\n const before = this.inFlight.size;\n await this.pollAndProcess();\n progressed = this.inFlight.size > before;\n }\n } while (this.wakeRecheckPending && !this.shuttingDown);\n } finally {\n this.wakeDraining = false;\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n if (this.shuttingDown) {\n // Still drain, but don't tear intervals down twice.\n await this.drainInFlight();\n return;\n }\n this.shuttingDown = true;\n if (this.pollTimer) {\n clearInterval(this.pollTimer);\n this.pollTimer = null;\n }\n if (this.sweeperTimer) {\n clearInterval(this.sweeperTimer);\n this.sweeperTimer = null;\n }\n process.removeListener('SIGTERM', this.sigtermHandler);\n\n // LISTEN-NOTIFY-1 — release the listener connection so the process can exit\n // cleanly. Best-effort; a failure here doesn't block the drain.\n if (this.notifyListener) {\n try {\n await this.notifyListener.stop();\n } catch (err) {\n this.logger.error(`notify listener stop failed: ${(err as Error).message}`);\n }\n this.notifyListener = null;\n }\n\n await this.drainInFlight();\n\n // Any rows still `running` past timeout → release back to pending.\n try {\n await this.db\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(\n and(eq(jobRuns.status, 'running'), eq(jobRuns.pool, this.options.pool)),\n );\n } catch (err) {\n this.logger.error(`shutdown reset failed: ${(err as Error).message}`);\n }\n }\n\n private async drainInFlight(): Promise<void> {\n if (this.inFlight.size === 0) return;\n const timeout = new Promise<void>((resolve) =>\n setTimeout(resolve, this.shutdownTimeoutMs),\n );\n await Promise.race([\n Promise.allSettled([...this.inFlight]).then(() => undefined),\n timeout,\n ]);\n }\n\n // ============================================================================\n // Poll loop\n // ============================================================================\n\n async pollAndProcess(): Promise<void> {\n if (this.shuttingDown) return;\n if (this.inFlight.size >= this.options.concurrency) return;\n\n let claimed: JobRunRow | null;\n try {\n claimed = await this.claimNext(this.options.pool);\n } catch (err) {\n this.logger.error(`claimNext failed: ${(err as Error).message}`);\n return;\n }\n if (!claimed) return;\n\n const run = claimed;\n const promise = this.processRun(run).catch((err) => {\n this.logger.error(\n `processRun(${run.id}) unhandled: ${(err as Error).message}`,\n );\n });\n this.inFlight.add(promise);\n promise.finally(() => {\n this.inFlight.delete(promise);\n });\n }\n\n /**\n * Claim the next runnable row from the pool. Transaction ensures the\n * select-candidate + update-to-running pair is atomic; FOR UPDATE SKIP\n * LOCKED lets multiple workers share the table without serialising.\n */\n async claimNext(pool: string): Promise<JobRunRow | null> {\n return this.db.transaction(async (tx) => {\n const candidates = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.status, 'pending'),\n eq(jobRuns.pool, pool),\n lte(jobRuns.runAt, new Date()),\n ),\n )\n .orderBy(desc(jobRuns.priority), asc(jobRuns.runAt))\n .limit(1)\n .for('update', { skipLocked: true });\n const candidate = candidates[0];\n if (!candidate) return null;\n\n const [claimed] = await tx\n .update(jobRuns)\n .set({\n status: 'running',\n claimedAt: new Date(),\n startedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, candidate.id))\n .returning();\n return (claimed ?? null) as JobRunRow | null;\n });\n }\n\n // ============================================================================\n // Stale claim sweeper\n // ============================================================================\n\n /**\n * Release rows whose `claimed_at` is older than the threshold. Safe to\n * run concurrently across workers — the two-phase tx (select-for-update\n * then update) guarantees each stranded row is only reset once.\n */\n async sweepStaleClaims(): Promise<void> {\n if (this.shuttingDown) return;\n try {\n await this.db.transaction(async (tx) => {\n const threshold = new Date(Date.now() - this.staleThresholdMs);\n const stale = await tx\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(eq(jobRuns.status, 'running'), lt(jobRuns.claimedAt, threshold)),\n )\n .for('update', { skipLocked: true });\n if (stale.length === 0) return;\n const ids = stale.map((r) => r.id);\n await tx\n .update(jobRuns)\n .set({ status: 'pending', claimedAt: null, startedAt: null })\n .where(inArray(jobRuns.id, ids));\n for (const id of ids) {\n this.logger.warn(`Recovered stale claim on run ${id}`);\n }\n });\n } catch (err) {\n this.logger.error(`sweepStaleClaims failed: ${(err as Error).message}`);\n }\n }\n\n // ============================================================================\n // processRun\n // ============================================================================\n\n private async processRun(claimed: JobRunRow): Promise<void> {\n const registryEntry = JOB_HANDLER_REGISTRY.get(claimed.jobType);\n\n // (a) Missing handler — defensive; JOB-5 boot validator should have caught.\n if (!registryEntry) {\n this.logger.error(\n `No handler registered for jobType='${claimed.jobType}' (run ${claimed.id})`,\n );\n await this.markFailed(\n claimed,\n new Error(`No handler registered for jobType='${claimed.jobType}'`),\n /*finalAttempts*/ (claimed.attempts ?? 0) + 1,\n );\n return;\n }\n\n // (b) Concurrency-queue release gate — defer if another run with the\n // same key is already `running`.\n if (claimed.concurrencyKey) {\n const inflight = await this.db\n .select({ id: jobRuns.id })\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.concurrencyKey, claimed.concurrencyKey),\n eq(jobRuns.status, 'running'),\n ),\n );\n const other = inflight.find((r) => r.id !== claimed.id);\n if (other) {\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n claimedAt: null,\n startedAt: null,\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n return;\n }\n }\n\n const meta = registryEntry.meta as JobHandlerMeta<unknown>;\n const HandlerClass = registryEntry.handlerClass;\n\n // (c) Build JobContext. Resolve the handler instance from Nest's DI\n // graph so its `@Inject` constructor params (which may come from\n // any module in the app graph) are satisfied. `moduleRef.create()`\n // would otherwise instantiate a fresh class within JobWorkerModule's\n // scope only — which blows up with \"not a provider of the current\n // module\" for any handler that consumes a service from a peer\n // module (e.g. CrmSyncJob injecting CrmSyncFactory from CrmModule).\n // Consequence: handlers MUST be registered as providers in their\n // owning module (@Injectable + `providers: [HandlerClass]`). The\n // @JobHandler decorator handles registry registration only, not DI.\n // See the jobs skill's handler-authoring.md for the registration\n // rule.\n const handler = this.moduleRef.get(\n HandlerClass as unknown as new (...args: unknown[]) => unknown,\n { strict: false },\n ) as JobHandlerBase<unknown>;\n const ctx: JobContext<unknown> = {\n input: claimed.input,\n run: claimed as JobRun,\n step: this.makeStepFn(claimed),\n spawnChild: this.makeSpawnFn(claimed),\n logger: new Logger(`JobRun:${claimed.id}`),\n };\n\n const attemptsBefore = claimed.attempts ?? 0;\n try {\n // (d) Run the handler.\n const output = (await handler.run(ctx)) as Record<string, unknown> | undefined;\n // (e) Success.\n await this.db\n .update(jobRuns)\n .set({\n status: 'completed',\n output: (output ?? {}) as Record<string, unknown>,\n finishedAt: new Date(),\n updatedAt: new Date(),\n attempts: attemptsBefore + 1,\n })\n .where(eq(jobRuns.id, claimed.id));\n } catch (err) {\n // (f) Error classification + retry/fail.\n const policy = meta.retry;\n const decision = classifyError(err, policy, attemptsBefore);\n const nextAttempts = attemptsBefore + 1;\n if (decision === 'retry' && policy) {\n const delay = computeBackoff(policy, nextAttempts);\n await this.db\n .update(jobRuns)\n .set({\n status: 'pending',\n attempts: nextAttempts,\n runAt: new Date(Date.now() + delay),\n startedAt: null,\n claimedAt: null,\n error: serialiseError(err, nextAttempts, true),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n } else {\n await this.markFailed(claimed, err, nextAttempts);\n }\n }\n }\n\n private async markFailed(\n claimed: JobRunRow,\n err: unknown,\n finalAttempts: number,\n ): Promise<void> {\n await this.db\n .update(jobRuns)\n .set({\n status: 'failed',\n attempts: finalAttempts,\n finishedAt: new Date(),\n error: serialiseError(err, finalAttempts, false),\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, claimed.id));\n\n // Parent-close-policy cascade: if this run has children under the same\n // root_run_id and this run's own parentClosePolicy is 'terminate', cascade.\n if (claimed.parentClosePolicy === 'terminate') {\n try {\n // JOB-8 — thread the run's own tenantId so the orchestrator's\n // multi-tenant gate passes. Without this, every terminate-policy\n // cascade throws MissingTenantIdError under multiTenant=true and\n // the outer catch silently swallows it — children never cancel.\n await this.orchestrator.cancel(claimed.id, {\n cascade: true,\n reason: 'parent-failed',\n tenantId: claimed.tenantId,\n });\n } catch (cascadeErr) {\n // cancel is idempotent; failure here is unusual but not fatal.\n this.logger.warn(\n `cascade on failed run ${claimed.id}: ${(cascadeErr as Error).message}`,\n );\n }\n }\n }\n\n // ============================================================================\n // ctx.step / ctx.spawnChild builders\n // ============================================================================\n\n private makeStepFn(run: JobRunRow) {\n return async <TOutput>(\n stepId: string,\n fn: () => Promise<TOutput>,\n _opts?: StepOptions,\n ): Promise<TOutput> => {\n void _opts;\n const existing = await this.stepService.findStep(run.id, stepId);\n if (existing?.status === 'completed') {\n return existing.output as TOutput;\n }\n\n const seq = await this.nextStepSeq(run.id);\n const startedAt = new Date();\n const nextAttempts = (existing?.attempts ?? 0) + 1;\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'running',\n startedAt,\n attempts: nextAttempts,\n });\n try {\n const output = await fn();\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'completed',\n output: output as Record<string, unknown> | undefined,\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n return output;\n } catch (err) {\n await this.stepService.recordStep({\n jobRunId: run.id,\n stepId,\n kind: 'task',\n seq,\n status: 'failed',\n error: serialiseError(err, nextAttempts, false),\n finishedAt: new Date(),\n attempts: nextAttempts,\n });\n throw err;\n }\n };\n }\n\n private makeSpawnFn(run: JobRunRow) {\n return async (\n type: string,\n input: unknown,\n opts?: SpawnChildOptions,\n ): Promise<JobRun> => {\n return this.orchestrator.start(type, input, {\n parentRunId: run.id,\n parentClosePolicy: opts?.closePolicy,\n runAt: opts?.runAt,\n priority: opts?.priority,\n tags: opts?.tags,\n triggerSource: 'parent',\n triggerRef: run.id,\n });\n };\n }\n\n /**\n * Allocate the next `seq` for a given run. SELECT-max approach — runs\n * typically have <100 steps so the scan is cheap, and correctness across\n * retries is more important than the microseconds saved by an in-memory\n * counter (which would drift if the worker crashes mid-run and another\n * worker resumes via stale-claim sweep).\n */\n private async nextStepSeq(runId: string): Promise<number> {\n const result = await this.db.execute(\n sql`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM job_step WHERE job_run_id = ${runId}`,\n );\n // Driver shape varies and is NOT uniformly array-iterable, so we must\n // never array-destructure the raw result (that throws `{} is not iterable`\n // on the node-postgres `Result` object, which exposes `.rows` instead of\n // being an array — first hit by package-mode bridge deliveries on\n // `drizzle-orm/node-postgres`). Normalise to a row array first, then read.\n // - node-postgres `db.execute(sql)` → `{ rows: [{ next }], ... }`\n // - some drivers / future shapes → a plain `[{ next }]` array\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const raw = result as any;\n const rows: Array<{ next?: unknown }> = Array.isArray(raw)\n ? raw\n : Array.isArray(raw?.rows)\n ? raw.rows\n : [];\n const next = rows[0]?.next;\n return typeof next === 'undefined' ? 1 : Number(next);\n }\n\n // ============================================================================\n // (suppress unused-import noise)\n // ============================================================================\n}\n\n// Terminal statuses re-exported for JOB-4 parity imports.\nexport { TERMINAL_STATUSES };\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts"],"sourcesContent":["/**\n * DrizzleJobOrchestrator — Postgres-backed implementation of\n * `IJobOrchestrator` (ADR-022, JOB-3).\n *\n * Single-layer architecture: `start` writes a single `job_run` row; the\n * `JobWorker` polling loop claims it directly via `FOR UPDATE SKIP LOCKED`.\n * No `job_queue` table, no executor port. See `docs/specs/JOB-3.md`.\n */\nimport { randomUUID } from 'node:crypto';\nimport { Inject, Injectable, Logger, Optional } from '@nestjs/common';\nimport { and, desc, eq, gt, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport type { DrizzleTransaction } from '../events/event-bus.protocol';\nimport { DRIZZLE } from '../../constants/tokens';\nimport {\n jobRuns,\n jobs,\n type JobDefinitionRow,\n type JobRunRow,\n} from './job-orchestration.schema';\nimport type {\n CancelOptions,\n IJobOrchestrator,\n JobPoolDef,\n JobRun,\n JobUpsertEntry,\n StartOptions,\n} from './job-orchestrator.protocol';\nimport {\n JobCollisionError,\n JobNotReplayableError,\n JobTemplateFieldMissingError,\n JobTypeNotFoundError,\n MissingTenantIdError,\n} from './jobs-errors';\nimport { jobSteps } from './job-orchestration.schema';\nimport { JOBS_MULTI_TENANT, JOBS_LISTEN_NOTIFY } from './jobs-domain.tokens';\nimport { JOBS_WAKE_CHANNEL, pgNotify } from './pg-notify';\n\n/**\n * Terminal statuses — transitions into these are final. Used by `cancel`\n * (to short-circuit idempotently) and by `replay` (as the guard gate).\n */\nexport const TERMINAL_STATUSES = [\n 'completed',\n 'failed',\n 'timed_out',\n 'canceled',\n] as const;\ntype TerminalStatus = (typeof TERMINAL_STATUSES)[number];\ntype JobRunStatus = JobRunRow['status'];\n\n/** Statuses excluded from dedupe window matches per ADR-022. */\nconst DEDUPE_EXCLUDED_STATUSES: JobRunStatus[] = ['canceled', 'failed'];\n/** Statuses that count as in-flight for concurrency collision checks. */\nconst IN_FLIGHT_STATUSES: JobRunStatus[] = ['pending', 'running'];\n\n/**\n * Substitute `{{field}}` placeholders against the input payload.\n *\n * Implementation decision (JOB-3, 2026-04-19): simple `{{field}}` single-key\n * substitution, no dotted paths, no Mustache/Handlebars dependency. A missing\n * field throws `JobTemplateFieldMissingError` synchronously — cheaper than\n * discovering the misconfiguration at claim time.\n */\nexport function evaluateKeyTemplate(\n template: string,\n input: Record<string, unknown>,\n): string {\n return template.replace(/\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g, (_match, field: string) => {\n const value = input[field];\n if (value === undefined || value === null) {\n throw new JobTemplateFieldMissingError(template, field);\n }\n return String(value);\n });\n}\n\n@Injectable()\nexport class DrizzleJobOrchestrator implements IJobOrchestrator {\n // TODO(logging-subsystem): swap to ILogger once ADR-028 lands\n private readonly logger = new Logger(DrizzleJobOrchestrator.name);\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,\n // LISTEN-NOTIFY-1 — when true, `start()` emits an in-tx\n // `pg_notify(codegen_jobs_wake, <pool>)` so a `listen_notify` worker wakes\n // on enqueue-commit. `@Optional()` defaulting to false so direct\n // construction (integration tests not going through DI) keeps working.\n @Optional()\n @Inject(JOBS_LISTEN_NOTIFY)\n private readonly listenNotify: boolean = false,\n ) {}\n\n /**\n * JOB-8 — resolve `tenantId` for a mutating / targeted-read call.\n * Returns the tenant value that should be written to the row (or compared\n * against in a WHERE clause). When `multiTenant` is off, the column is\n * forced to `null` regardless of what callers pass. When on, `undefined`\n * throws; `null` and strings pass through untouched.\n */\n private resolveTenantId(\n method: string,\n tenantId: string | null | undefined,\n ): string | null {\n if (!this.multiTenant) return null;\n if (tenantId === undefined) throw new MissingTenantIdError(method);\n return tenantId;\n }\n\n // ==========================================================================\n // start\n // ==========================================================================\n\n async start(\n type: string,\n input: unknown,\n opts: StartOptions = {},\n tx?: DrizzleTransaction,\n ): Promise<JobRun> {\n const payload = (input ?? {}) as Record<string, unknown>;\n\n // JOB-8 — resolve tenant gate up front so `multi_tenant=true` +\n // undefined surfaces before any row is touched.\n const tenantId = this.resolveTenantId('start', opts.tenantId);\n\n // BRIDGE-7: thread the optional caller tx through every read/write\n // in this method so EventFlowService.publishAndStart can bundle the\n // outbox insert, the eager job_run insert, and (for Case B) the\n // bridge_delivery pre-write into a single transaction.\n const client = (tx ?? this.db) as DrizzleClient;\n\n // 1a. Load job definition.\n const [def] = await client\n .select()\n .from(jobs)\n .where(eq(jobs.type, type))\n .limit(1);\n if (!def) throw new JobTypeNotFoundError(type);\n const definition = def as JobDefinitionRow;\n\n // 1b. Dedupe check.\n if (definition.dedupeKeyTemplate && definition.dedupeWindowMs) {\n const dedupeKey = evaluateKeyTemplate(definition.dedupeKeyTemplate, payload);\n const windowStart = new Date(Date.now() - definition.dedupeWindowMs);\n const existing = await client\n .select()\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.jobType, type),\n eq(jobRuns.dedupeKey, dedupeKey),\n gt(jobRuns.createdAt, windowStart),\n // status NOT IN ('canceled', 'failed')\n notInStatus(DEDUPE_EXCLUDED_STATUSES),\n ),\n )\n .orderBy(desc(jobRuns.createdAt))\n .limit(1);\n if (existing.length > 0) {\n return existing[0] as JobRun;\n }\n }\n\n // 1c. Concurrency collision check.\n let concurrencyKey: string | null = null;\n if (definition.concurrencyKeyTemplate) {\n concurrencyKey = evaluateKeyTemplate(\n definition.concurrencyKeyTemplate,\n payload,\n );\n const inFlight = await client\n .select()\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.concurrencyKey, concurrencyKey),\n inArray(jobRuns.status, IN_FLIGHT_STATUSES),\n ),\n )\n .limit(1);\n if (inFlight.length > 0) {\n const incumbent = inFlight[0] as JobRun;\n switch (definition.collisionMode) {\n case 'reject':\n throw new JobCollisionError(type, concurrencyKey, incumbent);\n case 'replace':\n // JOB-8 — thread the incumbent's own tenantId through the\n // internal cascade. Without this, every `replace`-collision\n // start() under multiTenant=true throws MissingTenantIdError\n // from the inner cancel() call instead of cancelling the\n // incumbent. Mirrors the memory backend's `cancelLocked(\n // incumbent.id, ..., incumbent.tenantId)` pattern.\n await this.cancel(incumbent.id, {\n cascade: true,\n reason: 'replaced',\n tenantId: incumbent.tenantId,\n });\n break;\n case 'queue':\n // Fall through — row is inserted; claim query gates it until\n // the incumbent transitions (see JobWorker.processRun queue gate).\n break;\n }\n }\n }\n\n // 1d. Resolve id + rootRunId, INSERT.\n const newId = randomUUID();\n let rootRunId: string = newId;\n if (opts.parentRunId) {\n const [parent] = await client\n .select({ rootRunId: jobRuns.rootRunId })\n .from(jobRuns)\n .where(eq(jobRuns.id, opts.parentRunId))\n .limit(1);\n if (!parent) {\n throw new Error(\n `parentRunId ${opts.parentRunId} does not reference an existing job_run`,\n );\n }\n rootRunId = parent.rootRunId;\n }\n\n const dedupeKey =\n definition.dedupeKeyTemplate\n ? evaluateKeyTemplate(definition.dedupeKeyTemplate, payload)\n : null;\n\n const [inserted] = await client\n .insert(jobRuns)\n .values({\n id: newId,\n jobType: type,\n jobVersion: definition.version,\n parentRunId: opts.parentRunId ?? null,\n rootRunId,\n parentClosePolicy: opts.parentClosePolicy ?? 'terminate',\n scopeEntityType: opts.scope?.entityType ?? null,\n scopeEntityId: opts.scope?.entityId ?? null,\n tenantId,\n tags: opts.tags ?? {},\n pool: opts.pool ?? definition.pool,\n priority: opts.priority ?? definition.priorityDefault,\n concurrencyKey,\n dedupeKey,\n status: 'pending',\n input: payload,\n output: null,\n error: null,\n triggerSource: opts.triggerSource ?? 'manual',\n triggerRef: opts.triggerRef ?? null,\n runAt: opts.runAt ?? new Date(),\n startedAt: null,\n finishedAt: null,\n claimedAt: null,\n attempts: 0,\n })\n .returning();\n\n // LISTEN-NOTIFY-1 — wake a listening worker the instant this enqueue\n // commits. Emitted through the SAME `client` (the caller's tx when one was\n // passed, else the pool) so delivery is gated on commit — a rolled-back\n // enqueue emits no phantom wake (D2). The pool name is the payload; the\n // worker re-runs its own pool-filtered claim query on wake. Polling is the\n // fallback, so a failed notify is non-fatal: log + continue.\n if (this.listenNotify) {\n const wakePool = (inserted as JobRunRow).pool;\n try {\n await pgNotify(client, JOBS_WAKE_CHANNEL, wakePool);\n } catch (err) {\n this.logger.warn(\n `pg_notify(${JOBS_WAKE_CHANNEL}, ${wakePool}) failed for run ` +\n `${(inserted as JobRunRow).id}: ${(err as Error).message} ` +\n `(non-fatal — interval polling still claims the run).`,\n );\n }\n }\n\n return inserted as JobRun;\n }\n\n // ==========================================================================\n // cancel\n // ==========================================================================\n\n async cancel(runId: string, opts: CancelOptions = {}): Promise<void> {\n // JOB-8 — resolve tenant gate up front (strict undefined-throws).\n const tenantId = this.resolveTenantId('cancel', opts.tenantId);\n\n // Load target.\n const [target] = await this.db\n .select()\n .from(jobRuns)\n .where(eq(jobRuns.id, runId))\n .limit(1);\n if (!target) return;\n // JOB-8 — cross-tenant cancel is a silent no-op (no existence leak).\n if (this.multiTenant && target.tenantId !== tenantId) return;\n if (TERMINAL_STATUSES.includes(target.status as TerminalStatus)) {\n return; // idempotent\n }\n\n // Atomic transition, guarded against concurrent terminal moves.\n const [cancelled] = await this.db\n .update(jobRuns)\n .set({\n status: 'canceled',\n finishedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(\n and(eq(jobRuns.id, runId), notInStatus([...TERMINAL_STATUSES])),\n )\n .returning();\n\n if (!cancelled) return; // lost the race; already terminal\n\n if (opts.cascade === false) return;\n\n // Fetch descendants and branch on parent_close_policy.\n const descendants = await this.db\n .select()\n .from(jobRuns)\n .where(\n and(\n eq(jobRuns.rootRunId, target.rootRunId),\n ne(jobRuns.id, runId),\n notInStatus([...TERMINAL_STATUSES]),\n ),\n );\n\n for (const child of descendants) {\n const policy = (child as JobRunRow).parentClosePolicy;\n if (policy === 'abandon') continue;\n // 'terminate' | 'cancel' — both transition the child to canceled.\n await this.db\n .update(jobRuns)\n .set({\n status: 'canceled',\n finishedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(\n and(\n eq(jobRuns.id, (child as JobRunRow).id),\n notInStatus([...TERMINAL_STATUSES]),\n ),\n );\n }\n\n void opts.reason; // reserved for future audit logging\n }\n\n // ==========================================================================\n // replay\n // ==========================================================================\n\n async replay(runId: string): Promise<JobRun> {\n // Load target + its job definition (we need replay_from).\n const [target] = await this.db\n .select()\n .from(jobRuns)\n .where(eq(jobRuns.id, runId))\n .limit(1);\n if (!target) {\n throw new Error(`replay: run ${runId} not found`);\n }\n const run = target as JobRunRow;\n if (!TERMINAL_STATUSES.includes(run.status as TerminalStatus)) {\n throw new JobNotReplayableError(runId, run.status);\n }\n\n const [def] = await this.db\n .select()\n .from(jobs)\n .where(eq(jobs.type, run.jobType))\n .limit(1);\n if (!def) throw new JobTypeNotFoundError(run.jobType);\n const mode = (def as JobDefinitionRow).replayFrom;\n\n // Atomic: step reset + run reset must commit together.\n const result = await this.db.transaction(async (tx) => {\n if (mode === 'scratch') {\n await tx.delete(jobSteps).where(eq(jobSteps.jobRunId, runId));\n } else if (mode === 'last_step') {\n // Delete only non-completed step rows — completed steps stay memoised.\n await tx\n .delete(jobSteps)\n .where(\n and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),\n );\n } else {\n // 'last_checkpoint' — Phase 1 has no explicit checkpoint markers, so\n // behaviour collapses to `last_step`. See docs/specs/JOB-3.md\n // \"Implementation Decisions\" — planned divergence in a later phase.\n await tx\n .delete(jobSteps)\n .where(\n and(eq(jobSteps.jobRunId, runId), ne(jobSteps.status, 'completed')),\n );\n }\n\n const [updated] = await tx\n .update(jobRuns)\n .set({\n status: 'pending',\n attempts: 0,\n runAt: new Date(),\n startedAt: null,\n finishedAt: null,\n claimedAt: null,\n error: null,\n output: null,\n updatedAt: new Date(),\n })\n .where(eq(jobRuns.id, runId))\n .returning();\n return updated as JobRunRow;\n });\n\n return result as JobRun;\n }\n\n // ==========================================================================\n // upsertJobRows — boot-time materialisation of `job` definitions\n // ==========================================================================\n\n /**\n * Hash-gated `INSERT … ON CONFLICT (type) DO UPDATE … WHERE` per Q3\n * resolution (2026-04-19): the `UPDATE` branch executes only when one\n * of the persisted metadata fields differs from the incoming payload;\n * `version` bumps only on real change; concurrent boots with identical\n * content are idempotent no-ops.\n *\n * Why this shape (not `DO NOTHING`, not advisory locks):\n * - `DO NOTHING` would let an old-version instance leave a stale row\n * that a new-version instance can't overwrite during a rolling deploy.\n * - Advisory locks add latency and leak risk under crashes.\n * - The `WHERE … IS DISTINCT FROM …` clause makes the conditional\n * atomic — no read-modify-write race on `version` between concurrent\n * boots.\n *\n * Orphan detection: a single `SELECT type FROM job WHERE type NOT IN (...)`\n * returns the types present in DB but absent from `entries`. Caller (boot\n * validator) decides whether to throw `BootValidationError`.\n */\n async upsertJobRows(\n entries: JobUpsertEntry[],\n poolConfig: ReadonlyMap<string, JobPoolDef>,\n ): Promise<{ orphaned: string[] }> {\n void poolConfig; // pool validation is the module's responsibility; orchestrator just persists\n\n for (const entry of entries) {\n const meta = entry.meta;\n const pool = meta.pool ?? 'batch';\n const retryPolicy = meta.retry ?? {\n attempts: 1,\n backoff: 'fixed' as const,\n baseMs: 0,\n };\n const concurrencyKeyTemplate =\n (meta.concurrency as { key?: unknown } | undefined)?.key;\n const concurrencyKeyTemplateStr =\n typeof concurrencyKeyTemplate === 'string' ? concurrencyKeyTemplate : null;\n const collisionMode =\n (meta.concurrency?.collisionMode as JobDefinitionRow['collisionMode']) ??\n 'queue';\n const dedupeKeyTemplate =\n (meta.dedupe as { key?: unknown } | undefined)?.key;\n const dedupeKeyTemplateStr =\n typeof dedupeKeyTemplate === 'string' ? dedupeKeyTemplate : null;\n const dedupeWindowMs = meta.dedupe?.windowMs ?? null;\n const timeoutMs = meta.timeoutMs ?? null;\n const replayFrom = meta.replayFrom ?? 'last_checkpoint';\n const scopeEntityType = meta.scope?.entity ?? null;\n // Q3 resolution: priority_default and replay_from are part of the\n // hashed metadata even though they aren't currently set via decorator\n // metadata above (priority_default has no `@JobHandler` field yet).\n // Default to 0 to keep UPDATE branch quiet across deploys.\n const priorityDefault = 0;\n\n // Hash-gated upsert: every metadata column appears in the WHERE clause\n // so the UPDATE branch only fires on a real change. `version` bumps\n // exactly when the WHERE matches.\n await this.db\n .insert(jobs)\n .values({\n type: entry.type,\n version: 1,\n pool,\n scopeEntityType,\n retryPolicy,\n timeoutMs,\n concurrencyKeyTemplate: concurrencyKeyTemplateStr,\n collisionMode,\n dedupeKeyTemplate: dedupeKeyTemplateStr,\n dedupeWindowMs,\n priorityDefault,\n replayFrom,\n })\n .onConflictDoUpdate({\n target: jobs.type,\n set: {\n pool: sql`EXCLUDED.pool`,\n scopeEntityType: sql`EXCLUDED.scope_entity_type`,\n retryPolicy: sql`EXCLUDED.retry_policy`,\n timeoutMs: sql`EXCLUDED.timeout_ms`,\n concurrencyKeyTemplate: sql`EXCLUDED.concurrency_key_template`,\n collisionMode: sql`EXCLUDED.collision_mode`,\n dedupeKeyTemplate: sql`EXCLUDED.dedupe_key_template`,\n dedupeWindowMs: sql`EXCLUDED.dedupe_window_ms`,\n priorityDefault: sql`EXCLUDED.priority_default`,\n replayFrom: sql`EXCLUDED.replay_from`,\n version: sql`${jobs.version} + 1`,\n updatedAt: sql`now()`,\n },\n // The hash gate: every field listed in the Q3 resolution appears\n // here. `IS DISTINCT FROM` is the null-safe inequality operator;\n // jsonb cast to text gives stable comparison without invoking a\n // dedicated hash column (avoids a JOB-1 schema migration).\n setWhere: sql`\n ${jobs.pool} IS DISTINCT FROM EXCLUDED.pool OR\n ${jobs.retryPolicy}::text IS DISTINCT FROM EXCLUDED.retry_policy::text OR\n ${jobs.timeoutMs} IS DISTINCT FROM EXCLUDED.timeout_ms OR\n ${jobs.concurrencyKeyTemplate} IS DISTINCT FROM EXCLUDED.concurrency_key_template OR\n ${jobs.collisionMode} IS DISTINCT FROM EXCLUDED.collision_mode OR\n ${jobs.dedupeKeyTemplate} IS DISTINCT FROM EXCLUDED.dedupe_key_template OR\n ${jobs.dedupeWindowMs} IS DISTINCT FROM EXCLUDED.dedupe_window_ms OR\n ${jobs.priorityDefault} IS DISTINCT FROM EXCLUDED.priority_default OR\n ${jobs.replayFrom} IS DISTINCT FROM EXCLUDED.replay_from OR\n ${jobs.scopeEntityType} IS DISTINCT FROM EXCLUDED.scope_entity_type\n `,\n });\n }\n\n // Orphan detection: any `job` row whose type is not in the registry.\n const types = entries.map((e) => e.type);\n const orphans =\n types.length === 0\n ? await this.db.select({ type: jobs.type }).from(jobs)\n : await this.db\n .select({ type: jobs.type })\n .from(jobs)\n .where(notInArray(jobs.type, types));\n\n return { orphaned: orphans.map((o) => o.type) };\n }\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction notInStatus(statuses: JobRunStatus[]) {\n // Drizzle's inArray composes with `not` via negation helper; use raw sql\n // to stay readable. `inArray` + `.not()` isn't idiomatic in 0.45.\n const negated = statuses.map((s) => ne(jobRuns.status, s));\n return and(...negated);\n}\n\n// `isNotNull` + `gt` imports are retained for potential future use; silence\n// unused-import lint by re-exporting via `void`.\nvoid isNotNull;\n"],"mappings":"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","names":["dedupeKey"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/integration/integration.module.ts"],"sourcesContent":["/**\n * IntegrationModule — `DynamicModule.forRoot({ backend, multiTenant? })` factory\n * wiring the integration subsystem's substrate (SYNC-6, ADR-008 subsystem pattern).\n *\n * ## What this module provides\n *\n * - `INTEGRATION_CURSOR_STORE` — Drizzle or Memory cursor store\n * - `INTEGRATION_RUN_RECORDER` — Drizzle or Memory run recorder\n * - `INTEGRATION_FIELD_DIFFER` — default `DeepEqualDiffer`\n * - `INTEGRATION_MULTI_TENANT` — resolved boolean flag (defaults to false)\n * - `INTEGRATION_MODULE_OPTIONS` — the options object itself, for backends\n * that need to inspect config at construction time\n *\n * ## What this module does NOT provide\n *\n * - `INTEGRATION_CHANGE_SOURCE` — per-provider per-entity; consumer binds in\n * their feature module (e.g. `OpportunityIntegrationModule` provides a\n * `SalesforceOpportunityChangeSource`). Loopback suppression — when\n * needed — is composed into the primitive's middleware chain via\n * `createLoopbackMiddleware(store)` (#226-5 / ADR-033); the\n * orchestrator no longer accepts a fingerprint store directly.\n * - `INTEGRATION_SINK` — per canonical entity; consumer binds in their feature\n * module.\n * - `ExecuteIntegrationUseCase` — registered by the feature module alongside\n * its source + sink bindings. Providing the orchestrator here would\n * force Nest to resolve INTEGRATION_CHANGE_SOURCE + INTEGRATION_SINK at module\n * compile time, which fails when the feature module hasn't been\n * imported yet. Consumers register `ExecuteIntegrationUseCase` in the same\n * `providers` array as their source + sink so resolution is local\n * to where all three are bound.\n *\n * Same shape as `EventsModule.forRoot` — the module wires the bus; you\n * bring your own handlers. Here: the module wires the substrate; you\n * bring your own source + sink.\n *\n * ## Usage\n *\n * ```ts\n * // AppModule — single source of truth for backend + multi-tenancy.\n * @Module({\n * imports: [IntegrationModule.forRoot({ backend: 'drizzle' })],\n * })\n * export class AppModule {}\n *\n * // Per-entity feature module — binds source + sink, gets the\n * // orchestrator for free.\n * @Module({\n * providers: [\n * { provide: INTEGRATION_CHANGE_SOURCE, useClass: SalesforceOpportunitySource },\n * { provide: INTEGRATION_SINK, useClass: OpportunityIntegrationSink },\n * ExecuteIntegrationUseCase,\n * ],\n * })\n * export class OpportunityIntegrationModule {\n * constructor(\n * private readonly execute: ExecuteIntegrationUseCase<CanonicalOpportunity>,\n * ) {}\n * }\n * ```\n *\n * `global: true` means feature modules do not need to re-import\n * `IntegrationModule` — the substrate tokens are available project-wide.\n */\nimport { Module, type DynamicModule, type Provider } from '@nestjs/common';\nimport {\n INTEGRATION_CURSOR_STORE,\n INTEGRATION_FIELD_DIFFER,\n INTEGRATION_MODULE_OPTIONS,\n INTEGRATION_MULTI_TENANT,\n INTEGRATION_RUN_RECORDER,\n} from './integration.tokens';\nimport { MemoryCursorStore } from './integration-cursor-store.memory-backend';\nimport { MemoryRunRecorder } from './integration-run-recorder.memory-backend';\nimport { PostgresCursorStore } from './integration-cursor-store.drizzle-backend';\nimport { DrizzleIntegrationRunRecorder } from './integration-run-recorder.drizzle-backend';\nimport { DeepEqualDiffer } from './deep-equal.differ';\n\nexport interface IntegrationModuleOptions {\n /**\n * Backend selection. `drizzle` wires the Postgres cursor store +\n * run-log recorder; `memory` wires in-memory doubles suitable for\n * tests + local dev.\n */\n backend: 'drizzle' | 'memory';\n\n /**\n * Multi-tenancy opt-in (SYNC-6).\n *\n * When `true`, every call to the orchestrator + both Drizzle backends\n * must supply a non-null `tenantId`; missing values throw\n * `MissingTenantIdError`. Defense-in-depth: the orchestrator rejects\n * at entry (no dangling `status=running` rows) AND the Drizzle\n * backends reject at their write boundary (belt-and-braces for any\n * path that bypasses the orchestrator). Both sites use the shared\n * `assertTenantId` helper so error messages match.\n *\n * Memory backends accept `tenantId` unconditionally — their state is\n * process-local; cross-tenant isolation there is not meaningful.\n *\n * Defaults to `false`.\n */\n multiTenant?: boolean;\n}\n\n@Module({})\nexport class IntegrationModule {\n static forRoot(options: IntegrationModuleOptions): DynamicModule {\n const multiTenant = options.multiTenant ?? false;\n\n const sharedProviders: Provider[] = [\n { provide: INTEGRATION_MODULE_OPTIONS, useValue: options },\n { provide: INTEGRATION_MULTI_TENANT, useValue: multiTenant },\n // Default differ — consumers can override by binding a different\n // `IFieldDiffer<T>` to `INTEGRATION_FIELD_DIFFER` in their feature module.\n { provide: INTEGRATION_FIELD_DIFFER, useValue: new DeepEqualDiffer() },\n ];\n\n const backendProviders: Provider[] =\n options.backend === 'memory'\n ? [\n // Wired as singletons via `useValue` so tests can pull\n // them out via `moduleRef.get(MemoryCursorStore)` for\n // direct assertions. Matches JOB-4 / MemoryJobStore shape.\n { provide: MemoryCursorStore, useValue: new MemoryCursorStore() },\n {\n provide: INTEGRATION_CURSOR_STORE,\n useExisting: MemoryCursorStore,\n },\n { provide: MemoryRunRecorder, useValue: new MemoryRunRecorder() },\n {\n provide: INTEGRATION_RUN_RECORDER,\n useExisting: MemoryRunRecorder,\n },\n ]\n : [\n // Drizzle backends — injected with DRIZZLE (provided by the\n // consumer's DrizzleModule) + the INTEGRATION_MULTI_TENANT flag\n // we bound above.\n { provide: INTEGRATION_CURSOR_STORE, useClass: PostgresCursorStore },\n { provide: INTEGRATION_RUN_RECORDER, useClass: DrizzleIntegrationRunRecorder },\n ];\n\n return {\n module: IntegrationModule,\n global: true,\n providers: [...sharedProviders, ...backendProviders],\n exports: [\n INTEGRATION_MODULE_OPTIONS,\n INTEGRATION_MULTI_TENANT,\n INTEGRATION_FIELD_DIFFER,\n INTEGRATION_CURSOR_STORE,\n INTEGRATION_RUN_RECORDER,\n ],\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA+DA,SAAS,cAAiD;AA0CnD,IAAM,oBAAN,MAAwB;AAAA,EAC7B,OAAO,QAAQ,SAAkD;AAC/D,UAAM,cAAc,QAAQ,eAAe;AAE3C,UAAM,kBAA8B;AAAA,MAClC,EAAE,SAAS,4BAA4B,UAAU,QAAQ;AAAA,MACzD,EAAE,SAAS,0BAA0B,UAAU,YAAY;AAAA;AAAA;AAAA,MAG3D,EAAE,SAAS,0BAA0B,UAAU,IAAI,gBAAgB,EAAE;AAAA,IACvE;AAEA,UAAM,mBACJ,QAAQ,YAAY,WAChB;AAAA;AAAA;AAAA;AAAA,MAIE,EAAE,SAAS,mBAAmB,UAAU,IAAI,kBAAkB,EAAE;AAAA,MAChE;AAAA,QACE,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,MACA,EAAE,SAAS,mBAAmB,UAAU,IAAI,kBAAkB,EAAE;AAAA,MAChE;AAAA,QACE,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,IACF,IACA;AAAA;AAAA;AAAA;AAAA,MAIE,EAAE,SAAS,0BAA0B,UAAU,oBAAoB;AAAA,MACnE,EAAE,SAAS,0BAA0B,UAAU,8BAA8B;AAAA,IAC/E;AAEN,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,GAAG,iBAAiB,GAAG,gBAAgB;AAAA,MACnD,SAAS;AAAA,QACP;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAlDa,oBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}