@pattern-stack/codegen 0.22.0 → 0.24.0

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 (104) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/consumer-skills/integration/SKILL.md +11 -3
  3. package/dist/{chunk-XKWOJZZ4.js → chunk-37PILMIT.js} +4 -4
  4. package/dist/{chunk-NR7QQ6ZI.js → chunk-6M6LZEP6.js} +3 -3
  5. package/dist/{chunk-VDL5CJ5C.js → chunk-7B7MMDOJ.js} +54 -1
  6. package/dist/chunk-7B7MMDOJ.js.map +1 -0
  7. package/dist/{chunk-NXHL5YII.js → chunk-7LKAMLV4.js} +4 -4
  8. package/dist/{chunk-6DQEIXYU.js → chunk-CKLM57IE.js} +10 -10
  9. package/dist/chunk-CKLM57IE.js.map +1 -0
  10. package/dist/{chunk-QXVCRA23.js → chunk-ENAR3F5S.js} +9 -4
  11. package/dist/chunk-ENAR3F5S.js.map +1 -0
  12. package/dist/{chunk-FFUDEIFF.js → chunk-HN5HT5WL.js} +2 -2
  13. package/dist/{chunk-6ECCJVYW.js → chunk-K4BQQ2NN.js} +46 -2
  14. package/dist/chunk-K4BQQ2NN.js.map +1 -0
  15. package/dist/{chunk-QFUIE37H.js → chunk-KFXXOFDC.js} +4 -4
  16. package/dist/{chunk-O2A6XHGD.js → chunk-LLDJS7PJ.js} +2 -2
  17. package/dist/{chunk-JOBQ6RUU.js → chunk-LQZESSM3.js} +28 -1
  18. package/dist/chunk-LQZESSM3.js.map +1 -0
  19. package/dist/{chunk-JRQO2IOF.js → chunk-MU54DZCC.js} +27 -1
  20. package/dist/chunk-MU54DZCC.js.map +1 -0
  21. package/dist/{chunk-INO47JXD.js → chunk-PBENHIN2.js} +3 -3
  22. package/dist/{chunk-CLWBNXKF.js → chunk-PLUJEQLU.js} +2 -2
  23. package/dist/{chunk-DB5UXJC3.js → chunk-PNCOUFFI.js} +4 -2
  24. package/dist/chunk-PNCOUFFI.js.map +1 -0
  25. package/dist/{chunk-S7C6TIIF.js → chunk-S5G3HO7N.js} +3 -1
  26. package/dist/chunk-S5G3HO7N.js.map +1 -0
  27. package/dist/{chunk-FNHNSFIJ.js → chunk-WZOPWQN2.js} +2 -2
  28. package/dist/{chunk-TDEHU73T.js → chunk-YIVQ7KLS.js} +46 -5
  29. package/dist/chunk-YIVQ7KLS.js.map +1 -0
  30. package/dist/runtime/subsystems/auth/auth.module.js +2 -2
  31. package/dist/runtime/subsystems/auth/index.js +4 -4
  32. package/dist/runtime/subsystems/bridge/bridge.module.js +7 -7
  33. package/dist/runtime/subsystems/bridge/index.js +7 -7
  34. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +1 -1
  35. package/dist/runtime/subsystems/events/events.module.js +5 -5
  36. package/dist/runtime/subsystems/events/generated/bus.js +3 -3
  37. package/dist/runtime/subsystems/events/generated/index.d.ts +2 -2
  38. package/dist/runtime/subsystems/events/generated/index.js +9 -3
  39. package/dist/runtime/subsystems/events/generated/registry.d.ts +36 -0
  40. package/dist/runtime/subsystems/events/generated/registry.js +1 -1
  41. package/dist/runtime/subsystems/events/generated/schemas.d.ts +109 -1
  42. package/dist/runtime/subsystems/events/generated/schemas.js +7 -1
  43. package/dist/runtime/subsystems/events/generated/types.d.ts +48 -2
  44. package/dist/runtime/subsystems/events/index.js +5 -5
  45. package/dist/runtime/subsystems/index.d.ts +3 -2
  46. package/dist/runtime/subsystems/index.js +29 -25
  47. package/dist/runtime/subsystems/integration/execute-integration.use-case.d.ts +11 -1
  48. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  49. package/dist/runtime/subsystems/integration/index.d.ts +2 -1
  50. package/dist/runtime/subsystems/integration/index.js +10 -8
  51. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.d.ts +106 -0
  52. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js +1 -0
  53. package/dist/runtime/subsystems/integration/integration-change-emitter.protocol.js.map +1 -0
  54. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  55. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  56. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  57. package/dist/runtime/subsystems/integration/integration.tokens.d.ts +11 -1
  58. package/dist/runtime/subsystems/integration/integration.tokens.js +3 -1
  59. package/dist/runtime/subsystems/jobs/index.js +12 -12
  60. package/dist/runtime/subsystems/jobs/job-worker.d.ts +592 -4
  61. package/dist/runtime/subsystems/jobs/job-worker.js +3 -1
  62. package/dist/runtime/subsystems/jobs/job-worker.module.js +6 -6
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +19 -0
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +4 -4
  65. package/dist/runtime/subsystems/observability/index.js +3 -3
  66. package/dist/runtime/subsystems/observability/observability.module.js +3 -3
  67. package/dist/runtime/subsystems/observability/observability.service.js +2 -2
  68. package/dist/src/cli/index.js +413 -85
  69. package/dist/src/cli/index.js.map +1 -1
  70. package/dist/src/index.d.ts +490 -1
  71. package/dist/src/index.js +7 -7
  72. package/package.json +1 -1
  73. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +23 -7
  74. package/runtime/subsystems/events/generated/registry.ts +27 -0
  75. package/runtime/subsystems/events/generated/schemas.ts +26 -0
  76. package/runtime/subsystems/events/generated/types.ts +52 -0
  77. package/runtime/subsystems/index.ts +23 -0
  78. package/runtime/subsystems/integration/execute-integration.use-case.ts +69 -1
  79. package/runtime/subsystems/integration/index.ts +6 -0
  80. package/runtime/subsystems/integration/integration-change-emitter.protocol.ts +107 -0
  81. package/runtime/subsystems/integration/integration.tokens.ts +11 -0
  82. package/runtime/subsystems/jobs/job-worker.module.ts +5 -0
  83. package/runtime/subsystems/jobs/job-worker.ts +126 -12
  84. package/runtime/subsystems/jobs/jobs-domain.module.ts +19 -0
  85. package/templates/entity/new/clean-lite-ps/prompt-extension.js +59 -10
  86. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +11 -0
  87. package/dist/chunk-6DQEIXYU.js.map +0 -1
  88. package/dist/chunk-6ECCJVYW.js.map +0 -1
  89. package/dist/chunk-DB5UXJC3.js.map +0 -1
  90. package/dist/chunk-JOBQ6RUU.js.map +0 -1
  91. package/dist/chunk-JRQO2IOF.js.map +0 -1
  92. package/dist/chunk-QXVCRA23.js.map +0 -1
  93. package/dist/chunk-S7C6TIIF.js.map +0 -1
  94. package/dist/chunk-TDEHU73T.js.map +0 -1
  95. package/dist/chunk-VDL5CJ5C.js.map +0 -1
  96. /package/dist/{chunk-XKWOJZZ4.js.map → chunk-37PILMIT.js.map} +0 -0
  97. /package/dist/{chunk-NR7QQ6ZI.js.map → chunk-6M6LZEP6.js.map} +0 -0
  98. /package/dist/{chunk-NXHL5YII.js.map → chunk-7LKAMLV4.js.map} +0 -0
  99. /package/dist/{chunk-FFUDEIFF.js.map → chunk-HN5HT5WL.js.map} +0 -0
  100. /package/dist/{chunk-QFUIE37H.js.map → chunk-KFXXOFDC.js.map} +0 -0
  101. /package/dist/{chunk-O2A6XHGD.js.map → chunk-LLDJS7PJ.js.map} +0 -0
  102. /package/dist/{chunk-INO47JXD.js.map → chunk-PBENHIN2.js.map} +0 -0
  103. /package/dist/{chunk-CLWBNXKF.js.map → chunk-PLUJEQLU.js.map} +0 -0
  104. /package/dist/{chunk-FNHNSFIJ.js.map → chunk-WZOPWQN2.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,7 +2,62 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.24.0] — 2026-06-06
6
+
7
+ ### Added
8
+
9
+ - **Integration: opt-in post-upsert change-event emission seam.** An entity that
10
+ declares `integration.sink.emit_changes: true` now gets typed per-entity domain
11
+ events published automatically when integration sync upserts/soft-deletes rows.
12
+ Codegen (a) desugars the entity into `<entity>_created` / `<entity>_edited` /
13
+ `<entity>_deleted` events (merged into the generated events registry exactly
14
+ like a hand-authored `events/*.yaml` — TypedEventBus augmentation, the
15
+ `EventTypeName` union, payload schemas), and (b) emits a fully-`@generated`
16
+ `<entity>.change-emitter.ts` that the per-entity assembly module binds to the
17
+ new optional `INTEGRATION_CHANGE_EMITTER` token. `ExecuteIntegrationUseCase`
18
+ injects the emitter `@Optional()` and publishes after every real sink
19
+ write/soft-delete (never on a `noop` diff or a delete that hit no row). Payload:
20
+ `{ entityId, externalId, provider, changedFields?, source: 'integration' }` —
21
+ the `source` provenance marker lets a write-back action detect
22
+ integration-originated changes and break the inbound→writeback loop. The verb
23
+ is `_edited`, NOT `_updated` (swe-brain ADR-0009 B1). Entities that don't opt in
24
+ are byte-for-byte unchanged (the emitter token stays unbound). Generalizes the
25
+ emission swe-brain hand-built in its sinks. See `docs/specs/EMIT-CHANGES-1.md`.
26
+
27
+ ## [0.23.0] — 2026-06-06
28
+
29
+ ### Fixed
30
+
31
+ - **Jobs: claim heartbeat (CLAIM-HB-1) — long-running handlers are no longer
32
+ swept mid-flight.** The drizzle `JobWorker` stamped `claimed_at` once at claim
33
+ and never renewed it, while `sweepStaleClaims` reset any `running` row whose
34
+ `claimed_at` aged past `staleThresholdMs` (default 5 min) back to `pending`.
35
+ Consequence: ANY handler that legitimately ran longer than the threshold was
36
+ silently re-queued and re-claimed by a second worker, running CONCURRENTLY
37
+ with the still-live (uncancellable) original. Discovered by the swe-brain
38
+ dogfood: a 365-day Gmail backfill could never finish inside 5 min, so it
39
+ re-spawned a fresh concurrent mailbox walk every ~6 min for 5 days (writes
40
+ were idempotent upserts, so no corruption — but a non-idempotent handler would
41
+ have corrupted). Fix: a live worker now tracks its in-flight run IDs and bumps
42
+ `claimed_at = now()` for them every `claimHeartbeatIntervalMs` (new
43
+ `JobWorkerOptions` knob, default `staleThresholdMs / 3`). The sweeper now
44
+ fires only for genuinely dead workers (renewal stopped) — its documented
45
+ "stranded by a crashed worker" intent.
46
+
47
+ ### Added
48
+
49
+ - **Jobs: consumer-threadable lease tuning.** `jobs.extensions.drizzle` now
50
+ accepts `stale_threshold_ms`, `stale_sweeper_interval_ms`, and
51
+ `claim_heartbeat_interval_ms`, threaded through the subsystem barrel generator
52
+ into both `JobsDomainModule.forRoot` and `JobWorkerModule.forRoot` (camelCase
53
+ runtime keys). All optional; the worker defaults the heartbeat to a third of
54
+ the stale threshold.
55
+
56
+ > **Deferred (CLAIM-HB-1 follow-up):** fencing — a claim token on `job_run` so a
57
+ > swept-and-reclaimed run cannot be double-completed by a zombie attempt that
58
+ > finishes after the sweep. Needs a schema/migration change + write-site guards;
59
+ > tracked as issue #501. The heartbeat closes the practical
60
+ > re-claim-loop bug; fencing hardens the residual crash-recovery race.
6
61
 
7
62
  ## [0.21.0] — 2026-06-06
8
63
 
@@ -114,9 +114,17 @@ export class AppModule {}
114
114
  those records. This is the most common "wait, what?" moment; document it in
115
115
  your runbooks. Retry semantics are caller-owned.
116
116
 
117
- 7. **The orchestrator does not emit events, schedule itself, retry, or resolve
118
- subscriptions.** Those are all consumer concerns. Wire event emission inside
119
- your sink's transaction; wire scheduling via a job or webhook handler.
117
+ 7. **Event emission is an opt-in seam; scheduling/retry/subscription-resolution
118
+ stay consumer concerns.** Declare `integration.sink.emit_changes: true` on an
119
+ entity and codegen generates `<entity>_created` / `<entity>_edited` /
120
+ `<entity>_deleted` typed events plus a `<entity>.change-emitter.ts` the
121
+ assembly binds to `INTEGRATION_CHANGE_EMITTER`; the orchestrator then publishes
122
+ after every real sink write/soft-delete (payload carries
123
+ `source: 'integration'` for loop-breaking). Omit the flag (the default) and the
124
+ orchestrator emits nothing — hand-roll emission in your sink if you need a
125
+ bespoke payload, or override the generated event via a top-level
126
+ `events/<entity>_created.yaml`. Scheduling is still a job/webhook concern;
127
+ retry semantics are still caller-owned. See `docs/specs/EMIT-CHANGES-1.md`.
120
128
 
121
129
  ## Do not
122
130
 
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  BridgeMetricsReporter
3
3
  } from "./chunk-AQFQ4BYM.js";
4
- import {
5
- ObservabilityService
6
- } from "./chunk-CLWBNXKF.js";
7
4
  import {
8
5
  OBSERVABILITY,
9
6
  OBSERVABILITY_MODULE_OPTIONS
10
7
  } from "./chunk-Y7RRSEOC.js";
8
+ import {
9
+ ObservabilityService
10
+ } from "./chunk-PLUJEQLU.js";
11
11
  import {
12
12
  __decorateClass
13
13
  } from "./chunk-2E224ZSN.js";
@@ -45,4 +45,4 @@ ObservabilityModule = __decorateClass([
45
45
  export {
46
46
  ObservabilityModule
47
47
  };
48
- //# sourceMappingURL=chunk-XKWOJZZ4.js.map
48
+ //# sourceMappingURL=chunk-37PILMIT.js.map
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  TypedEventBus
3
- } from "./chunk-INO47JXD.js";
3
+ } from "./chunk-PBENHIN2.js";
4
4
  import {
5
5
  EventScheduler,
6
6
  scheduledEventsFromRegistry
7
7
  } from "./chunk-DUUCU77W.js";
8
8
  import {
9
9
  DrizzleEventBus
10
- } from "./chunk-DB5UXJC3.js";
10
+ } from "./chunk-PNCOUFFI.js";
11
11
  import {
12
12
  MemoryEventBus
13
13
  } from "./chunk-GOO5ZMYO.js";
@@ -200,4 +200,4 @@ export {
200
200
  EventSchedulerLifecycle,
201
201
  EventsModule
202
202
  };
203
- //# sourceMappingURL=chunk-NR7QQ6ZI.js.map
203
+ //# sourceMappingURL=chunk-6M6LZEP6.js.map
@@ -32,6 +32,7 @@ var DEFAULT_POLL_INTERVAL_MS = 1e3;
32
32
  var DEFAULT_STALE_SWEEPER_INTERVAL_MS = 6e4;
33
33
  var DEFAULT_STALE_THRESHOLD_MS = 5 * 6e4;
34
34
  var DEFAULT_SHUTDOWN_TIMEOUT_MS = 3e4;
35
+ var CLAIM_HEARTBEAT_DIVISOR = 3;
35
36
  var TERMINAL_STATUSES = [
36
37
  "completed",
37
38
  "failed",
@@ -79,6 +80,9 @@ function buildStaleSweepQuery(db, staleThresholdMs) {
79
80
  )
80
81
  ).for("update", { skipLocked: true });
81
82
  }
83
+ function buildClaimRenewQuery(db, runIds, now = /* @__PURE__ */ new Date()) {
84
+ return db.update(jobRuns).set({ claimedAt: now, updatedAt: now }).where(and(inArray(jobRuns.id, runIds), eq(jobRuns.status, "running")));
85
+ }
82
86
  function serialiseError(err, attempt, retryable) {
83
87
  const e = err;
84
88
  return {
@@ -99,6 +103,7 @@ var JobWorker = class {
99
103
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
100
104
  this.staleSweeperIntervalMs = options.staleSweeperIntervalMs ?? DEFAULT_STALE_SWEEPER_INTERVAL_MS;
101
105
  this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
106
+ this.claimHeartbeatIntervalMs = options.claimHeartbeatIntervalMs ?? Math.max(1, Math.floor(this.staleThresholdMs / CLAIM_HEARTBEAT_DIVISOR));
102
107
  this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
103
108
  this.listenNotifyEnabled = options.listenNotify ?? false;
104
109
  this.sigtermHandler = () => {
@@ -117,13 +122,24 @@ var JobWorker = class {
117
122
  logger = new Logger(JobWorker.name);
118
123
  shuttingDown = false;
119
124
  inFlight = /* @__PURE__ */ new Set();
125
+ /**
126
+ * CLAIM-HB-1 — the set of run IDs this worker currently has executing. The
127
+ * heartbeat renews `claimed_at` for exactly these; a run is added when its
128
+ * `processRun` is dispatched and removed when its execution settles (success,
129
+ * failure, retry-release, or concurrency-defer). Kept separate from
130
+ * `inFlight` (which tracks the wrapper Promises for drain) because the
131
+ * heartbeat needs the IDs, not the promises.
132
+ */
133
+ inFlightRunIds = /* @__PURE__ */ new Set();
120
134
  pollTimer = null;
121
135
  sweeperTimer = null;
136
+ heartbeatTimer = null;
122
137
  sigtermHandled = false;
123
138
  sigtermHandler;
124
139
  pollIntervalMs;
125
140
  staleSweeperIntervalMs;
126
141
  staleThresholdMs;
142
+ claimHeartbeatIntervalMs;
127
143
  shutdownTimeoutMs;
128
144
  // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when
129
145
  // `listenNotify` is off (the common case); polling is the only driver then.
@@ -143,6 +159,9 @@ var JobWorker = class {
143
159
  this.sweeperTimer = setInterval(() => {
144
160
  void this.sweepStaleClaims();
145
161
  }, this.staleSweeperIntervalMs);
162
+ this.heartbeatTimer = setInterval(() => {
163
+ void this.renewClaims();
164
+ }, this.claimHeartbeatIntervalMs);
146
165
  process.on("SIGTERM", this.sigtermHandler);
147
166
  if (this.listenNotifyEnabled) {
148
167
  const pool = this.db.$client;
@@ -213,6 +232,10 @@ var JobWorker = class {
213
232
  clearInterval(this.sweeperTimer);
214
233
  this.sweeperTimer = null;
215
234
  }
235
+ if (this.heartbeatTimer) {
236
+ clearInterval(this.heartbeatTimer);
237
+ this.heartbeatTimer = null;
238
+ }
216
239
  process.removeListener("SIGTERM", this.sigtermHandler);
217
240
  await this.drainInFlight();
218
241
  try {
@@ -265,10 +288,13 @@ var JobWorker = class {
265
288
  }
266
289
  if (!claimed) return;
267
290
  const run = claimed;
291
+ this.inFlightRunIds.add(run.id);
268
292
  const promise = this.processRun(run).catch((err) => {
269
293
  this.logger.error(
270
294
  `processRun(${run.id}) unhandled: ${err.message}`
271
295
  );
296
+ }).finally(() => {
297
+ this.inFlightRunIds.delete(run.id);
272
298
  });
273
299
  this.inFlight.add(promise);
274
300
  promise.finally(() => {
@@ -328,6 +354,32 @@ var JobWorker = class {
328
354
  }
329
355
  }
330
356
  // ============================================================================
357
+ // Claim heartbeat (CLAIM-HB-1)
358
+ // ============================================================================
359
+ /**
360
+ * Renew the claim lease on every run this worker currently has in flight by
361
+ * bumping `claimed_at = now()` in a single UPDATE. This is what keeps
362
+ * `sweepStaleClaims` from re-queueing a legitimately long-running handler:
363
+ * the sweeper only resets rows whose `claimed_at` has aged past the threshold,
364
+ * and a live worker keeps renewing. When the worker process dies, renewal
365
+ * stops, the row ages out, and the sweeper correctly recovers it — its
366
+ * documented "stranded by a crashed worker" intent.
367
+ *
368
+ * No-ops (no query) when nothing is in flight. The `status = 'running'` guard
369
+ * inside the UPDATE means a run that was swept-and-reclaimed elsewhere (or has
370
+ * already settled) is not touched.
371
+ */
372
+ async renewClaims() {
373
+ if (this.shuttingDown) return;
374
+ const ids = [...this.inFlightRunIds];
375
+ if (ids.length === 0) return;
376
+ try {
377
+ await buildClaimRenewQuery(this.db, ids, /* @__PURE__ */ new Date());
378
+ } catch (err) {
379
+ this.logger.error(`renewClaims failed: ${err.message}`);
380
+ }
381
+ }
382
+ // ============================================================================
331
383
  // processRun
332
384
  // ============================================================================
333
385
  async processRun(claimed) {
@@ -526,6 +578,7 @@ export {
526
578
  classifyError,
527
579
  buildClaimQuery,
528
580
  buildStaleSweepQuery,
581
+ buildClaimRenewQuery,
529
582
  JobWorker
530
583
  };
531
- //# sourceMappingURL=chunk-VDL5CJ5C.js.map
584
+ //# sourceMappingURL=chunk-7B7MMDOJ.js.map
@@ -0,0 +1 @@
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 three\n * intervals: the poll loop (claim → process → repeat), the claim heartbeat\n * (CLAIM-HB-1 — renews `claimed_at` for in-flight runs so a long handler isn't\n * swept), and the stale-claim sweeper. On `onModuleDestroy` / SIGTERM it drains\n * in-flight work and releases still-`running` rows back to `pending` so a\n * replacement worker 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 whose `claimed_at` has NOT been\n * renewed is presumed stranded by a crashed worker, and the sweeper resets\n * it to `pending`. Default 5 min.\n *\n * With the claim heartbeat (CLAIM-HB-1) in place this is a *liveness*\n * threshold — a live worker bumps `claimed_at` every\n * `claimHeartbeatIntervalMs`, so a long-running-but-alive handler is NEVER\n * swept; only a row whose worker died (process crash/SIGKILL, no clean\n * shutdown reset) ages past the threshold. It therefore no longer needs to\n * be `>= 2× max handler duration` — it just needs to exceed a few missed\n * heartbeats (default leaves a 3× heartbeat margin).\n */\n staleThresholdMs?: number;\n /**\n * CLAIM-HB-1 — interval at which this worker bumps `claimed_at = now()` for\n * every run it currently holds in flight (one batched UPDATE). This is the\n * lease renewal that keeps a legitimately long-running handler from being\n * swept by `sweepStaleClaims`. Default `staleThresholdMs / 3` so a row\n * survives up to two missed renewals before the sweeper acts. MUST be\n * comfortably less than `staleThresholdMs` or live runs will be re-queued\n * mid-flight.\n */\n claimHeartbeatIntervalMs?: 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/**\n * CLAIM-HB-1 — the heartbeat fires at `staleThresholdMs / DIVISOR`, leaving\n * `DIVISOR - 1` missed-renewal margin before the sweeper presumes the worker\n * dead. 3 gives two missed beats of slack while keeping the renewal cheap.\n */\nconst CLAIM_HEARTBEAT_DIVISOR = 3;\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/**\n * CLAIM-HB-1 — build the heartbeat renewal UPDATE. Bumps `claimed_at = now()`\n * (and `updated_at`) for the given run IDs, but ONLY rows still `status =\n * 'running'`: a row this worker thinks it owns may have been swept and\n * reclaimed by another worker (now running elsewhere), or already moved to a\n * terminal state — the status guard makes the renewal a safe no-op in both\n * cases rather than resurrecting a lease the worker no longer holds. Exported so\n * tests can inspect `.toSQL()` without a live DB.\n */\nexport function buildClaimRenewQuery(\n db: DrizzleClient,\n runIds: string[],\n now: Date = new Date(),\n) {\n return db\n .update(jobRuns)\n .set({ claimedAt: now, updatedAt: now })\n .where(and(inArray(jobRuns.id, runIds), eq(jobRuns.status, 'running')));\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 /**\n * CLAIM-HB-1 — the set of run IDs this worker currently has executing. The\n * heartbeat renews `claimed_at` for exactly these; a run is added when its\n * `processRun` is dispatched and removed when its execution settles (success,\n * failure, retry-release, or concurrency-defer). Kept separate from\n * `inFlight` (which tracks the wrapper Promises for drain) because the\n * heartbeat needs the IDs, not the promises.\n */\n private readonly inFlightRunIds = new Set<string>();\n private pollTimer: ReturnType<typeof setInterval> | null = null;\n private sweeperTimer: ReturnType<typeof setInterval> | null = null;\n private heartbeatTimer: 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 claimHeartbeatIntervalMs: 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 // CLAIM-HB-1 — default to a third of the stale threshold so a row tolerates\n // two missed renewals before the sweeper acts. A consumer-supplied value is\n // honored verbatim (it's their call if they want it tighter/looser).\n this.claimHeartbeatIntervalMs =\n options.claimHeartbeatIntervalMs ??\n Math.max(1, Math.floor(this.staleThresholdMs / CLAIM_HEARTBEAT_DIVISOR));\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 // CLAIM-HB-1 — renew the claim lease on in-flight runs so a legitimately\n // long-running handler is not swept mid-flight. No-ops cheaply (no UPDATE)\n // when nothing is in flight.\n this.heartbeatTimer = setInterval(() => {\n void this.renewClaims();\n }, this.claimHeartbeatIntervalMs);\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 // LISTEN-NOTIFY-2 — release the listener connection on EVERY destroy path,\n // including the `shuttingDown` early-return below (reached when SIGTERM and\n // Nest's onModuleDestroy both fire) and a destroy with no prior SIGTERM.\n // Previously this lived only on the first (non-shuttingDown) branch, so a\n // double-fire — or a stop() that raced the listener's own in-flight\n // connect() — left a `LISTEN codegen_jobs_wake` socket open past\n // `app.close()`, hanging the process. `PgNotifyListener.stop()` is itself\n // idempotent + connect-race-safe (LISTEN-NOTIFY-2). Best-effort; a failure\n // here doesn't block the drain.\n await this.stopNotifyListener();\n\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 if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n process.removeListener('SIGTERM', this.sigtermHandler);\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 * LISTEN-NOTIFY-2 — stop + drop the wake listener. Idempotent: a second call\n * (SIGTERM + Nest destroy) finds `notifyListener` already null and no-ops.\n * `PgNotifyListener.stop()` is itself race-safe against an in-flight\n * `connect()`, so even a destroy that arrives microseconds after `start()`\n * releases the listener socket rather than leaking it.\n */\n private async stopNotifyListener(): Promise<void> {\n const listener = this.notifyListener;\n if (!listener) return;\n this.notifyListener = null;\n try {\n await listener.stop();\n } catch (err) {\n this.logger.error(`notify listener stop failed: ${(err as Error).message}`);\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 // CLAIM-HB-1 — register the run as in-flight so the heartbeat renews its\n // lease, and deregister the moment its execution settles (success, failure,\n // retry-release, concurrency-defer — every path out of processRun). Held in\n // a `finally` so an unhandled throw can't strand the id in the renew set and\n // keep bumping `claimed_at` for a run this worker no longer owns.\n this.inFlightRunIds.add(run.id);\n const promise = this.processRun(run)\n .catch((err) => {\n this.logger.error(\n `processRun(${run.id}) unhandled: ${(err as Error).message}`,\n );\n })\n .finally(() => {\n this.inFlightRunIds.delete(run.id);\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 // Claim heartbeat (CLAIM-HB-1)\n // ============================================================================\n\n /**\n * Renew the claim lease on every run this worker currently has in flight by\n * bumping `claimed_at = now()` in a single UPDATE. This is what keeps\n * `sweepStaleClaims` from re-queueing a legitimately long-running handler:\n * the sweeper only resets rows whose `claimed_at` has aged past the threshold,\n * and a live worker keeps renewing. When the worker process dies, renewal\n * stops, the row ages out, and the sweeper correctly recovers it — its\n * documented \"stranded by a crashed worker\" intent.\n *\n * No-ops (no query) when nothing is in flight. The `status = 'running'` guard\n * inside the UPDATE means a run that was swept-and-reclaimed elsewhere (or has\n * already settled) is not touched.\n */\n async renewClaims(): Promise<void> {\n if (this.shuttingDown) return;\n const ids = [...this.inFlightRunIds];\n if (ids.length === 0) return;\n try {\n await buildClaimRenewQuery(this.db, ids, new Date());\n } catch (err) {\n // Best-effort: a transient failure just means this beat was missed. The\n // staleThreshold leaves several beats of slack before the sweeper acts,\n // and the next beat retries.\n this.logger.error(`renewClaims 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,15 +1,15 @@
1
1
  import {
2
2
  EnvEncryptionKey
3
3
  } from "./chunk-IP4OO26U.js";
4
+ import {
5
+ DrizzleOAuthStateStore
6
+ } from "./chunk-N5OTOWTP.js";
4
7
  import {
5
8
  MemoryOAuthStateStore
6
9
  } from "./chunk-QLTJSCE6.js";
7
10
  import {
8
11
  AuthController
9
12
  } from "./chunk-SZVPIHWE.js";
10
- import {
11
- DrizzleOAuthStateStore
12
- } from "./chunk-N5OTOWTP.js";
13
13
  import {
14
14
  AUTH_OPTIONS,
15
15
  ENCRYPTION_KEY,
@@ -89,4 +89,4 @@ AuthModule = __decorateClass([
89
89
  export {
90
90
  AuthModule
91
91
  };
92
- //# sourceMappingURL=chunk-NXHL5YII.js.map
92
+ //# sourceMappingURL=chunk-7LKAMLV4.js.map
@@ -1,6 +1,3 @@
1
- import {
2
- MemoryJobOrchestrator
3
- } from "./chunk-VQOAATIG.js";
4
1
  import {
5
2
  DrizzleJobRunService
6
3
  } from "./chunk-3VEVGL74.js";
@@ -10,12 +7,6 @@ import {
10
7
  import {
11
8
  DrizzleJobStepService
12
9
  } from "./chunk-DV4RV2DC.js";
13
- import {
14
- MemoryJobStepService
15
- } from "./chunk-PNZSGAB2.js";
16
- import {
17
- MemoryJobStore
18
- } from "./chunk-SNQ3TOWP.js";
19
10
  import {
20
11
  BULLMQ_CONNECTION,
21
12
  BULLMQ_RESOLVED_CONFIG,
@@ -24,6 +15,15 @@ import {
24
15
  import {
25
16
  DrizzleJobOrchestrator
26
17
  } from "./chunk-E6PLM6QG.js";
18
+ import {
19
+ MemoryJobOrchestrator
20
+ } from "./chunk-VQOAATIG.js";
21
+ import {
22
+ MemoryJobStepService
23
+ } from "./chunk-PNZSGAB2.js";
24
+ import {
25
+ MemoryJobStore
26
+ } from "./chunk-SNQ3TOWP.js";
27
27
  import {
28
28
  JOBS_LISTEN_NOTIFY,
29
29
  JOBS_MULTI_TENANT,
@@ -114,4 +114,4 @@ JobsDomainModule = __decorateClass([
114
114
  export {
115
115
  JobsDomainModule
116
116
  };
117
- //# sourceMappingURL=chunk-6DQEIXYU.js.map
117
+ //# sourceMappingURL=chunk-CKLM57IE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../runtime/subsystems/jobs/jobs-domain.module.ts"],"sourcesContent":["/**\n * JobsDomainModule — `DynamicModule.forRoot({ backend })` factory wiring\n * the three jobs-domain protocol tokens to a backend implementation\n * (ADR-022, JOB-5).\n *\n * Mirrors `EventsModule.forRoot()` exactly:\n * - `global: true` so consumer entity modules don't have to import this\n * individually — `JOB_ORCHESTRATOR` / `JOB_RUN_SERVICE` /\n * `JOB_STEP_SERVICE` are available project-wide.\n * - One backend at a time (Drizzle for production, Memory for tests).\n *\n * Backend swappability follows the core/extension protocol from CLAUDE.md:\n * the three tokens are the **core contract**; backend-specific tunables\n * live under `extensions.<backend>` so opting into a feature is explicit\n * and the type system reserves the slot.\n */\nimport { Module, type DynamicModule, type Provider } from '@nestjs/common';\nimport { DRIZZLE } from '../../constants/tokens';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n} from './jobs-domain.tokens';\nimport { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';\nimport { DrizzleJobRunService } from './job-run-service.drizzle-backend';\nimport { DrizzleJobStepService } from './job-step-service.drizzle-backend';\n// #6 — `BullMQJobOrchestrator` is lazy-loaded only when `backend: 'bullmq'`\n// is selected. The backend file is filtered out of drizzle/memory installs\n// (see `backendFileFilter`); a non-literal dynamic import below sidesteps\n// consumer-side tsc resolution of an absent file.\nimport { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';\nimport { MemoryJobRunService } from './job-run-service.memory-backend';\nimport { MemoryJobStepService } from './job-step-service.memory-backend';\nimport { MemoryJobStore } from './memory-job-store';\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolveBullMqConfig,\n type BullMqExtensionsConfig,\n} from './bullmq.config';\n\n/**\n * Drizzle backend extensions surface (LISTEN-NOTIFY-1 wires both fields).\n *\n * - `listenNotify` → provided as `JOBS_LISTEN_NOTIFY` so the orchestrator emits\n * in-tx `pg_notify` on enqueue, and threaded into each spawned `JobWorker`\n * (which holds the listener connection). Off by default.\n * - `pollIntervalMs` → threaded into the spawned `JobWorker`'s\n * `JobWorkerOptions.pollIntervalMs` (the worker already honored this; it just\n * never received a config value). Default 1000.\n *\n * Both run ALONGSIDE interval polling — `listenNotify` only adds an early wake;\n * polling remains the durability heartbeat.\n */\nexport interface DrizzleBackendExtensions {\n /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */\n listenNotify?: boolean;\n /** Polling interval (ms). Default 1000. */\n pollIntervalMs?: number;\n /**\n * CLAIM-HB-1 — stale-claim sweep interval (ms). How often each worker scans\n * for `running` rows whose lease has expired. Default 60_000.\n */\n staleSweeperIntervalMs?: number;\n /**\n * CLAIM-HB-1 — stale-claim threshold (ms). A `running` row whose `claimed_at`\n * has not been renewed within this window is presumed stranded by a dead\n * worker and reset to `pending`. A LIVE worker renews the lease every\n * `claimHeartbeatIntervalMs`, so this only catches genuine crashes. Default\n * 300_000 (5 min).\n */\n staleThresholdMs?: number;\n /**\n * CLAIM-HB-1 — claim heartbeat interval (ms). How often a worker bumps\n * `claimed_at` for its in-flight runs to keep them from being swept. Must be\n * comfortably below `staleThresholdMs`. Default `staleThresholdMs / 3`.\n */\n claimHeartbeatIntervalMs?: number;\n}\n\nexport interface JobsDomainModuleOptions {\n backend: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Backend-specific extensions. Only the matching backend's extensions\n * are read at boot; non-matching keys are ignored. This is the\n * core/extension protocol surface — see CLAUDE.md.\n */\n extensions?: {\n drizzle?: DrizzleBackendExtensions;\n /**\n * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML\n * under `jobs.extensions.bullmq`. `redis_url` falls back to\n * `process.env.REDIS_URL` then `redis://localhost:6379`.\n */\n bullmq?: BullMqExtensionsConfig;\n };\n /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */\n multiTenant?: boolean;\n}\n\n@Module({})\nexport class JobsDomainModule {\n static forRoot(opts: JobsDomainModuleOptions): DynamicModule {\n const multiTenant = opts.multiTenant ?? false;\n // LISTEN-NOTIFY-1 — drizzle-only extension. `listen_notify` is meaningless\n // for memory (no DB) and redundant for bullmq (native wakeups); only the\n // drizzle backend's orchestrator reads it.\n const listenNotify =\n opts.backend === 'drizzle' && Boolean(opts.extensions?.drizzle?.listenNotify);\n\n const providers: Provider[] = [\n // JOB-8 — boolean provider consumed by the four service-layer backends.\n // Always provided (even when `multiTenant === false`) so `@Inject`\n // always resolves; backends short-circuit the enforcement path when\n // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop\n // cross-tenant-by-design decision.\n { provide: JOBS_MULTI_TENANT, useValue: multiTenant },\n // LISTEN-NOTIFY-1 — always provided so the orchestrator's `@Inject`\n // resolves; the orchestrator skips the `pg_notify` emit when `false`.\n { provide: JOBS_LISTEN_NOTIFY, useValue: listenNotify },\n ];\n\n if (opts.backend === 'memory') {\n // The store is a plain class — wired as a singleton `useValue` so\n // unit tests can pull it out via `.get(MemoryJobStore)` for direct\n // assertions (matches the access pattern in JOB-4 specs).\n const store = new MemoryJobStore();\n providers.push({ provide: MemoryJobStore, useValue: store });\n providers.push(MemoryJobStepService);\n providers.push({ provide: JOB_STEP_SERVICE, useExisting: MemoryJobStepService });\n providers.push(MemoryJobOrchestrator);\n providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });\n providers.push(MemoryJobRunService);\n providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });\n } else if (opts.backend === 'bullmq') {\n // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The\n // run/step services stay Drizzle (domain reads + `listForScope` are\n // Postgres queries, unchanged per spec). Only the orchestrator's\n // claim/dispatch half swaps to BullMQ.\n //\n // #6 — the bullmq backend module is filtered out of drizzle/memory\n // installs (no `bullmq` peer dep, no consumer-side tsc compile of an\n // unused file). The factory below dynamic-imports it via a non-literal\n // specifier so TS treats the module type as `any` and never tries to\n // resolve the absent file on a drizzle/memory consumer.\n const resolved = resolveBullMqConfig(opts.extensions?.bullmq);\n providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });\n providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });\n providers.push({\n provide: JOB_ORCHESTRATOR,\n useFactory: async (...args: unknown[]): Promise<object> => {\n const specifier = './job-orchestrator.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobOrchestrator: new (...args: unknown[]) => object;\n };\n return new mod.BullMQJobOrchestrator(...args);\n },\n // The bullmq orchestrator constructor mirrors DrizzleJobOrchestrator's\n // injection list: DRIZZLE + JOBS_MULTI_TENANT + the resolved BullMQ\n // tokens. Importing token references would force a static dep on the\n // tokens file in this module's import graph; using the existing\n // symbols already in scope is sufficient.\n inject: [DRIZZLE, JOBS_MULTI_TENANT, BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG],\n });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n } else {\n providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n }\n\n const exports = [\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n JOBS_LISTEN_NOTIFY,\n ];\n // BULLMQ-1 — only export the BullMQ tokens when they were actually\n // provided. Nest throws \"exported but not provided\" otherwise. Exported so\n // JobWorkerModule (which imports this module) can read the resolved\n // connection/config to spawn BullMQ workers.\n if (opts.backend === 'bullmq') {\n exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);\n }\n\n return {\n module: JobsDomainModule,\n global: true,\n providers,\n exports,\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA,SAAS,cAAiD;AAuFnD,IAAM,mBAAN,MAAuB;AAAA,EAC5B,OAAO,QAAQ,MAA8C;AAC3D,UAAM,cAAc,KAAK,eAAe;AAIxC,UAAM,eACJ,KAAK,YAAY,aAAa,QAAQ,KAAK,YAAY,SAAS,YAAY;AAE9E,UAAM,YAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAM5B,EAAE,SAAS,mBAAmB,UAAU,YAAY;AAAA;AAAA;AAAA,MAGpD,EAAE,SAAS,oBAAoB,UAAU,aAAa;AAAA,IACxD;AAEA,QAAI,KAAK,YAAY,UAAU;AAI7B,YAAM,QAAQ,IAAI,eAAe;AACjC,gBAAU,KAAK,EAAE,SAAS,gBAAgB,UAAU,MAAM,CAAC;AAC3D,gBAAU,KAAK,oBAAoB;AACnC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,qBAAqB,CAAC;AAC/E,gBAAU,KAAK,qBAAqB;AACpC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,sBAAsB,CAAC;AAChF,gBAAU,KAAK,mBAAmB;AAClC,gBAAU,KAAK,EAAE,SAAS,iBAAiB,aAAa,oBAAoB,CAAC;AAAA,IAC/E,WAAW,KAAK,YAAY,UAAU;AAWpC,YAAM,WAAW,oBAAoB,KAAK,YAAY,MAAM;AAC5D,gBAAU,KAAK,EAAE,SAAS,mBAAmB,UAAU,SAAS,WAAW,CAAC;AAC5E,gBAAU,KAAK,EAAE,SAAS,wBAAwB,UAAU,SAAS,CAAC;AACtE,gBAAU,KAAK;AAAA,QACb,SAAS;AAAA,QACT,YAAY,UAAU,SAAqC;AACzD,gBAAM,YAAY;AAClB,gBAAM,MAAO,MAAM,OAAO;AAG1B,iBAAO,IAAI,IAAI,sBAAsB,GAAG,IAAI;AAAA,QAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMA,QAAQ,CAAC,SAAS,mBAAmB,mBAAmB,sBAAsB;AAAA,MAChF,CAAC;AACD,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E,OAAO;AACL,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,uBAAuB,CAAC;AAC9E,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E;AAEA,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAKA,QAAI,KAAK,YAAY,UAAU;AAC7B,cAAQ,KAAK,mBAAmB,sBAAsB;AAAA,IACxD;AAEA,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AA7Fa,mBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  JobWorker
3
- } from "./chunk-VDL5CJ5C.js";
3
+ } from "./chunk-7B7MMDOJ.js";
4
4
  import {
5
5
  JobsDomainModule
6
- } from "./chunk-6DQEIXYU.js";
6
+ } from "./chunk-CKLM57IE.js";
7
7
  import {
8
8
  BULLMQ_CONNECTION,
9
9
  BULLMQ_RESOLVED_CONFIG,
@@ -98,7 +98,12 @@ var JobWorkerOrchestrator = class {
98
98
  concurrency: def.concurrency,
99
99
  shutdownTimeoutMs: this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
100
100
  pollIntervalMs: drizzleExt?.pollIntervalMs,
101
- listenNotify: drizzleExt?.listenNotify
101
+ listenNotify: drizzleExt?.listenNotify,
102
+ // CLAIM-HB-1 — lease tuning knobs. All optional; the worker defaults
103
+ // claimHeartbeatIntervalMs to staleThresholdMs/3 when omitted.
104
+ staleSweeperIntervalMs: drizzleExt?.staleSweeperIntervalMs,
105
+ staleThresholdMs: drizzleExt?.staleThresholdMs,
106
+ claimHeartbeatIntervalMs: drizzleExt?.claimHeartbeatIntervalMs
102
107
  };
103
108
  const worker = this.options.workerFactory ? this.options.workerFactory(workerOptions) : backend === "bullmq" ? await this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig) : this.spawnWorker(workerOptions);
104
109
  await worker.onModuleInit();
@@ -290,4 +295,4 @@ export {
290
295
  JobWorkerOrchestrator,
291
296
  JobWorkerModule
292
297
  };
293
- //# sourceMappingURL=chunk-QXVCRA23.js.map
298
+ //# sourceMappingURL=chunk-ENAR3F5S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../runtime/subsystems/jobs/job-worker.module.ts"],"sourcesContent":["/**\n * JobWorkerModule — `DynamicModule.forRoot({ mode, pools? })` factory that\n * boots one `JobWorker` per active pool and runs the boot-time validator\n * (Drizzle only) (ADR-022, JOB-5).\n *\n * Imports `JobsDomainModule` internally so call sites only need to add\n * `JobWorkerModule.forRoot(...)` to `AppModule.imports` — the protocol\n * tokens become available transitively via `global: true`.\n *\n * Lifecycle (`onModuleInit`, **order-critical** per JOB-5 spec):\n * 1. `loadPoolConfig()` → resolved `PoolConfig`\n * 2. `HandlerRegistry.getAll()` → registered entries\n * 3. Reserved-pool validation → throws `ReservedPoolViolationError`\n * 4. `orchestrator.upsertJobRows(entries, …)` → persist `job` definitions\n * 5. Boot validator (Drizzle only) → throws `BootValidationError`\n * (skipped entirely in memory mode — Q4 resolution 2026-04-19)\n * 6. Spawn one `JobWorker` per active pool → start polling loops\n *\n * `onModuleDestroy` calls `gracefulStop` on each worker (drains in-flight,\n * resets `running` rows, removes SIGTERM handler).\n */\nimport {\n Inject,\n Injectable,\n Logger,\n Module,\n Optional,\n type DynamicModule,\n type OnModuleDestroy,\n type OnModuleInit,\n} from '@nestjs/common';\nimport { ModuleRef } from '@nestjs/core';\nimport { tokenKey } from '../token-key';\nimport { DRIZZLE } from '../../constants/tokens';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { HandlerRegistry, type HandlerRegistryEntry } from './job-handler.base';\nimport {\n JobsDomainModule,\n type JobsDomainModuleOptions,\n} from './jobs-domain.module';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n} from './jobs-domain.tokens';\nimport type { IJobOrchestrator } from './job-orchestrator.protocol';\nimport type { IJobRunService } from './job-run-service.protocol';\nimport type { IJobStepService } from './job-step-service.protocol';\nimport {\n allNonReservedPoolNames,\n allPoolNames,\n loadPoolConfig,\n type PoolConfig,\n} from './pool-config.loader';\nimport { JobWorker, type JobWorkerOptions } from './job-worker';\n// #6 — `BullMQJobWorker` is lazy-loaded only when `backend: 'bullmq'` is\n// selected (`spawnBullMQWorker` below). The file is filtered out of drizzle/\n// memory installs (see `backendFileFilter`). The `ConnectionOptions` type\n// previously imported from `'bullmq'` is replaced by `BullMqConnectionOptions`\n// from `./bullmq.config` (a self-contained structural mirror that does NOT\n// require the `bullmq` peer dep to type-check).\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolvePoolQueueName,\n type BullMqConnectionOptions,\n type BullMqResolvedConfig,\n} from './bullmq.config';\nimport {\n BootValidationError,\n ReservedPoolViolationError,\n} from './jobs-errors';\n\nconst DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;\n\nexport interface JobWorkerModuleOptions {\n mode: 'embedded' | 'standalone';\n /**\n * Threads into the internal `JobsDomainModule.forRoot({ backend })`\n * import. Default `'drizzle'`. The boot-time validator runs for both\n * `'drizzle'` and `'bullmq'` (both persist `job` rows to Postgres);\n * `'memory'` skips it.\n */\n backend?: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Active pool names. Defaults to every non-reserved pool in the resolved\n * config (i.e. `interactive`, `batch`, plus any user-defined pools).\n * Operators reduce this to one or two pools per worker process to scale\n * horizontally.\n */\n pools?: string[];\n /**\n * BULLMQ-1 Phase 1 — when `true`, `onModuleInit` activates **every** pool\n * in the resolved config, including the reserved `events_*` lanes. This is\n * how the standalone worker (`worker.ts`) drains bridge wrappers without\n * the consumer hand-listing `...BRIDGE_RESERVED_POOLS`. Mutually exclusive\n * with an explicit `pools` list — when both are set, `pools` wins (explicit\n * beats blanket) and `allPools` is ignored.\n *\n * `BridgeModule`'s reserved-pool guard short-circuits to \"pass\" when this\n * is `true`, since every reserved pool is provably being polled.\n */\n allPools?: boolean;\n /** SIGTERM drain budget. Default 30_000 ms. */\n shutdownTimeoutMs?: number;\n /**\n * Test-only — point the pool config loader at a specific YAML file.\n * Production code reads `${process.cwd()}/codegen.config.yaml`.\n */\n configPath?: string;\n /**\n * Forwarded into the inner `JobsDomainModule.forRoot()` call so the\n * worker module's caller can configure backend extensions in one place.\n */\n domainModuleExtensions?: JobsDomainModuleOptions['extensions'];\n /** Forwarded into `JobsDomainModule.forRoot()`. JOB-8 wires this. */\n multiTenant?: boolean;\n /**\n * Test-only escape hatch — when set, the module uses this factory\n * instead of `new JobWorker(...)` so unit tests can stub the worker\n * without spinning up the polling loop.\n */\n workerFactory?: (options: JobWorkerOptions) => Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>;\n}\n\n/**\n * DI token for the resolved `JobWorkerModuleOptions`. Exported so other\n * subsystems can inject it `@Optional()` and inspect the active\n * configuration — e.g. `BridgeModule.onModuleInit` checks\n * `options.pools` against `BRIDGE_RESERVED_POOLS` to fail fast when a\n * reserved pool isn't being polled (BRIDGE-8).\n *\n * ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value\n * across runtime copies.\n */\nexport const JOB_WORKER_MODULE_OPTIONS = Symbol.for(tokenKey('jobs', 'worker-module-options'));\n\n/**\n * The lifecycle holder. Named `JobWorkerOrchestrator` in the spec to avoid\n * collision with `JobWorker` and `IJobOrchestrator`. Registered as a\n * provider on `JobWorkerModule`; Nest invokes `onModuleInit` /\n * `onModuleDestroy` automatically.\n */\n@Injectable()\nexport class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(JobWorkerOrchestrator.name);\n private readonly workers: Array<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> = [];\n\n constructor(\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_MODULE_OPTIONS)\n private readonly options: JobWorkerModuleOptions,\n /**\n * Drizzle client is only required when `backend === 'drizzle'`. Made\n * `@Optional()` so memory-mode boots in `Test.createTestingModule`\n * without supplying a `DRIZZLE` provider.\n */\n @Optional() @Inject(DRIZZLE) private readonly db: DrizzleClient | null = null,\n /**\n * ADR-037 (package-mode DI): inject `ModuleRef` EXPLICITLY via `@Inject`\n * rather than relying on `design:paramtypes` reflection. The published\n * package bundle is built without `emitDecoratorMetadata` (tsup/esbuild\n * default), so a by-type injection here would resolve to `undefined` at\n * boot in package mode — breaking the worker entirely (the\n * `ModuleRef not available` throw). Vendored mode happened to work only\n * because the consumer's own `tsc` (emitDecoratorMetadata: true)\n * recompiled the source and emitted the metadata. The explicit token is\n * mode-agnostic. `ModuleRef` is always provided by `@nestjs/core`, so no\n * `@Optional()` is needed (it's a hard dependency of the worker path).\n */\n @Inject(ModuleRef) private readonly moduleRef?: ModuleRef,\n /**\n * BULLMQ-1 — resolved BullMQ connection + config, only bound when the\n * inner `JobsDomainModule` was booted with `backend: 'bullmq'`. `@Optional()`\n * so drizzle/memory boots see `null`.\n */\n @Optional()\n @Inject(BULLMQ_CONNECTION)\n private readonly bullConnection: BullMqConnectionOptions | null = null,\n @Optional()\n @Inject(BULLMQ_RESOLVED_CONFIG)\n private readonly bullConfig: BullMqResolvedConfig | null = null,\n ) {}\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n async onModuleInit(): Promise<void> {\n const backend = this.options.backend ?? 'drizzle';\n\n // (1) Pool config first — every later step needs the resolved map.\n const poolConfig = loadPoolConfig(this.options.configPath);\n\n // (2) Snapshot the registry. Decorators run at class-load time so the\n // map is fully populated before any module init fires.\n const entries = HandlerRegistry.getAll();\n\n // (3) Reserved-pool validation BEFORE the upsert. Persisting a\n // reserved-pool handler row would leave the DB in a bad state for\n // the next boot to clean up.\n this.assertNoReservedPoolHandlers(entries, poolConfig);\n\n // (4) Upsert `job` definitions. Drizzle: hash-gated `ON CONFLICT DO\n // UPDATE`. Memory: populates `MemoryJobStore.jobs` + handler-class\n // registry.\n const { orphaned } = await this.orchestrator.upsertJobRows(\n entries,\n poolConfig,\n );\n\n // (5) Boot validator — Drizzle only. Memory mode never has DB rows\n // to validate (Q4 resolution 2026-04-19); the equivalent\n // protection is `MemoryJobOrchestrator.start()` throwing\n // `JobTypeNotFoundError` synchronously for unknown types.\n if (backend !== 'memory' && orphaned.length > 0) {\n throw new BootValidationError(orphaned);\n }\n\n // (6) Resolve active pool list and spawn one worker per pool.\n // Precedence: explicit `pools` > `allPools` (incl. reserved) >\n // non-reserved default. BULLMQ-1 Phase 1 adds the `allPools` rung so\n // the standalone worker drains the reserved `events_*` bridge lanes.\n const activePools = this.options.pools\n ? this.options.pools\n : this.options.allPools\n ? allPoolNames(poolConfig)\n : allNonReservedPoolNames(poolConfig);\n\n for (const poolName of activePools) {\n const def = poolConfig.get(poolName);\n if (!def) {\n throw new Error(\n `JobWorkerModule: active pool '${poolName}' is not defined in ` +\n `the resolved pool config. Configured pools: [${[...poolConfig.keys()].join(', ')}].`,\n );\n }\n // `pool` here is the logical pool name (e.g. 'crm_sync') — the same\n // value the orchestrator persists into `job_run.pool` from\n // `@JobHandler.meta.pool`, and therefore the value the worker's\n // claim query filters on. `def.queue` is a display/routing alias\n // (e.g. 'jobs-crm-sync') used by BullMQ-style backends for queue\n // naming; it MUST NOT be passed as the claim-filter pool, or the\n // worker will never match any row and the pool silently never\n // drains. See v0.4.4 fix notes.\n // LISTEN-NOTIFY-1 — thread the drizzle extension knobs into each spawned\n // worker. `pollIntervalMs` was always honored by JobWorker but never\n // received a config value; `listenNotify` is the new wake opt-in. Only\n // the drizzle backend reads these (bullmq has native wakeups + its own\n // queue topology), so we ignore them under `backend: 'bullmq'`.\n const drizzleExt =\n backend === 'drizzle'\n ? this.options.domainModuleExtensions?.drizzle\n : undefined;\n const workerOptions: JobWorkerOptions = {\n pool: poolName,\n concurrency: def.concurrency,\n shutdownTimeoutMs:\n this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,\n pollIntervalMs: drizzleExt?.pollIntervalMs,\n listenNotify: drizzleExt?.listenNotify,\n // CLAIM-HB-1 — lease tuning knobs. All optional; the worker defaults\n // claimHeartbeatIntervalMs to staleThresholdMs/3 when omitted.\n staleSweeperIntervalMs: drizzleExt?.staleSweeperIntervalMs,\n staleThresholdMs: drizzleExt?.staleThresholdMs,\n claimHeartbeatIntervalMs: drizzleExt?.claimHeartbeatIntervalMs,\n };\n const worker = this.options.workerFactory\n ? this.options.workerFactory(workerOptions)\n : backend === 'bullmq'\n ? await this.spawnBullMQWorker(poolName, def.queue, def.concurrency, poolConfig)\n : this.spawnWorker(workerOptions);\n // `JobWorker` extends Nest's lifecycle hooks but the worker isn't\n // a Nest provider here (we manage the array ourselves). Call\n // `onModuleInit` to start the loop. The Drizzle/stub workers return\n // void; `BullMQJobWorker.onModuleInit` is async (it lazily loads the\n // optional `bullmq` package), so we `await` — awaiting a `void` is a\n // harmless no-op for the synchronous workers.\n await worker.onModuleInit();\n this.workers.push(worker);\n this.logger.log(\n `JobWorker started: pool='${poolName}' (queue='${def.queue}') ` +\n `concurrency=${def.concurrency} backend='${backend}'`,\n );\n }\n }\n\n async onModuleDestroy(): Promise<void> {\n // Tear down in reverse order so the most recently started worker\n // drains first — keeps the SIGTERM handler graph predictable.\n for (let i = this.workers.length - 1; i >= 0; i--) {\n const worker = this.workers[i];\n if (!worker) continue;\n try {\n await worker.onModuleDestroy();\n } catch (err) {\n this.logger.error(\n `JobWorker shutdown failed: ${(err as Error).message}`,\n );\n }\n }\n this.workers.length = 0;\n\n // BULLMQ-1 — close the orchestrator's producer-side Queue/FlowProducer\n // connections so the process can exit cleanly. The orchestrator is the\n // BullMQ producer; workers are the consumers (closed above).\n const orch = this.orchestrator as { closeConnections?: () => Promise<void> };\n if (typeof orch.closeConnections === 'function') {\n try {\n await orch.closeConnections();\n } catch (err) {\n this.logger.error(\n `BullMQ orchestrator connection close failed: ${(err as Error).message}`,\n );\n }\n }\n }\n\n // ============================================================================\n // Internals\n // ============================================================================\n\n /**\n * Walk every registered handler; collect any whose declared `pool`\n * targets a reserved pool from the resolved config. If non-empty,\n * throw `ReservedPoolViolationError` with the offender list so the\n * operator sees every violating class on a single boot.\n */\n private assertNoReservedPoolHandlers(\n entries: HandlerRegistryEntry[],\n poolConfig: PoolConfig,\n ): void {\n const offenders: Array<{ handlerClass: string; pool: string }> = [];\n for (const entry of entries) {\n // Framework-owned handlers (`@framework/*` job types) are allowed in\n // reserved pools — that is in fact the entire point of the reserved\n // `events_*` pools (ADR-022 + ADR-023). The reserved-pool guard\n // exists to keep USER handlers out, not the framework's own\n // bridge-delivery handler. BRIDGE-5 introduced this exemption.\n if (entry.type.startsWith('@framework/')) continue;\n const declaredPool = entry.meta.pool ?? 'batch';\n const def = poolConfig.get(declaredPool);\n if (def?.reserved) {\n offenders.push({\n handlerClass: entry.handlerClass.name,\n pool: declaredPool,\n });\n }\n }\n if (offenders.length > 0) {\n throw new ReservedPoolViolationError(offenders);\n }\n }\n\n /**\n * Production worker spawn. `JobWorker` requires `DRIZZLE` so this only\n * succeeds when the module was booted with `backend: 'drizzle'`. Memory\n * mode tests must supply `workerFactory` — the memory backend has no\n * polling loop equivalent (`MemoryJobOrchestrator` is direct-invocation\n * only).\n *\n * We instantiate outside the Nest container because the module spawns\n * N workers from a single options shape, which doesn't fit Nest's\n * \"one provider per token\" model. The dependencies are passed\n * positionally; the constructor's `@Inject` decorators are unused on\n * this path (Nest still uses them when `JobWorker` is a provider — e.g.\n * in JOB-6's standalone `worker.ts` entrypoint).\n */\n private spawnWorker(workerOptions: JobWorkerOptions): JobWorker {\n if (!this.db) {\n throw new Error(\n `JobWorkerModule: in-process worker spawning requires the Drizzle ` +\n `backend (no DRIZZLE provider available). Memory-mode tests must ` +\n `pass 'workerFactory' to inject a stub.`,\n );\n }\n if (!this.moduleRef) {\n throw new Error(\n `JobWorkerModule: ModuleRef not available — cannot construct JobWorker ` +\n `with handler DI support. Ensure the orchestrator is resolved through ` +\n `the Nest container (not instantiated manually in tests).`,\n );\n }\n return new JobWorker(\n this.db,\n this.orchestrator,\n this.runService,\n this.stepService,\n workerOptions,\n this.moduleRef,\n );\n }\n\n /**\n * BULLMQ-1 — spawn a per-pool `BullMQJobWorker`. Requires the Drizzle\n * client (the worker drives `job_run` as the source of truth) AND the\n * resolved BullMQ connection (bound by `JobsDomainModule` when\n * `backend: 'bullmq'`). The queue name is derived identically to the\n * orchestrator's `dispatch` via `resolvePoolQueueName(pool, …)` so producer\n * and consumer agree.\n */\n /**\n * #6 — async + dynamic-import. The `job-worker.bullmq-backend.ts` file is\n * filtered out of the vendor set for drizzle/memory installs (no `bullmq`\n * peer dep needed). The non-literal import specifier makes TS treat the\n * module as `any` so the consumer's tsc never tries to resolve an absent\n * file. This method is only entered when `backend === 'bullmq'` — at which\n * point the file IS vendored.\n */\n private async spawnBullMQWorker(\n pool: string,\n _queueAlias: string,\n concurrency: number,\n poolConfig: PoolConfig,\n ): Promise<Pick<JobWorker, 'onModuleInit' | 'onModuleDestroy'>> {\n if (!this.db) {\n throw new Error(\n `JobWorkerModule: BullMQ worker spawning requires the Drizzle client ` +\n `(no DRIZZLE provider available) — job_run remains the source of truth.`,\n );\n }\n if (!this.bullConnection) {\n throw new Error(\n `JobWorkerModule: BullMQ worker spawning requires a resolved ` +\n `BULLMQ_CONNECTION. Ensure JobsDomainModule was booted with ` +\n `backend: 'bullmq'.`,\n );\n }\n if (!this.moduleRef) {\n throw new Error(\n `JobWorkerModule: ModuleRef not available — cannot construct ` +\n `BullMQJobWorker with handler DI support.`,\n );\n }\n const queueName = resolvePoolQueueName(pool, this.bullConfig, poolConfig);\n const specifier = './job-worker.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobWorker: new (...args: unknown[]) => Pick<\n JobWorker,\n 'onModuleInit' | 'onModuleDestroy'\n >;\n };\n return new mod.BullMQJobWorker(\n this.db,\n this.orchestrator,\n this.stepService,\n {\n pool,\n queueName,\n concurrency,\n connection: this.bullConnection,\n },\n this.moduleRef,\n );\n }\n}\n\n@Module({})\nexport class JobWorkerModule {\n static forRoot(opts: JobWorkerModuleOptions): DynamicModule {\n return {\n module: JobWorkerModule,\n imports: [\n JobsDomainModule.forRoot({\n backend: opts.backend ?? 'drizzle',\n extensions: opts.domainModuleExtensions,\n multiTenant: opts.multiTenant,\n }),\n ],\n providers: [\n { provide: JOB_WORKER_MODULE_OPTIONS, useValue: opts },\n JobWorkerOrchestrator,\n ],\n // BULLMQ-1 Phase 1 — export the options token so `BridgeModule`'s\n // reserved-pool guard (`onModuleInit`) can actually inject it.\n // Previously `exports: []` left the `@Optional()` inject resolving to\n // `undefined` and the guard silently no-opped (a dead check). With the\n // token exported the guard fires for real; consumers that omit the\n // reserved pools (and don't set `allPools`) now fail fast with\n // `BridgeReservedPoolsNotPolledError` — which is correct.\n exports: [JOB_WORKER_MODULE_OPTIONS],\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,iBAAiB;AA0C1B,IAAM,8BAA8B;AA8D7B,IAAM,4BAA4B,OAAO,IAAI,SAAS,QAAQ,uBAAuB,CAAC;AAStF,IAAM,wBAAN,MAAqE;AAAA,EAI1E,YAC6C,cACD,YACC,aAE1B,SAM6B,KAA2B,MAarC,WAQnB,iBAAiD,MAGjD,aAA0C,MAC3D;AAnC2C;AACD;AACC;AAE1B;AAM6B;AAaV;AAQnB;AAGA;AAAA,EAChB;AAAA,EAnC0C;AAAA,EACD;AAAA,EACC;AAAA,EAE1B;AAAA,EAM6B;AAAA,EAaV;AAAA,EAQnB;AAAA,EAGA;AAAA,EAtCF,SAAS,IAAI,OAAO,sBAAsB,IAAI;AAAA,EAC9C,UAAsE,CAAC;AAAA;AAAA;AAAA;AAAA,EA4CxF,MAAM,eAA8B;AAClC,UAAM,UAAU,KAAK,QAAQ,WAAW;AAGxC,UAAM,aAAa,eAAe,KAAK,QAAQ,UAAU;AAIzD,UAAM,UAAU,gBAAgB,OAAO;AAKvC,SAAK,6BAA6B,SAAS,UAAU;AAKrD,UAAM,EAAE,SAAS,IAAI,MAAM,KAAK,aAAa;AAAA,MAC3C;AAAA,MACA;AAAA,IACF;AAMA,QAAI,YAAY,YAAY,SAAS,SAAS,GAAG;AAC/C,YAAM,IAAI,oBAAoB,QAAQ;AAAA,IACxC;AAMA,UAAM,cAAc,KAAK,QAAQ,QAC7B,KAAK,QAAQ,QACb,KAAK,QAAQ,WACX,aAAa,UAAU,IACvB,wBAAwB,UAAU;AAExC,eAAW,YAAY,aAAa;AAClC,YAAM,MAAM,WAAW,IAAI,QAAQ;AACnC,UAAI,CAAC,KAAK;AACR,cAAM,IAAI;AAAA,UACR,iCAAiC,QAAQ,oEACS,CAAC,GAAG,WAAW,KAAK,CAAC,EAAE,KAAK,IAAI,CAAC;AAAA,QACrF;AAAA,MACF;AAcA,YAAM,aACJ,YAAY,YACR,KAAK,QAAQ,wBAAwB,UACrC;AACN,YAAM,gBAAkC;AAAA,QACtC,MAAM;AAAA,QACN,aAAa,IAAI;AAAA,QACjB,mBACE,KAAK,QAAQ,qBAAqB;AAAA,QACpC,gBAAgB,YAAY;AAAA,QAC5B,cAAc,YAAY;AAAA;AAAA;AAAA,QAG1B,wBAAwB,YAAY;AAAA,QACpC,kBAAkB,YAAY;AAAA,QAC9B,0BAA0B,YAAY;AAAA,MACxC;AACA,YAAM,SAAS,KAAK,QAAQ,gBACxB,KAAK,QAAQ,cAAc,aAAa,IACxC,YAAY,WACV,MAAM,KAAK,kBAAkB,UAAU,IAAI,OAAO,IAAI,aAAa,UAAU,IAC7E,KAAK,YAAY,aAAa;AAOpC,YAAM,OAAO,aAAa;AAC1B,WAAK,QAAQ,KAAK,MAAM;AACxB,WAAK,OAAO;AAAA,QACV,4BAA4B,QAAQ,aAAa,IAAI,KAAK,kBACzC,IAAI,WAAW,aAAa,OAAO;AAAA,MACtD;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,kBAAiC;AAGrC,aAAS,IAAI,KAAK,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;AACjD,YAAM,SAAS,KAAK,QAAQ,CAAC;AAC7B,UAAI,CAAC,OAAQ;AACb,UAAI;AACF,cAAM,OAAO,gBAAgB;AAAA,MAC/B,SAAS,KAAK;AACZ,aAAK,OAAO;AAAA,UACV,8BAA+B,IAAc,OAAO;AAAA,QACtD;AAAA,MACF;AAAA,IACF;AACA,SAAK,QAAQ,SAAS;AAKtB,UAAM,OAAO,KAAK;AAClB,QAAI,OAAO,KAAK,qBAAqB,YAAY;AAC/C,UAAI;AACF,cAAM,KAAK,iBAAiB;AAAA,MAC9B,SAAS,KAAK;AACZ,aAAK,OAAO;AAAA,UACV,gDAAiD,IAAc,OAAO;AAAA,QACxE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAYQ,6BACN,SACA,YACM;AACN,UAAM,YAA2D,CAAC;AAClE,eAAW,SAAS,SAAS;AAM3B,UAAI,MAAM,KAAK,WAAW,aAAa,EAAG;AAC1C,YAAM,eAAe,MAAM,KAAK,QAAQ;AACxC,YAAM,MAAM,WAAW,IAAI,YAAY;AACvC,UAAI,KAAK,UAAU;AACjB,kBAAU,KAAK;AAAA,UACb,cAAc,MAAM,aAAa;AAAA,UACjC,MAAM;AAAA,QACR,CAAC;AAAA,MACH;AAAA,IACF;AACA,QAAI,UAAU,SAAS,GAAG;AACxB,YAAM,IAAI,2BAA2B,SAAS;AAAA,IAChD;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBQ,YAAY,eAA4C;AAC9D,QAAI,CAAC,KAAK,IAAI;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AACA,QAAI,CAAC,KAAK,WAAW;AACnB,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AACA,WAAO,IAAI;AAAA,MACT,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL;AAAA,MACA,KAAK;AAAA,IACP;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,MAAc,kBACZ,MACA,aACA,aACA,YAC8D;AAC9D,QAAI,CAAC,KAAK,IAAI;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AACA,QAAI,CAAC,KAAK,gBAAgB;AACxB,YAAM,IAAI;AAAA,QACR;AAAA,MAGF;AAAA,IACF;AACA,QAAI,CAAC,KAAK,WAAW;AACnB,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AACA,UAAM,YAAY,qBAAqB,MAAM,KAAK,YAAY,UAAU;AACxE,UAAM,YAAY;AAClB,UAAM,MAAO,MAAM,OAAO;AAM1B,WAAO,IAAI,IAAI;AAAA,MACb,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL;AAAA,QACE;AAAA,QACA;AAAA,QACA;AAAA,QACA,YAAY,KAAK;AAAA,MACnB;AAAA,MACA,KAAK;AAAA,IACP;AAAA,EACF;AACF;AAzTa,wBAAN;AAAA,EADN,WAAW;AAAA,EAMP,0BAAO,gBAAgB;AAAA,EACvB,0BAAO,eAAe;AAAA,EACtB,0BAAO,gBAAgB;AAAA,EACvB,0BAAO,yBAAyB;AAAA,EAOhC,4BAAS;AAAA,EAAG,0BAAO,OAAO;AAAA,EAa1B,0BAAO,SAAS;AAAA,EAMhB,4BAAS;AAAA,EACT,0BAAO,iBAAiB;AAAA,EAExB,4BAAS;AAAA,EACT,0BAAO,sBAAsB;AAAA,GAtCrB;AA4TN,IAAM,kBAAN,MAAsB;AAAA,EAC3B,OAAO,QAAQ,MAA6C;AAC1D,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,iBAAiB,QAAQ;AAAA,UACvB,SAAS,KAAK,WAAW;AAAA,UACzB,YAAY,KAAK;AAAA,UACjB,aAAa,KAAK;AAAA,QACpB,CAAC;AAAA,MACH;AAAA,MACA,WAAW;AAAA,QACT,EAAE,SAAS,2BAA2B,UAAU,KAAK;AAAA,QACrD;AAAA,MACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAQA,SAAS,CAAC,yBAAyB;AAAA,IACrC;AAAA,EACF;AACF;AAzBa,kBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-SQDOBLBP.js";
9
9
  import {
10
10
  INTEGRATION_MULTI_TENANT
11
- } from "./chunk-S7C6TIIF.js";
11
+ } from "./chunk-S5G3HO7N.js";
12
12
  import {
13
13
  assertTenantId
14
14
  } from "./chunk-MZ6GV4YF.js";
@@ -127,4 +127,4 @@ DrizzleIntegrationRunRecorder = __decorateClass([
127
127
  export {
128
128
  DrizzleIntegrationRunRecorder
129
129
  };
130
- //# sourceMappingURL=chunk-FFUDEIFF.js.map
130
+ //# sourceMappingURL=chunk-HN5HT5WL.js.map
@@ -396,9 +396,17 @@ var ProviderIntegrationSchema = z.object({
396
396
  field_mapping: z.record(z.string(), z.string()).optional(),
397
397
  read_only_fields: z.array(z.string()).optional()
398
398
  });
399
+ var SinkPolicySchema = z.object({
400
+ delete: z.enum(["soft", "tombstone", "noop"]).optional(),
401
+ // NO .default() — see default fence in spec §Goal
402
+ exclude_fields: z.array(z.string()).optional(),
403
+ emit_changes: z.boolean().optional()
404
+ // NO .default() — absent ⇒ no emission
405
+ }).strict();
399
406
  var IntegrationConfigSchema = z.object({
400
407
  electric: z.boolean().optional().default(false),
401
- providers: z.record(z.string(), ProviderIntegrationSchema).optional()
408
+ providers: z.record(z.string(), ProviderIntegrationSchema).optional(),
409
+ sink: SinkPolicySchema.optional()
402
410
  });
403
411
  var EventDeclarationSchema = z.object({
404
412
  name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Event name must be snake_case"),
@@ -567,6 +575,42 @@ var EntityDefinitionSchema = z.object({
567
575
  });
568
576
  }
569
577
  }
578
+ }).superRefine((entity, ctx) => {
579
+ const excludeFields = entity.integration?.sink?.exclude_fields;
580
+ if (!excludeFields || excludeFields.length === 0) return;
581
+ const declaredFields = new Set(Object.keys(entity.fields ?? {}));
582
+ const fkColumns = /* @__PURE__ */ new Set();
583
+ for (const rel of Object.values(entity.relationships ?? {})) {
584
+ if (rel.type === "belongs_to" && typeof rel.foreign_key === "string") {
585
+ fkColumns.add(rel.foreign_key);
586
+ }
587
+ }
588
+ for (let i = 0; i < excludeFields.length; i++) {
589
+ const name = excludeFields[i];
590
+ if (!declaredFields.has(name)) {
591
+ ctx.addIssue({
592
+ code: "custom",
593
+ path: ["integration", "sink", "exclude_fields", i],
594
+ message: `exclude_fields: '${name}' is not a declared field. Declared fields: ${[...declaredFields].join(", ")}`
595
+ });
596
+ continue;
597
+ }
598
+ if (fkColumns.has(name)) {
599
+ ctx.addIssue({
600
+ code: "custom",
601
+ path: ["integration", "sink", "exclude_fields", i],
602
+ message: `exclude_fields: '${name}' is a FK column (belongs_to foreign_key). Excluding FK columns corrupts the FK-resolver path \u2014 exclude FK columns is not supported. Declare it in exclude_fields only for copy-through scalars.`
603
+ });
604
+ continue;
605
+ }
606
+ if (name === "user_id") {
607
+ ctx.addIssue({
608
+ code: "custom",
609
+ path: ["integration", "sink", "exclude_fields", i],
610
+ message: `exclude_fields: 'user_id' cannot be excluded. It is used for user-scoping and EAV dual-write; excluding it would break those mechanisms.`
611
+ });
612
+ }
613
+ }
570
614
  });
571
615
 
572
616
  // src/schema/event-definition.schema.ts
@@ -4305,4 +4349,4 @@ export {
4305
4349
  analyzeDomain,
4306
4350
  validateEntities
4307
4351
  };
4308
- //# sourceMappingURL=chunk-6ECCJVYW.js.map
4352
+ //# sourceMappingURL=chunk-K4BQQ2NN.js.map