@pattern-stack/codegen 0.15.3 → 0.16.1

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 (150) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/consumer-skills/integration/change-sources-and-sinks.md +1 -1
  3. package/dist/{chunk-GCYKMF22.js → chunk-24WXSC3C.js} +6 -6
  4. package/dist/{chunk-32DOFN3T.js → chunk-2WDX6I7T.js} +2 -2
  5. package/dist/{chunk-WWGYCIJX.js → chunk-43SBT72G.js} +2 -2
  6. package/dist/{chunk-FBGHYQIZ.js → chunk-5LXOJGO2.js} +6 -6
  7. package/dist/{chunk-32BMMV4H.js → chunk-5RT7JGKT.js} +5 -5
  8. package/dist/{chunk-3NMCDN7L.js → chunk-5TK7MEN4.js} +2 -2
  9. package/dist/chunk-5TK7MEN4.js.map +1 -0
  10. package/dist/{chunk-4H3PETLM.js → chunk-AYC2HEAL.js} +12 -9
  11. package/dist/chunk-AYC2HEAL.js.map +1 -0
  12. package/dist/{chunk-27ETSJ2X.js → chunk-COGHTKXY.js} +2 -2
  13. package/dist/{chunk-IYNSRIGR.js → chunk-CRBVI4GE.js} +5 -5
  14. package/dist/{chunk-J7JMVS2B.js → chunk-CZQUOIDY.js} +4 -4
  15. package/dist/{chunk-O37C3YE6.js → chunk-DGYTSCKN.js} +14 -8
  16. package/dist/chunk-DGYTSCKN.js.map +1 -0
  17. package/dist/{chunk-L7BNNRGI.js → chunk-DLG62MQY.js} +26 -6
  18. package/dist/chunk-DLG62MQY.js.map +1 -0
  19. package/dist/{chunk-TNXH7BJS.js → chunk-E45CSC33.js} +2 -2
  20. package/dist/{chunk-4JLJYWJC.js → chunk-H6FO2ZDJ.js} +99 -11
  21. package/dist/chunk-H6FO2ZDJ.js.map +1 -0
  22. package/dist/{chunk-5Y7W3XR6.js → chunk-IT6FRTEW.js} +30 -11
  23. package/dist/chunk-IT6FRTEW.js.map +1 -0
  24. package/dist/{chunk-RC23QROE.js → chunk-JM3T27ZW.js} +78 -4
  25. package/dist/chunk-JM3T27ZW.js.map +1 -0
  26. package/dist/{chunk-Z7PQCAVK.js → chunk-LQ6PYFU6.js} +4 -4
  27. package/dist/chunk-MYQIQ27N.js +118 -0
  28. package/dist/chunk-MYQIQ27N.js.map +1 -0
  29. package/dist/{chunk-YTN6BKWA.js → chunk-NXNVTXKG.js} +5 -5
  30. package/dist/{chunk-RDVTWIYY.js → chunk-QSJ3J4HE.js} +5 -5
  31. package/dist/{chunk-4MVGAMUA.js → chunk-RUSUZZAF.js} +4 -4
  32. package/dist/{chunk-4RFHUZXU.js → chunk-T4YJRD22.js} +4 -4
  33. package/dist/{chunk-7YGORYZD.js → chunk-T6C4LFLC.js} +4 -4
  34. package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
  35. package/dist/{chunk-YLPAPPLW.js → chunk-TIZXQU26.js} +36 -9
  36. package/dist/chunk-TIZXQU26.js.map +1 -0
  37. package/dist/{chunk-EOLLMEAH.js → chunk-TKVTEUBD.js} +3 -3
  38. package/dist/chunk-TKVTEUBD.js.map +1 -0
  39. package/dist/{chunk-YPWODKD5.js → chunk-W2UIDI3R.js} +5 -5
  40. package/dist/chunk-W4HOHZVF.js +1 -0
  41. package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
  42. package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
  43. package/dist/{chunk-BIO6F7YI.js → chunk-ZPL74UQN.js} +4 -2
  44. package/dist/{chunk-BIO6F7YI.js.map → chunk-ZPL74UQN.js.map} +1 -1
  45. package/dist/runtime/base-classes/index.js +22 -22
  46. package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
  47. package/dist/runtime/subsystems/analytics/index.js +4 -4
  48. package/dist/runtime/subsystems/auth/auth.module.js +1 -1
  49. package/dist/runtime/subsystems/auth/index.js +7 -7
  50. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  51. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +1 -1
  52. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.d.ts +2 -1
  53. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -5
  54. package/dist/runtime/subsystems/bridge/bridge.module.js +16 -15
  55. package/dist/runtime/subsystems/bridge/event-flow.service.js +3 -3
  56. package/dist/runtime/subsystems/bridge/index.js +16 -15
  57. package/dist/runtime/subsystems/cache/cache.drizzle-backend.js +2 -2
  58. package/dist/runtime/subsystems/cache/cache.module.js +3 -3
  59. package/dist/runtime/subsystems/cache/index.js +5 -5
  60. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +20 -0
  61. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -3
  62. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  63. package/dist/runtime/subsystems/events/events.module.d.ts +14 -0
  64. package/dist/runtime/subsystems/events/events.module.js +6 -5
  65. package/dist/runtime/subsystems/events/index.js +12 -11
  66. package/dist/runtime/subsystems/index.js +88 -87
  67. package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
  68. package/dist/runtime/subsystems/integration/detection-config.schema.d.ts +23 -15
  69. package/dist/runtime/subsystems/integration/detection-config.schema.js +1 -1
  70. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  71. package/dist/runtime/subsystems/integration/index.js +17 -17
  72. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  73. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  74. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  75. package/dist/runtime/subsystems/integration/webhook-change-source.d.ts +36 -6
  76. package/dist/runtime/subsystems/integration/webhook-change-source.js +1 -1
  77. package/dist/runtime/subsystems/jobs/index.d.ts +2 -1
  78. package/dist/runtime/subsystems/jobs/index.js +42 -30
  79. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +6 -5
  80. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  81. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +2 -1
  82. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +4 -3
  83. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +3 -3
  84. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  85. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  86. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  87. package/dist/runtime/subsystems/jobs/job-worker.d.ts +28 -0
  88. package/dist/runtime/subsystems/jobs/job-worker.js +4 -3
  89. package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -10
  90. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +12 -7
  91. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -8
  92. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.d.ts +13 -1
  93. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.js +3 -1
  94. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +85 -0
  95. package/dist/runtime/subsystems/jobs/pg-notify.js +14 -0
  96. package/dist/runtime/subsystems/jobs/pg-notify.js.map +1 -0
  97. package/dist/runtime/subsystems/observability/index.js +4 -4
  98. package/dist/runtime/subsystems/observability/observability.module.js +4 -4
  99. package/dist/runtime/subsystems/observability/observability.service.js +3 -3
  100. package/dist/runtime/subsystems/storage/index.js +4 -4
  101. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  102. package/dist/src/cli/index.js +53 -15
  103. package/dist/src/cli/index.js.map +1 -1
  104. package/dist/src/index.d.ts +11 -11
  105. package/dist/src/index.js +9 -9
  106. package/package.json +1 -1
  107. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +27 -0
  108. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +108 -4
  109. package/runtime/subsystems/events/events.module.ts +14 -0
  110. package/runtime/subsystems/integration/detection-config.schema.ts +64 -54
  111. package/runtime/subsystems/integration/webhook-change-source.ts +187 -133
  112. package/runtime/subsystems/jobs/index.ts +10 -0
  113. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +29 -2
  114. package/runtime/subsystems/jobs/job-worker.module.ts +11 -0
  115. package/runtime/subsystems/jobs/job-worker.ts +98 -0
  116. package/runtime/subsystems/jobs/jobs-domain.module.ts +22 -7
  117. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +13 -0
  118. package/runtime/subsystems/jobs/pg-notify.ts +216 -0
  119. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +14 -0
  120. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +13 -4
  121. package/dist/chunk-3NMCDN7L.js.map +0 -1
  122. package/dist/chunk-4H3PETLM.js.map +0 -1
  123. package/dist/chunk-4JLJYWJC.js.map +0 -1
  124. package/dist/chunk-5Y7W3XR6.js.map +0 -1
  125. package/dist/chunk-EOLLMEAH.js.map +0 -1
  126. package/dist/chunk-L7BNNRGI.js.map +0 -1
  127. package/dist/chunk-O37C3YE6.js.map +0 -1
  128. package/dist/chunk-RC23QROE.js.map +0 -1
  129. package/dist/chunk-UTN4GBPQ.js +0 -1
  130. package/dist/chunk-YLPAPPLW.js.map +0 -1
  131. /package/dist/{chunk-GCYKMF22.js.map → chunk-24WXSC3C.js.map} +0 -0
  132. /package/dist/{chunk-32DOFN3T.js.map → chunk-2WDX6I7T.js.map} +0 -0
  133. /package/dist/{chunk-WWGYCIJX.js.map → chunk-43SBT72G.js.map} +0 -0
  134. /package/dist/{chunk-FBGHYQIZ.js.map → chunk-5LXOJGO2.js.map} +0 -0
  135. /package/dist/{chunk-32BMMV4H.js.map → chunk-5RT7JGKT.js.map} +0 -0
  136. /package/dist/{chunk-27ETSJ2X.js.map → chunk-COGHTKXY.js.map} +0 -0
  137. /package/dist/{chunk-IYNSRIGR.js.map → chunk-CRBVI4GE.js.map} +0 -0
  138. /package/dist/{chunk-J7JMVS2B.js.map → chunk-CZQUOIDY.js.map} +0 -0
  139. /package/dist/{chunk-TNXH7BJS.js.map → chunk-E45CSC33.js.map} +0 -0
  140. /package/dist/{chunk-Z7PQCAVK.js.map → chunk-LQ6PYFU6.js.map} +0 -0
  141. /package/dist/{chunk-YTN6BKWA.js.map → chunk-NXNVTXKG.js.map} +0 -0
  142. /package/dist/{chunk-RDVTWIYY.js.map → chunk-QSJ3J4HE.js.map} +0 -0
  143. /package/dist/{chunk-4MVGAMUA.js.map → chunk-RUSUZZAF.js.map} +0 -0
  144. /package/dist/{chunk-4RFHUZXU.js.map → chunk-T4YJRD22.js.map} +0 -0
  145. /package/dist/{chunk-7YGORYZD.js.map → chunk-T6C4LFLC.js.map} +0 -0
  146. /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
  147. /package/dist/{chunk-YPWODKD5.js.map → chunk-W2UIDI3R.js.map} +0 -0
  148. /package/dist/{chunk-UTN4GBPQ.js.map → chunk-W4HOHZVF.js.map} +0 -0
  149. /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
  150. /package/dist/{chunk-SR7F3TJY.js.map → chunk-YK5JEVLX.js.map} +0 -0
@@ -21,6 +21,7 @@ import {
21
21
  JOB_RUN_SERVICE,
22
22
  JOB_STEP_SERVICE,
23
23
  JOBS_MULTI_TENANT,
24
+ JOBS_LISTEN_NOTIFY,
24
25
  } from './jobs-domain.tokens';
25
26
  import { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
26
27
  import { DrizzleJobRunService } from './job-run-service.drizzle-backend';
@@ -41,17 +42,22 @@ import {
41
42
  } from './bullmq.config';
42
43
 
43
44
  /**
44
- * Drizzle backend extensions surface. None are wired into the Drizzle
45
- * orchestrator yet — this is the **typed reservation** for the LISTEN/NOTIFY
46
- * + tunable poll-interval extensions called out in ADR-022. App code
47
- * passing these today is parsed but not yet dispatched; when the
48
- * Drizzle orchestrator grows the consumer hooks, opt-in code paths will
49
- * read directly from these fields.
45
+ * Drizzle backend extensions surface (LISTEN-NOTIFY-1 wires both fields).
46
+ *
47
+ * - `listenNotify` provided as `JOBS_LISTEN_NOTIFY` so the orchestrator emits
48
+ * in-tx `pg_notify` on enqueue, and threaded into each spawned `JobWorker`
49
+ * (which holds the listener connection). Off by default.
50
+ * - `pollIntervalMs` threaded into the spawned `JobWorker`'s
51
+ * `JobWorkerOptions.pollIntervalMs` (the worker already honored this; it just
52
+ * never received a config value). Default 1000.
53
+ *
54
+ * Both run ALONGSIDE interval polling — `listenNotify` only adds an early wake;
55
+ * polling remains the durability heartbeat.
50
56
  */
51
57
  export interface DrizzleBackendExtensions {
52
58
  /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */
53
59
  listenNotify?: boolean;
54
- /** Polling interval when LISTEN/NOTIFY is off (ms). Default 1000. */
60
+ /** Polling interval (ms). Default 1000. */
55
61
  pollIntervalMs?: number;
56
62
  }
57
63
 
@@ -79,6 +85,11 @@ export interface JobsDomainModuleOptions {
79
85
  export class JobsDomainModule {
80
86
  static forRoot(opts: JobsDomainModuleOptions): DynamicModule {
81
87
  const multiTenant = opts.multiTenant ?? false;
88
+ // LISTEN-NOTIFY-1 — drizzle-only extension. `listen_notify` is meaningless
89
+ // for memory (no DB) and redundant for bullmq (native wakeups); only the
90
+ // drizzle backend's orchestrator reads it.
91
+ const listenNotify =
92
+ opts.backend === 'drizzle' && Boolean(opts.extensions?.drizzle?.listenNotify);
82
93
 
83
94
  const providers: Provider[] = [
84
95
  // JOB-8 — boolean provider consumed by the four service-layer backends.
@@ -87,6 +98,9 @@ export class JobsDomainModule {
87
98
  // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop
88
99
  // cross-tenant-by-design decision.
89
100
  { provide: JOBS_MULTI_TENANT, useValue: multiTenant },
101
+ // LISTEN-NOTIFY-1 — always provided so the orchestrator's `@Inject`
102
+ // resolves; the orchestrator skips the `pg_notify` emit when `false`.
103
+ { provide: JOBS_LISTEN_NOTIFY, useValue: listenNotify },
90
104
  ];
91
105
 
92
106
  if (opts.backend === 'memory') {
@@ -144,6 +158,7 @@ export class JobsDomainModule {
144
158
  JOB_RUN_SERVICE,
145
159
  JOB_STEP_SERVICE,
146
160
  JOBS_MULTI_TENANT,
161
+ JOBS_LISTEN_NOTIFY,
147
162
  ];
148
163
  // BULLMQ-1 — only export the BullMQ tokens when they were actually
149
164
  // provided. Nest throws "exported but not provided" otherwise. Exported so
@@ -31,3 +31,16 @@ export const JOB_STEP_SERVICE = Symbol.for(tokenKey('jobs', 'step-service'));
31
31
  * targeted reads. See docs/specs/JOB-8.md.
32
32
  */
33
33
  export const JOBS_MULTI_TENANT = Symbol.for(tokenKey('jobs', 'multi-tenant'));
34
+
35
+ /**
36
+ * LISTEN/NOTIFY wakeup opt-in flag (LISTEN-NOTIFY-1). Bound to
37
+ * `JobsDomainModule.forRoot({ extensions: { drizzle: { listenNotify } } })`,
38
+ * defaulting to `false`.
39
+ *
40
+ * When `true`, the Drizzle orchestrator emits an in-transaction
41
+ * `pg_notify(codegen_jobs_wake, <pool>)` on every `start()` INSERT so a worker
42
+ * with `listen_notify` enabled wakes the moment the enqueue commits. Off by
43
+ * default; polling is unchanged. The flag is read by `DrizzleJobOrchestrator`
44
+ * and by the bridge outbox drain hook (its wrapper `job_run` inserts notify too).
45
+ */
46
+ export const JOBS_LISTEN_NOTIFY = Symbol.for(tokenKey('jobs', 'listen-notify'));
@@ -0,0 +1,216 @@
1
+ /**
2
+ * PgNotifyListener + pgNotify — Postgres LISTEN/NOTIFY wakeups
3
+ * (LISTEN-NOTIFY-1, dogfood gap #7).
4
+ *
5
+ * The drizzle jobs worker and events outbox drainer poll on an interval today
6
+ * (default 1 s/hop). With `listen_notify` enabled, a row write that makes work
7
+ * claimable emits an in-transaction `pg_notify(...)`; a dedicated listener
8
+ * connection wakes the polling loop the moment the writing transaction commits.
9
+ *
10
+ * Two halves:
11
+ * - `pgNotify(tx, channel, payload)` — fire an in-tx `pg_notify`. MUST be
12
+ * called with the SAME transaction handle as the row write it announces, so
13
+ * Postgres delivers it only on commit (the transactional-outbox guarantee).
14
+ * - `PgNotifyListener` — owns a single long-lived `pg.PoolClient`, issues
15
+ * `LISTEN <channel>`, forwards each notification's payload to an owner
16
+ * callback, debounces bursts, and reconnects with capped backoff on drop.
17
+ *
18
+ * **Polling never stops.** This is a wake-early optimisation layered ON TOP of
19
+ * interval polling. A lost notification (listener down, pooler eats the LISTEN,
20
+ * etc.) degrades to today's poll latency, never to lost work — the claim/drain
21
+ * query remains the source of truth.
22
+ *
23
+ * **PgBouncer caveat:** session-scoped `LISTEN` does not survive a
24
+ * transaction-mode pooler. `listen_notify` requires a direct (or session-mode)
25
+ * connection; behind a transaction pooler notifies are simply never received and
26
+ * the system degrades to polling. See the jobs config block / skill.
27
+ */
28
+ // TODO(logging-subsystem): swap to ILogger once ADR-028 lands
29
+ import { Logger } from '@nestjs/common';
30
+ import { sql } from 'drizzle-orm';
31
+ import type { DrizzleClient } from '../../types/drizzle';
32
+ import type { DrizzleTransaction } from '../events/event-bus.protocol';
33
+
34
+ /** Channel the jobs worker LISTENs on; payload = pool name. */
35
+ export const JOBS_WAKE_CHANNEL = 'codegen_jobs_wake';
36
+ /** Channel the events drainer LISTENs on; payload = event pool (or ''). */
37
+ export const EVENTS_WAKE_CHANNEL = 'codegen_events_wake';
38
+
39
+ /**
40
+ * Emit an in-transaction `pg_notify`. Call with the SAME `tx`/client handle as
41
+ * the row write being announced so delivery is gated on commit. `payload` is a
42
+ * short plain string (a pool name); it is NOT JSON — the wake is a hint and the
43
+ * subsequent claim/drain query is authoritative. Channel names are framework
44
+ * constants (never user input), so the `set_config`-free literal-channel form is
45
+ * safe; the payload is bound as a parameter.
46
+ */
47
+ export async function pgNotify(
48
+ tx: DrizzleClient | DrizzleTransaction,
49
+ channel: string,
50
+ payload: string,
51
+ ): Promise<void> {
52
+ const client = tx as DrizzleClient;
53
+ // `pg_notify(channel, payload)` is the function form (vs the `NOTIFY chan,
54
+ // 'payload'` statement form) precisely because it accepts bound parameters —
55
+ // the payload is parameterised, never string-concatenated.
56
+ await client.execute(sql`select pg_notify(${channel}, ${payload})`);
57
+ }
58
+
59
+ /** Minimal structural view of the `pg` Client/PoolClient surface we touch. */
60
+ interface PgListenClient {
61
+ query(text: string): Promise<unknown>;
62
+ on(event: 'notification', cb: (msg: { channel: string; payload?: string }) => void): void;
63
+ on(event: 'error', cb: (err: Error) => void): void;
64
+ removeAllListeners?: (event?: string) => void;
65
+ release?: (err?: boolean) => void;
66
+ end?: () => Promise<void>;
67
+ }
68
+
69
+ /** Minimal structural view of the `pg` Pool's `connect()`. */
70
+ interface PgPoolish {
71
+ connect(): Promise<PgListenClient>;
72
+ }
73
+
74
+ const DEFAULT_BACKOFF_MIN_MS = 100;
75
+ const DEFAULT_BACKOFF_MAX_MS = 5_000;
76
+
77
+ export interface PgNotifyListenerOptions {
78
+ /** Channel to LISTEN on. */
79
+ channel: string;
80
+ /**
81
+ * The underlying `pg.Pool` — obtained from `drizzleClient.$client`. A
82
+ * dedicated `PoolClient` is checked out and held for the listener's lifetime
83
+ * (separate from the query pool so a slow query never delays a wake).
84
+ */
85
+ pool: PgPoolish;
86
+ /**
87
+ * Called for every notification on `channel`, with the raw payload string
88
+ * (`''` when Postgres delivers an empty payload). The owner decides whether
89
+ * the payload is relevant (e.g. "is this one of my pools?") and debounces its
90
+ * own claim cycle.
91
+ */
92
+ onNotify: (payload: string) => void;
93
+ /** Label used in log lines (e.g. 'jobs:interactive', 'events'). */
94
+ label: string;
95
+ backoffMinMs?: number;
96
+ backoffMaxMs?: number;
97
+ }
98
+
99
+ /**
100
+ * Holds a dedicated listener connection and forwards notifications to `onNotify`.
101
+ * Reconnects with capped exponential backoff on drop; logs the first failure +
102
+ * the recovery exactly once each so a flapping connection doesn't flood logs.
103
+ */
104
+ export class PgNotifyListener {
105
+ private readonly logger: Logger;
106
+ private client: PgListenClient | null = null;
107
+ private stopped = false;
108
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
109
+ private backoffMs: number;
110
+ private readonly backoffMinMs: number;
111
+ private readonly backoffMaxMs: number;
112
+ /** WARN-once gate so a flapping listener doesn't spam the log. */
113
+ private warnedDown = false;
114
+
115
+ constructor(private readonly opts: PgNotifyListenerOptions) {
116
+ this.logger = new Logger(`PgNotifyListener(${opts.label})`);
117
+ this.backoffMinMs = opts.backoffMinMs ?? DEFAULT_BACKOFF_MIN_MS;
118
+ this.backoffMaxMs = opts.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
119
+ this.backoffMs = this.backoffMinMs;
120
+ }
121
+
122
+ /** Begin listening. Idempotent-ish: a second call while connected is a no-op. */
123
+ async start(): Promise<void> {
124
+ this.stopped = false;
125
+ await this.connect();
126
+ }
127
+
128
+ /** Stop listening + release the connection. Safe to call repeatedly. */
129
+ async stop(): Promise<void> {
130
+ this.stopped = true;
131
+ if (this.reconnectTimer) {
132
+ clearTimeout(this.reconnectTimer);
133
+ this.reconnectTimer = null;
134
+ }
135
+ await this.releaseClient();
136
+ }
137
+
138
+ private async connect(): Promise<void> {
139
+ if (this.stopped) return;
140
+ try {
141
+ const client = await this.opts.pool.connect();
142
+ client.on('notification', (msg) => {
143
+ if (msg.channel !== this.opts.channel) return;
144
+ try {
145
+ this.opts.onNotify(msg.payload ?? '');
146
+ } catch (err) {
147
+ this.logger.error(`onNotify threw: ${(err as Error).message}`);
148
+ }
149
+ });
150
+ client.on('error', (err) => {
151
+ // A connection-level error is the signal to reconnect. Don't double-log
152
+ // here — scheduleReconnect owns the WARN-once.
153
+ this.logger.debug?.(`listener connection error: ${err.message}`);
154
+ this.handleDrop();
155
+ });
156
+ await client.query(`LISTEN ${this.opts.channel}`);
157
+ this.client = client;
158
+ // Recovery: only announce if we had previously warned about being down.
159
+ if (this.warnedDown) {
160
+ this.logger.log(
161
+ `listener reconnected; LISTEN ${this.opts.channel} re-established`,
162
+ );
163
+ this.warnedDown = false;
164
+ }
165
+ this.backoffMs = this.backoffMinMs;
166
+ } catch (err) {
167
+ this.handleConnectFailure(err);
168
+ }
169
+ }
170
+
171
+ /** Connection dropped after being established → reconnect. */
172
+ private handleDrop(): void {
173
+ if (this.stopped) return;
174
+ void this.releaseClient().finally(() => this.scheduleReconnect());
175
+ }
176
+
177
+ /** Initial / reconnect `connect()` threw. */
178
+ private handleConnectFailure(err: unknown): void {
179
+ this.scheduleReconnect(err);
180
+ }
181
+
182
+ private scheduleReconnect(err?: unknown): void {
183
+ if (this.stopped) return;
184
+ if (!this.warnedDown) {
185
+ this.warnedDown = true;
186
+ this.logger.warn(
187
+ `listener down — falling back to interval polling until reconnect. ` +
188
+ `Cause: ${err instanceof Error ? err.message : 'connection lost'}. ` +
189
+ `(This degrades latency, not durability — polling still drives all work.)`,
190
+ );
191
+ }
192
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
193
+ const delay = this.backoffMs;
194
+ this.backoffMs = Math.min(this.backoffMs * 2, this.backoffMaxMs);
195
+ this.reconnectTimer = setTimeout(() => {
196
+ this.reconnectTimer = null;
197
+ void this.connect();
198
+ }, delay);
199
+ }
200
+
201
+ private async releaseClient(): Promise<void> {
202
+ const client = this.client;
203
+ this.client = null;
204
+ if (!client) return;
205
+ try {
206
+ client.removeAllListeners?.('notification');
207
+ 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
+ if (client.release) client.release(true);
211
+ else if (client.end) await client.end();
212
+ } catch {
213
+ // best-effort teardown
214
+ }
215
+ }
216
+ }
@@ -24,3 +24,17 @@ events:
24
24
  # all pending rows. Typical split is one process per lane so a slow
25
25
  # outbound handler cannot stall change-event propagation.
26
26
  # pools: [] # e.g. [events_inbound] | [events_change] | [events_outbound]
27
+
28
+ # ── Backend-specific extensions (typed per backend) ──
29
+ extensions:
30
+ drizzle:
31
+ # listen_notify: true # Postgres LISTEN/NOTIFY wakes the outbox
32
+ # # drainer the instant an event is published
33
+ # # (in-tx pg_notify, delivered on commit),
34
+ # # ALONGSIDE interval polling — polling stays
35
+ # # the safety net. Sub-500ms drain vs ~1s poll.
36
+ # # Off by default. REQUIRES a direct (or
37
+ # # session-mode) connection — session-scoped
38
+ # # LISTEN does NOT survive a transaction-mode
39
+ # # pooler (PgBouncer pool_mode=transaction);
40
+ # # behind one, the drainer degrades to polling.
@@ -17,10 +17,19 @@ jobs:
17
17
  # the active backend produce a config validation warning at boot.
18
18
  extensions:
19
19
  drizzle:
20
- # listen_notify: true # use Postgres LISTEN/NOTIFY to wake the
21
- # # polling loop instead of (or alongside)
22
- # # interval polling. Disabled by default.
23
- poll_interval_ms: 1000
20
+ # listen_notify: true # Postgres LISTEN/NOTIFY wakes the worker the
21
+ # # instant a job is enqueued (in-tx pg_notify,
22
+ # # delivered on commit), ALONGSIDE interval
23
+ # # polling — polling stays the safety net, so a
24
+ # # lost notify costs latency, never work.
25
+ # # Sub-500ms claim vs ~1s/poll-hop. Off by
26
+ # # default. REQUIRES a direct (or session-mode)
27
+ # # connection — session-scoped LISTEN does NOT
28
+ # # survive a transaction-mode pooler (PgBouncer
29
+ # # pool_mode=transaction); behind one, notifies
30
+ # # are simply never received and the worker
31
+ # # degrades to polling.
32
+ poll_interval_ms: 1000 # interval-poll heartbeat (the wake fallback)
24
33
  # bullmq: # Example shape for Phase 6+ BullMQ backend.
25
34
  # bull_board: # Mount Bull Board admin UI.
26
35
  # enabled: true
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/integration/detection-config.schema.ts"],"sourcesContent":["/**\n * Integration subsystem — DetectionConfig schema (#226-1)\n *\n * Canonical Zod schema for per-entity integration detection config. The schema is\n * the single source of truth for filter/mapping shape and is consumed by:\n *\n * 1. Runtime primitives — `PollChangeSource<T>`, `WebhookChangeSource<T>`\n * (#226-3, #226-4) accept a parsed `DetectionConfig` at construction.\n * 2. Codegen — `src/schema/entity-definition.schema.ts` (#226-6) imports\n * this schema so per-entity YAML `detection:` blocks validate against\n * the same shape the runtime enforces.\n *\n * Locked decisions (see ADR-033 + decision memo Q1–Q6):\n * - Filter vocabulary is flat AND of `{ field, op, value }` triples; richer\n * boolean expressions (OR / NOT / nested) are deferred per epic open Q3.\n * - Cursor strategy is a tagged union over the four shapes the three modes\n * need (`systemModstamp`, `replayId`, `timestamp`, `eventId`). Each\n * strategy types its cursor internally; the orchestrator persists what\n * the iterator last yielded (integration skill rule 2).\n * - `mode: 'poll'` may opt into `provenance: 'cdc'` so Stripe-style event\n * endpoints (mechanically a poll, semantically CDC) reuse the poll\n * primitive while emitting `Change<T>.source = 'cdc'`. Long-lived\n * streaming CDC (SFDC Pub-Sub, Debezium) is a separate primitive\n * deferred to #226-8.\n * - `webhook` mode requires `eventIdField` so `WebhookChangeSource<T>`\n * can populate `Change<T>.dedupKey` from the inbound staging row.\n */\nimport { z } from 'zod';\n\n// ============================================================================\n// Field mapping — provider field → canonical target\n// ============================================================================\n\n/**\n * Maps a single provider field onto the canonical record. `transform` is an\n * opt-in tag the adapter callback may inspect (`date-iso`, `decimal-string`,\n * etc.); the schema does not enumerate transforms — adapters interpret them.\n */\nexport const FieldMappingSchema = z.object({\n source: z.string().min(1),\n target: z.string().min(1),\n transform: z.string().min(1).optional(),\n});\n\nexport type FieldMapping = z.infer<typeof FieldMappingSchema>;\n\n// ============================================================================\n// Resolved filter — flat-AND triple\n// ============================================================================\n\n/**\n * A single resolved filter clause applied at fetch time. `value` is `unknown`\n * to admit primitives, arrays (for `in` / `nin`), and dates as ISO strings —\n * adapters interpret per provider.\n */\nexport const ResolvedFilterSchema = z.object({\n field: z.string().min(1),\n op: z.enum(['eq', 'neq', 'in', 'nin', 'gt', 'gte', 'lt', 'lte']),\n value: z.unknown(),\n});\n\nexport type ResolvedFilter = z.infer<typeof ResolvedFilterSchema>;\n\n// ============================================================================\n// Cursor strategy — tagged union over the four shapes the modes need\n// ============================================================================\n\nconst SystemModstampCursorSchema = z.object({\n kind: z.literal('systemModstamp'),\n field: z.string().min(1),\n});\n\nconst ReplayIdCursorSchema = z.object({\n kind: z.literal('replayId'),\n field: z.string().min(1),\n});\n\nconst TimestampCursorSchema = z.object({\n kind: z.literal('timestamp'),\n field: z.string().min(1),\n});\n\nconst EventIdCursorSchema = z.object({\n kind: z.literal('eventId'),\n field: z.string().min(1),\n});\n\n/**\n * Gmail `historyId` (RFC-0003 §3) — an opaque, atomic vendor token. The next\n * watermark only exists at end-of-walk; there is no resumable mid-walk value.\n * `field` is metadata for codegen/adapters (the response key the token lives on).\n */\nconst HistoryIdCursorSchema = z.object({\n kind: z.literal('historyId'),\n field: z.string().min(1),\n});\n\n/**\n * Google Calendar `syncToken` (RFC-0003 §3) — an opaque, atomic sync token,\n * same divisibility profile as `historyId`.\n */\nconst SyncTokenCursorSchema = z.object({\n kind: z.literal('syncToken'),\n field: z.string().min(1),\n});\n\nexport const CursorStrategySchema = z.discriminatedUnion('kind', [\n SystemModstampCursorSchema,\n ReplayIdCursorSchema,\n TimestampCursorSchema,\n EventIdCursorSchema,\n HistoryIdCursorSchema,\n SyncTokenCursorSchema,\n]);\n\nexport type CursorStrategy = z.infer<typeof CursorStrategySchema>;\n\n// ============================================================================\n// Cursor divisibility (RFC-0003 §3)\n// ============================================================================\n\n/**\n * Whether a cursor strategy is *divisible* — a property of the strategy, not\n * the read primitive. Divisible cursors are sortable/monotonic watermarks whose\n * value is meaningful AS OF any single record (HubSpot `systemModstamp`, a\n * `timestamp` field, a Salesforce CDC `replayId`); the read primitive may\n * checkpoint per-ref mid-walk, so a crash resumes from the last delivered ref.\n *\n * Atomic cursors are opaque vendor tokens (Gmail `historyId`, Calendar\n * `syncToken`, a generic `eventId`) whose next value only exists at end-of-walk.\n * The primitive must withhold per-ref cursors and emit the token only at a safe\n * boundary, so an interrupted run never persists an unresumable mid-walk token\n * (it resumes all-or-nothing from the prior token — see `IncrementalReadBase`).\n *\n * `eventId` is classified atomic conservatively: a generic opaque id is treated\n * all-or-nothing unless a concrete strategy proves it monotonically resumable.\n */\nexport const CURSOR_DIVISIBILITY: Readonly<Record<CursorStrategy['kind'], boolean>> = {\n systemModstamp: true,\n timestamp: true,\n replayId: true,\n eventId: false,\n historyId: false,\n syncToken: false,\n};\n\n/** Predicate form of {@link CURSOR_DIVISIBILITY}. */\nexport function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {\n return CURSOR_DIVISIBILITY[kind];\n}\n\n// ============================================================================\n// Mode-specific blocks\n// ============================================================================\n\n/**\n * Poll-mode block. `provenance: 'cdc'` opts the poll primitive into stamping\n * `Change<T>.source = 'cdc'` and populating `dedupKey` from the cursor's\n * `field` — used for Stripe-style event endpoints. Defaults to `'poll'`.\n */\nexport const PollDetectionSchema = z.object({\n cursor: CursorStrategySchema,\n provenance: z.enum(['poll', 'cdc']).optional(),\n});\n\nexport type PollDetection = z.infer<typeof PollDetectionSchema>;\n\n/**\n * Webhook-mode block. `eventIdField` names the column in the consumer-owned\n * inbound staging row that `WebhookChangeSource<T>` reads to set\n * `Change<T>.dedupKey`.\n */\nexport const WebhookDetectionSchema = z.object({\n eventIdField: z.string().min(1),\n});\n\nexport type WebhookDetection = z.infer<typeof WebhookDetectionSchema>;\n\n// ============================================================================\n// DetectionConfig — top-level discriminated union over `mode`\n// ============================================================================\n\nconst PollModeSchema = z.object({\n mode: z.literal('poll'),\n poll: PollDetectionSchema,\n mapping: z.array(FieldMappingSchema).min(1),\n filters: z.array(ResolvedFilterSchema).default([]),\n});\n\nconst WebhookModeSchema = z.object({\n mode: z.literal('webhook'),\n webhook: WebhookDetectionSchema,\n mapping: z.array(FieldMappingSchema).min(1),\n filters: z.array(ResolvedFilterSchema).default([]),\n});\n\n/**\n * Top-level detection config. Discriminated on `mode` so the relevant\n * mode-block (poll/webhook) is structurally required for that mode. CDC as a\n * long-lived streaming primitive is deferred (#226-8); CDC-as-provenance\n * (Stripe-style event endpoints) is expressed via `mode: 'poll'` with\n * `poll.provenance: 'cdc'`.\n */\nexport const DetectionConfigSchema = z.discriminatedUnion('mode', [\n PollModeSchema,\n WebhookModeSchema,\n]);\n\nexport type DetectionConfig = z.infer<typeof DetectionConfigSchema>;\n"],"mappings":";AA2BA,SAAS,SAAS;AAWX,IAAM,qBAAqB,EAAE,OAAO;AAAA,EACzC,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACxB,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACxB,WAAW,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AACxC,CAAC;AAaM,IAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACvB,IAAI,EAAE,KAAK,CAAC,MAAM,OAAO,MAAM,OAAO,MAAM,OAAO,MAAM,KAAK,CAAC;AAAA,EAC/D,OAAO,EAAE,QAAQ;AACnB,CAAC;AAQD,IAAM,6BAA6B,EAAE,OAAO;AAAA,EAC1C,MAAM,EAAE,QAAQ,gBAAgB;AAAA,EAChC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAED,IAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,QAAQ,UAAU;AAAA,EAC1B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAED,IAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,MAAM,EAAE,QAAQ,WAAW;AAAA,EAC3B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAED,IAAM,sBAAsB,EAAE,OAAO;AAAA,EACnC,MAAM,EAAE,QAAQ,SAAS;AAAA,EACzB,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAOD,IAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,MAAM,EAAE,QAAQ,WAAW;AAAA,EAC3B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAMD,IAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,MAAM,EAAE,QAAQ,WAAW;AAAA,EAC3B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AACzB,CAAC;AAEM,IAAM,uBAAuB,EAAE,mBAAmB,QAAQ;AAAA,EAC/D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAwBM,IAAM,sBAAyE;AAAA,EACpF,gBAAgB;AAAA,EAChB,WAAW;AAAA,EACX,UAAU;AAAA,EACV,SAAS;AAAA,EACT,WAAW;AAAA,EACX,WAAW;AACb;AAGO,SAAS,kBAAkB,MAAuC;AACvE,SAAO,oBAAoB,IAAI;AACjC;AAWO,IAAM,sBAAsB,EAAE,OAAO;AAAA,EAC1C,QAAQ;AAAA,EACR,YAAY,EAAE,KAAK,CAAC,QAAQ,KAAK,CAAC,EAAE,SAAS;AAC/C,CAAC;AASM,IAAM,yBAAyB,EAAE,OAAO;AAAA,EAC7C,cAAc,EAAE,OAAO,EAAE,IAAI,CAAC;AAChC,CAAC;AAQD,IAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,MAAM,EAAE,QAAQ,MAAM;AAAA,EACtB,MAAM;AAAA,EACN,SAAS,EAAE,MAAM,kBAAkB,EAAE,IAAI,CAAC;AAAA,EAC1C,SAAS,EAAE,MAAM,oBAAoB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC;AAED,IAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,MAAM,EAAE,QAAQ,SAAS;AAAA,EACzB,SAAS;AAAA,EACT,SAAS,EAAE,MAAM,kBAAkB,EAAE,IAAI,CAAC;AAAA,EAC1C,SAAS,EAAE,MAAM,oBAAoB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC;AASM,IAAM,wBAAwB,EAAE,mBAAmB,QAAQ;AAAA,EAChE;AAAA,EACA;AACF,CAAC;","names":[]}
@@ -1 +0,0 @@
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 const workerOptions: JobWorkerOptions = {\n pool: poolName,\n concurrency: def.concurrency,\n shutdownTimeoutMs:\n this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,\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":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/events/event-bus.drizzle-backend.ts"],"sourcesContent":["/**\n * DrizzleEventBus — Postgres-backed event bus using the transactional outbox pattern.\n *\n * Events are inserted into the `domain_events` table within the caller's\n * Drizzle transaction. A background polling loop (started on module init)\n * reads unprocessed events and dispatches them to registered subscribers.\n *\n * When the transaction rolls back, the event is never persisted — no\n * phantom events.\n *\n * Pool awareness (EVT-4):\n * - On `publish`/`publishMany` the backend writes `metadata.pool`,\n * `metadata.direction`, and `metadata.tenantId` into the first-class\n * `pool` / `direction` / `tenant_id` columns (metadata JSON is still\n * written unchanged for protocol stability).\n * - The drain loop filters by `opts.pools` when provided, so separate\n * processes (e.g. one per `events_inbound` / `events_change` /\n * `events_outbound`) can claim only their own lane. `pools: undefined`\n * drains all pending rows (backwards-compatible behaviour).\n *\n * EVT-Q7: No stale-event sweeper. `FOR UPDATE SKIP LOCKED` is\n * self-healing — the row is only locked for the duration of the\n * enclosing polling transaction; the `status='processed'` update happens\n * within that same transaction. There is no `claimed_at` semantic (unlike\n * jobs), so no stale rows can exist.\n *\n * This backend is suitable until you need real-time fan-out or very high\n * throughput. At that point, swap the backend for Redis Streams or similar\n * via EventsModule.forRoot({ backend: '...' }) without touching use cases.\n */\nimport { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';\nimport { eq, and, inArray, asc, desc, gte, lt, or, sql, type SQL } from 'drizzle-orm';\nimport type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';\nimport type {\n EventPage,\n IEventReadPort,\n ListEventsQuery,\n} from './event-read.protocol';\nimport {\n clampEventLimit,\n decodeEventCursor,\n encodeEventCursor,\n} from './event-keyset-cursor';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { domainEvents, type DomainEventRecord } from './domain-events.schema';\nimport { DRIZZLE } from '../../constants/tokens';\nimport { EVENTS_MODULE_OPTIONS } from './events.tokens';\nimport type { EventsModuleOptions } from './events.module';\nimport { BRIDGE_OUTBOX_DRAIN_HOOK } from '../bridge/bridge.tokens';\nimport type { IBridgeOutboxDrainHook } from '../bridge/bridge.protocol';\n\n/** How long to wait between polling cycles (ms). */\nconst POLL_INTERVAL_MS = 1_000;\n/** Max events claimed per polling cycle to bound memory usage. */\nconst POLL_BATCH_SIZE = 50;\n\n/**\n * Row shape built from `metadata` for writing into `domain_events`. Keeps\n * the per-event extraction logic in one place so publish/publishMany stay\n * in sync.\n */\nfunction toInsertValues(event: DomainEvent, multiTenant: boolean) {\n const metadata = event.metadata ?? undefined;\n const pool = (metadata?.['pool'] as string | undefined) ?? null;\n const direction = (metadata?.['direction'] as string | undefined) ?? null;\n // AUDIT-1: tier defaults to 'domain' when absent. The DB CHECK\n // constraint (`domain_events_tier_routing_check`) enforces the\n // tier ⇔ routing-fields invariant at the storage boundary; no\n // JS-side assertion is needed here.\n const tier = (metadata?.['tier'] as string | undefined) ?? 'domain';\n const base = {\n id: event.id,\n type: event.type,\n aggregateId: event.aggregateId,\n aggregateType: event.aggregateType,\n payload: event.payload,\n occurredAt: event.occurredAt,\n processedAt: null,\n status: 'pending' as const,\n metadata: event.metadata,\n pool,\n direction,\n tier,\n };\n // EVT-8: `tenant_id` is a scaffold-time conditional column, emitted only\n // when `events.multi_tenant: true`. Only write it when multi-tenancy is\n // on — under single-tenant scaffolds the column does not exist, so the\n // key must be omitted from the insert.\n if (!multiTenant) return base;\n const tenantId = (metadata?.['tenantId'] as string | undefined) ?? null;\n return { ...base, tenantId };\n}\n\n/**\n * Project a raw `domain_events` row into the narrow `EventSummary` shape.\n * Shared with the memory backend via this helper kept module-local to each\n * backend (the events subsystem has no cross-backend projection file yet;\n * the two are byte-identical and small).\n */\nfunction toEventSummary(r: DomainEventRecord) {\n const metadata = (r.metadata ?? undefined) as\n | Record<string, unknown>\n | undefined;\n const rootRunId = metadata?.['rootRunId'];\n return {\n id: r.id,\n type: r.type,\n aggregateId: r.aggregateId,\n aggregateType: r.aggregateType,\n status: r.status,\n pool: r.pool,\n direction: r.direction,\n tier: r.tier,\n rootRunId: typeof rootRunId === 'string' ? rootRunId : null,\n // EVT-8: `tenant_id` is a scaffold-time conditional column. Read it\n // structurally so this projection typechecks against both the\n // multi-tenant schema (column present) and the single-tenant schema\n // (column absent → undefined → null).\n tenantId: (r as { tenantId?: string | null }).tenantId ?? null,\n occurredAt:\n r.occurredAt instanceof Date\n ? r.occurredAt\n : new Date(r.occurredAt as unknown as string),\n processedAt:\n r.processedAt == null\n ? null\n : r.processedAt instanceof Date\n ? r.processedAt\n : new Date(r.processedAt as unknown as string),\n };\n}\n\n@Injectable()\nexport class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit, OnModuleDestroy {\n private readonly logger = new Logger(DrizzleEventBus.name);\n private polling = false;\n private pollTimer: ReturnType<typeof setTimeout> | null = null;\n private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();\n private readonly opts: EventsModuleOptions;\n\n constructor(\n @Inject(DRIZZLE) private readonly db: DrizzleClient,\n @Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,\n /**\n * Bridge subsystem hook (BRIDGE-4). Optional — when the bridge\n * subsystem is not installed in the consuming app, this token is\n * undefined and the drain skips the bridge block entirely (preserves\n * EVT-4 baseline behaviour).\n *\n * When provided, `processEvent` is invoked once per drained event\n * INSIDE the per-event tx, before `processed_at` is stamped. The\n * hook owns all knowledge of `bridge_delivery + wrapper job_run`\n * shapes; the events subsystem stays unaware of bridge schemas.\n */\n @Optional()\n @Inject(BRIDGE_OUTBOX_DRAIN_HOOK)\n private readonly bridgeHook: IBridgeOutboxDrainHook | null = null,\n ) {\n // Default so direct construction (e.g. integration tests not going\n // through Nest DI) keeps working without an explicit options object.\n this.opts = opts ?? { backend: 'drizzle' };\n }\n\n // ============================================================================\n // Lifecycle\n // ============================================================================\n\n async onModuleInit(): Promise<void> {\n this.polling = true;\n this.schedulePoll();\n }\n\n async onModuleDestroy(): Promise<void> {\n this.polling = false;\n if (this.pollTimer) {\n clearTimeout(this.pollTimer);\n this.pollTimer = null;\n }\n }\n\n // ============================================================================\n // IEventBus\n // ============================================================================\n\n async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {\n const client = (tx ?? this.db) as DrizzleClient;\n const multiTenant = this.opts.multiTenant ?? false;\n await client.insert(domainEvents).values(toInsertValues(event, multiTenant));\n }\n\n async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {\n if (events.length === 0) return;\n const client = (tx ?? this.db) as DrizzleClient;\n const multiTenant = this.opts.multiTenant ?? false;\n await client\n .insert(domainEvents)\n .values(events.map((e) => toInsertValues(e, multiTenant)));\n }\n\n async findById(eventId: string): Promise<DomainEvent | null> {\n const rows = await this.db\n .select()\n .from(domainEvents)\n .where(eq(domainEvents.id, eventId))\n .limit(1);\n const row = rows[0];\n if (!row) return null;\n return {\n id: row.id,\n type: row.type,\n aggregateId: row.aggregateId,\n aggregateType: row.aggregateType,\n payload: row.payload as Record<string, unknown>,\n occurredAt:\n row.occurredAt instanceof Date\n ? row.occurredAt\n : new Date(row.occurredAt as unknown as string),\n metadata: (row.metadata ?? undefined) as\n | Record<string, unknown>\n | undefined,\n };\n }\n\n subscribe<T extends DomainEvent = DomainEvent>(\n eventType: string,\n handler: (event: T) => Promise<void>,\n ): () => void {\n if (!this.handlers.has(eventType)) {\n this.handlers.set(eventType, new Set());\n }\n const set = this.handlers.get(eventType)!;\n const h = handler as (event: DomainEvent) => Promise<void>;\n set.add(h);\n return () => {\n set.delete(h);\n };\n }\n\n // ============================================================================\n // IEventReadPort (OBS-LIST-1)\n // ============================================================================\n\n async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {\n const limit = clampEventLimit(query.limit);\n const conditions: SQL<unknown>[] = [];\n\n if (query.poolId) conditions.push(eq(domainEvents.pool, query.poolId));\n if (query.direction)\n conditions.push(eq(domainEvents.direction, query.direction));\n if (query.since) conditions.push(gte(domainEvents.occurredAt, query.since));\n if (query.rootRunId) {\n // Filter on the JSON correlation id: metadata->>'rootRunId'.\n conditions.push(\n sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`,\n );\n }\n // EVT-8: `tenant_id` is a scaffold-time conditional column (emitted only\n // under `events.multi_tenant: true`). Guard the filter behind the same\n // `multiTenant` flag, and read the column structurally so this backend\n // typechecks against both the multi-tenant schema (column present) and\n // the single-tenant schema (column absent). When multi-tenancy is off\n // there is no `tenant_id` column to filter on.\n if (this.opts.multiTenant && query.tenantId !== undefined) {\n const tenantIdColumn = (\n domainEvents as unknown as { tenantId: typeof domainEvents.pool }\n ).tenantId;\n conditions.push(\n query.tenantId === null\n ? (sql`${tenantIdColumn} is null` as SQL<unknown>)\n : eq(tenantIdColumn, query.tenantId),\n );\n }\n\n // Keyset seek: WHERE (occurred_at, id) < (cursorOccurredAt, cursorId).\n if (query.cursor) {\n const keyset = decodeEventCursor(query.cursor);\n if (keyset) {\n conditions.push(\n or(\n lt(domainEvents.occurredAt, keyset.occurredAt),\n and(\n eq(domainEvents.occurredAt, keyset.occurredAt),\n lt(domainEvents.id, keyset.id),\n ),\n )!,\n );\n }\n }\n\n const rows = (await this.db\n .select()\n .from(domainEvents)\n .where(conditions.length > 0 ? and(...conditions) : undefined)\n .orderBy(desc(domainEvents.occurredAt), desc(domainEvents.id))\n .limit(limit + 1)) as DomainEventRecord[];\n\n const hasMore = rows.length > limit;\n const page = hasMore ? rows.slice(0, limit) : rows;\n const items = page.map(toEventSummary);\n const last = page[page.length - 1];\n const nextCursor =\n hasMore && last\n ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })\n : null;\n\n return { items, nextCursor };\n }\n\n // ============================================================================\n // Polling\n // ============================================================================\n\n /**\n * Test-only hook. Runs exactly one drain cycle and returns. Production\n * code goes through `onModuleInit` → `schedulePoll`, which calls the\n * same `processBatch` under a timer.\n */\n async drainOnce(): Promise<void> {\n await this.processBatch();\n }\n\n private schedulePoll(): void {\n if (!this.polling) return;\n this.pollTimer = setTimeout(async () => {\n try {\n await this.processBatch();\n } catch (err) {\n this.logger.error(`Poll cycle error: ${err}`);\n } finally {\n this.schedulePoll();\n }\n }, POLL_INTERVAL_MS);\n }\n\n /**\n * Drain one batch (BRIDGE-4 restructure of EVT-4).\n *\n * Two-phase per drained event:\n *\n * 1. **Per-event transaction** — bridge fanout (`bridgeHook.processEvent`)\n * + `processed_at` stamp. Both write through the same `tx`. A throw\n * inside the tx (only infra-level failures should reach here, since\n * the hook tolerates null direction and registry misses inline)\n * rolls back the bridge inserts AND the `processed_at` stamp; the\n * event re-claims on the next drain cycle. Bridge `UNIQUE\n * (event_id, trigger_id)` makes the retry idempotent.\n *\n * 2. **After commit** — dispatch in-process subscribers (`IEventBus.subscribe`\n * handlers). This deliberately runs OUTSIDE the per-event tx (lead\n * decision 2026-04-22): subscribers are best-effort and must not\n * gate forward progress or roll back bridge fanout. Subscriber\n * errors are caught + logged; `processed_at` is already committed.\n * The old `MAX_RETRIES=3` in-process retry loop and the\n * `failed`-stamping path were removed in BRIDGE-4 along with their\n * coupling.\n *\n * The `processed_at` UPDATE carries `AND status='pending'` (BRIDGE-4\n * tightening — without it, a hypothetical double-claim could double-stamp\n * the timestamp). The per-event tx + `FOR UPDATE SKIP LOCKED` claim\n * make this defensive belt-and-suspenders.\n */\n private async processBatch(): Promise<void> {\n const pools = this.opts.pools;\n\n // Build WHERE: status='pending' [AND pool IN (...)]\n const whereClause: SQL<unknown> = pools && pools.length > 0\n ? (and(eq(domainEvents.status, 'pending'), inArray(domainEvents.pool, pools)) as SQL<unknown>)\n : eq(domainEvents.status, 'pending');\n\n // Claim a batch with FOR UPDATE SKIP LOCKED so multiple pollers don't\n // double-dispatch. The lock is released when the outer transaction\n // commits — which is fine because the immediately-following per-event\n // tx flips status='processed' under its own `AND status='pending'`\n // guard, so a re-claim of the same row in a subsequent batch is a\n // no-op UPDATE.\n const rows = await this.db.transaction(async (tx) => {\n return tx\n .select()\n .from(domainEvents)\n .where(whereClause)\n .orderBy(asc(domainEvents.occurredAt))\n .limit(POLL_BATCH_SIZE)\n .for('update', { skipLocked: true });\n }) as Array<typeof domainEvents.$inferSelect>;\n\n for (const row of rows) {\n const event: DomainEvent = {\n id: row.id,\n type: row.type,\n aggregateId: row.aggregateId,\n aggregateType: row.aggregateType,\n payload: row.payload as Record<string, unknown>,\n occurredAt: row.occurredAt instanceof Date ? row.occurredAt : new Date(row.occurredAt as unknown as string),\n metadata: (row.metadata ?? undefined) as Record<string, unknown> | undefined,\n };\n\n // Phase 1 — per-event tx: bridge fanout + processed_at stamp.\n try {\n await this.db.transaction(async (tx) => {\n if (this.bridgeHook) {\n await this.bridgeHook.processEvent(event, tx);\n }\n await tx\n .update(domainEvents)\n .set({ status: 'processed', processedAt: new Date() })\n .where(\n and(\n eq(domainEvents.id, event.id),\n eq(domainEvents.status, 'pending'),\n ),\n );\n });\n } catch (err) {\n // Infra-level failure inside the per-event tx — bridge inserts\n // and processed_at both rolled back. Log and move on; the next\n // drain cycle re-claims the row. UNIQUE on bridge_delivery makes\n // the retry idempotent.\n this.logger.error(\n `Per-event tx failed for event id=${event.id} type=${event.type}: ${err}`,\n );\n continue;\n }\n\n // Phase 2 — best-effort subscriber dispatch. Errors are logged\n // and discarded; processed_at is already committed. Subscribers\n // are observability + cache-busts + small ancillary work; they\n // must not gate forward progress.\n try {\n await this.dispatch(event);\n } catch (err) {\n this.logger.error(\n `Subscriber dispatch failed for event id=${event.id} type=${event.type} ` +\n `(processed_at already committed; failure does not retry): ${err}`,\n );\n }\n }\n }\n\n private async dispatch(event: DomainEvent): Promise<void> {\n const set = this.handlers.get(event.type);\n if (!set) return;\n\n let firstError: unknown;\n for (const handler of set) {\n try {\n await handler(event);\n } catch (err) {\n this.logger.error(\n `Handler error for event type \"${event.type}\" (id: ${event.id}): ${err}`,\n );\n if (firstError === undefined) {\n firstError = err;\n }\n }\n }\n\n if (firstError !== undefined) {\n throw firstError;\n }\n }\n}\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 } 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 } from './jobs-domain.tokens';\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 ) {}\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 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/events/events.module.ts"],"sourcesContent":["/**\n * EventsModule — DynamicModule factory for the event bus subsystem.\n *\n * Register once in AppModule:\n * ```typescript\n * @Module({\n * imports: [\n * EventsModule.forRoot({ backend: 'drizzle' }),\n * ],\n * })\n * export class AppModule {}\n * ```\n *\n * Tests swap to the memory backend without touching application code:\n * ```typescript\n * Test.createTestingModule({\n * imports: [EventsModule.forRoot({ backend: 'memory' })],\n * });\n * ```\n *\n * Per-pool drain isolation (EVT-4):\n * ```typescript\n * EventsModule.forRoot({ backend: 'drizzle', pools: ['events_change'] });\n * ```\n * Each process restricts its drain loop to the pools listed here. `pools`\n * is undefined by default → drain all pending rows (backwards-compatible).\n *\n * Typed facade + multi-tenancy (EVT-6):\n * - `TYPED_EVENT_BUS` resolves to the generated `TypedEventBus` wrapping\n * whichever backend is selected.\n * - `multiTenant: true` makes `TypedEventBus.publish` throw\n * `MissingTenantIdError` when the caller forgets `metadata.tenantId`.\n *\n * `global: true` means entity modules do not need to import EventsModule\n * individually — the EVENT_BUS and TYPED_EVENT_BUS tokens are available\n * project-wide.\n *\n * Async configuration (`forRootAsync`):\n * The async factory returns `EventsModuleOptions`; the EVENT_BUS provider\n * then receives its backend dependencies — DRIZZLE for the drizzle\n * backend, REDIS_URL for the redis backend, the resolved options for the\n * memory backend — through a proper `useFactory` so Nest DI wires them\n * correctly. Earlier revisions hand-constructed backends with\n * `new Class()` which silently left `db` / `redisUrl` undefined\n * (issue #108).\n */\nimport { Module, type DynamicModule, type Provider, type Type } from '@nestjs/common';\nimport {\n EVENT_BUS,\n EVENT_READ_PORT,\n EVENTS_MODULE_OPTIONS,\n EVENTS_MULTI_TENANT,\n REDIS_URL,\n TYPED_EVENT_BUS,\n} from './events.tokens';\nimport { DRIZZLE } from '../../constants/tokens';\nimport type { DrizzleClient } from '../../types/drizzle';\nimport { DrizzleEventBus } from './event-bus.drizzle-backend';\nimport { MemoryEventBus } from './event-bus.memory-backend';\n// #6 — `RedisEventBus` is lazy-loaded only when `backend: 'redis'` is selected.\n// The file is filtered out of the vendor set for non-redis installs (see\n// `backendFileFilter` in src/cli/commands/subsystem.ts); the dynamic-string\n// import below makes TS treat the specifier as `any` so the consumer's tsc\n// never tries to resolve the absent file.\nimport { TypedEventBus } from './generated/bus';\n\n/**\n * Lazy-load the Redis backend. Routed through a non-literal specifier so\n * the consumer's `tsc` doesn't resolve `./event-bus.redis-backend` at type\n * check time — important because that file is filtered out of drizzle/\n * memory installs (#6).\n */\nasync function loadRedisEventBus(): Promise<new (url: string) => object> {\n // Non-literal specifier — TS gives this an `any` module type, sidestepping\n // resolution of a file that may not be vendored.\n const specifier = './event-bus.redis-backend';\n const mod = (await import(specifier)) as { RedisEventBus: new (url: string) => object };\n return mod.RedisEventBus;\n}\n\nexport interface EventsModuleOptions {\n backend: 'drizzle' | 'memory' | 'redis';\n /**\n * Redis connection URL used when `backend` is `'redis'`.\n * Falls back to the REDIS_URL environment variable, then\n * `redis://localhost:6379` if neither is set.\n */\n redisUrl?: string;\n /**\n * Restrict the drain loop to these pools. Each pool name matches the\n * `domain_events.pool` column (populated from `event.metadata.pool` at\n * publish time). Leave undefined to drain all pending rows.\n *\n * Typical lane split: one process per `events_inbound` /\n * `events_change` / `events_outbound` so a slow outbound handler\n * cannot stall change-event propagation (see ADR-022).\n */\n pools?: string[];\n /**\n * Multi-tenancy opt-in (EVT-6).\n *\n * When `true`, every `TypedEventBus.publish()` call must supply\n * `opts.metadata.tenantId` — otherwise it throws `MissingTenantIdError`.\n * The tenantId is preserved on `event.metadata` and, for the Drizzle\n * backend, written to `domain_events.tenant_id` (EVT-4).\n *\n * Drain-side tenant filtering is deferred — the tenant-context model\n * (per-process vs. per-request vs. async-local-storage) is still\n * unsettled; see ADR-024 §Multi-tenancy. Only the publish-side\n * requirement ships here.\n *\n * Defaults to `false`.\n */\n multiTenant?: boolean;\n /**\n * The generated `TypedEventBus` class to bind to `TYPED_EVENT_BUS`.\n *\n * **Package mode (ADR-037).** When the runtime is imported from\n * `@pattern-stack/codegen` (not vendored), the bundled `./generated/bus`\n * `TypedEventBus` is typed to an EMPTY event union and reads the bundled\n * empty `eventRegistry` — a consumer's `events/*.yaml` are scanned into\n * `src/generated/events/bus.ts` (typed to THEIR union, reading THEIR\n * registry), which the package can't import. The generated subsystem barrel\n * therefore threads that class in here:\n * `EventsModule.forRoot({ ..., typedBus: TypedEventBus })`. Nest constructs\n * it with this module's `EVENT_BUS` + `EVENTS_MULTI_TENANT` providers (the\n * generated class injects the same string-valued tokens, which match across\n * the package boundary).\n *\n * Omitted (vendored mode / tests) ⇒ falls back to the bundled\n * `./generated/bus`, which in a vendored tree IS the consumer's generated\n * file. Without this, a package-mode consumer's typed `publish<'…'>()` calls\n * resolve against the empty union and their events never get the right\n * `pool` / `direction` stamped.\n *\n * Only consulted by `forRoot` (the path the barrel emits); `forRootAsync`\n * keeps the bundled bus.\n */\n typedBus?: Type<unknown>;\n}\n\nexport interface EventsModuleAsyncOptions {\n useFactory: (...args: unknown[]) => Promise<EventsModuleOptions> | EventsModuleOptions;\n inject?: unknown[];\n imports?: unknown[];\n}\n\n/**\n * Shared provider set: `TypedEventBus` itself, the `TYPED_EVENT_BUS` token\n * binding, and the resolved `EVENTS_MULTI_TENANT` flag. Returned from one\n * place so every `forRoot` branch and `forRootAsync` agree.\n */\nfunction buildTypedBusProviders(\n multiTenant: boolean,\n typedBus?: Type<unknown>,\n): Provider[] {\n // Package mode threads the consumer's generated `TypedEventBus` (typed to\n // their event union, reading their registry) via `typedBus`; vendored mode\n // omits it and we fall back to the bundled `./generated/bus` (which IS the\n // consumer's generated file in a vendored tree). See `EventsModuleOptions.typedBus`.\n const BusClass = typedBus ?? TypedEventBus;\n return [\n BusClass,\n { provide: TYPED_EVENT_BUS, useExisting: BusClass },\n { provide: EVENTS_MULTI_TENANT, useValue: multiTenant },\n ];\n}\n\n/**\n * Construct the backend instance for the async path, routing constructor\n * arguments through Nest-resolved dependencies.\n *\n * DRIZZLE is declared optional at inject time so that memory-backend\n * consumers aren't required to also import `DatabaseModule`. If the\n * drizzle backend is selected but no DRIZZLE provider is registered, we\n * throw a clear error instead of silently constructing a broken bus.\n */\nasync function buildEventBusAsync(\n options: EventsModuleOptions,\n db: DrizzleClient | null,\n redisUrl: string,\n): Promise<unknown> {\n if (options.backend === 'drizzle') {\n if (!db) {\n throw new Error(\n \"EventsModule.forRootAsync: backend: 'drizzle' selected but DRIZZLE provider is not available. \" +\n 'Ensure DatabaseModule (or another provider exposing DRIZZLE) is imported before EventsModule.forRootAsync.',\n );\n }\n return new DrizzleEventBus(db, options);\n }\n if (options.backend === 'redis') {\n // #6: lazy import — the redis backend ships only with `--backend redis`\n // installs; drizzle/memory consumers never touch the file.\n const RedisEventBus = await loadRedisEventBus();\n return new RedisEventBus(redisUrl);\n }\n return new MemoryEventBus(options);\n}\n\n@Module({})\nexport class EventsModule {\n static forRootAsync(asyncOptions: EventsModuleAsyncOptions): DynamicModule {\n return {\n module: EventsModule,\n global: true,\n imports: (asyncOptions.imports ?? []) as Parameters<typeof Module>[0]['imports'],\n providers: [\n {\n provide: EVENTS_MODULE_OPTIONS,\n useFactory: asyncOptions.useFactory,\n inject: (asyncOptions.inject ?? []) as (string | symbol | Function)[],\n },\n {\n provide: EVENTS_MULTI_TENANT,\n useFactory: (options: EventsModuleOptions) => options.multiTenant ?? false,\n inject: [EVENTS_MODULE_OPTIONS],\n },\n {\n provide: REDIS_URL,\n useFactory: (options: EventsModuleOptions) =>\n options.redisUrl ?? process.env['REDIS_URL'] ?? 'redis://localhost:6379',\n inject: [EVENTS_MODULE_OPTIONS],\n },\n {\n provide: EVENT_BUS,\n useFactory: (\n options: EventsModuleOptions,\n db: DrizzleClient | null,\n redisUrl: string,\n ) => buildEventBusAsync(options, db, redisUrl),\n inject: [\n EVENTS_MODULE_OPTIONS,\n { token: DRIZZLE, optional: true },\n REDIS_URL,\n ],\n },\n {\n // Read port (OBS-LIST-1). Drizzle + memory backends implement\n // IEventReadPort on the EVENT_BUS instance; the redis backend\n // retains no history, so EVENT_READ_PORT resolves to `null` and\n // optional consumers (the observability combiner) degrade to\n // empty results.\n provide: EVENT_READ_PORT,\n useFactory: (options: EventsModuleOptions, bus: unknown) =>\n options.backend === 'redis' ? null : bus,\n inject: [EVENTS_MODULE_OPTIONS, EVENT_BUS],\n },\n TypedEventBus,\n { provide: TYPED_EVENT_BUS, useExisting: TypedEventBus },\n ],\n exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],\n };\n }\n\n static forRoot(\n options: EventsModuleOptions = { backend: 'drizzle' },\n ): DynamicModule {\n const multiTenant = options.multiTenant ?? false;\n\n if (options.backend === 'redis') {\n const resolvedUrl =\n options.redisUrl ?? process.env['REDIS_URL'] ?? 'redis://localhost:6379';\n\n return {\n module: EventsModule,\n global: true,\n providers: [\n { provide: EVENTS_MODULE_OPTIONS, useValue: options },\n { provide: REDIS_URL, useValue: resolvedUrl },\n {\n // #6: useFactory + dynamic import so the consumer's tsc never\n // needs to resolve `event-bus.redis-backend.ts` for drizzle/\n // memory installs (the file is filtered out by\n // `backendFileFilter`). Nest awaits async factories + manages\n // lifecycle on the returned instance, so we drop the old bare\n // `RedisEventBus` provider entry.\n provide: EVENT_BUS,\n useFactory: async (url: string): Promise<object> => {\n const RedisEventBus = await loadRedisEventBus();\n return new RedisEventBus(url);\n },\n inject: [REDIS_URL],\n },\n ...buildTypedBusProviders(multiTenant, options.typedBus),\n ],\n exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],\n };\n }\n\n const provider =\n options.backend === 'drizzle'\n ? { provide: EVENT_BUS, useClass: DrizzleEventBus }\n : { provide: EVENT_BUS, useClass: MemoryEventBus };\n\n return {\n module: EventsModule,\n global: true,\n providers: [\n { provide: EVENTS_MODULE_OPTIONS, useValue: options },\n provider,\n // Read port (OBS-LIST-1): drizzle + memory backends implement\n // IEventReadPort on the same instance as EVENT_BUS. The redis\n // backend retains no history and does not provide this token.\n { provide: EVENT_READ_PORT, useExisting: EVENT_BUS },\n ...buildTypedBusProviders(multiTenant, options.typedBus),\n ],\n exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],\n };\n }\n}\n"],"mappings":"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","names":[]}