@pattern-stack/codegen 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{chunk-WEVWJKOW.js → chunk-24WXSC3C.js} +10 -10
  3. package/dist/{chunk-KMZCQASO.js → chunk-3RWMQC3K.js} +18 -12
  4. package/dist/chunk-3RWMQC3K.js.map +1 -0
  5. package/dist/{chunk-24CWKBK5.js → chunk-4MF3HKJA.js} +3 -3
  6. package/dist/chunk-4MF3HKJA.js.map +1 -0
  7. package/dist/{chunk-4JLJYWJC.js → chunk-4PFF3ED4.js} +98 -10
  8. package/dist/chunk-4PFF3ED4.js.map +1 -0
  9. package/dist/{chunk-32BMMV4H.js → chunk-5RT7JGKT.js} +5 -5
  10. package/dist/{chunk-JRVNVKN6.js → chunk-BHZP6LOV.js} +2 -2
  11. package/dist/{chunk-4MVGAMUA.js → chunk-BK5ICA2F.js} +4 -4
  12. package/dist/{chunk-4RFHUZXU.js → chunk-BULPAAD3.js} +2 -2
  13. package/dist/{chunk-OZZJDRGW.js → chunk-CEWLVVAH.js} +10 -10
  14. package/dist/{chunk-YTN6BKWA.js → chunk-DRCLNYH7.js} +7 -7
  15. package/dist/{chunk-TNXH7BJS.js → chunk-E45CSC33.js} +2 -2
  16. package/dist/{chunk-L7BNNRGI.js → chunk-EBKVKN75.js} +26 -6
  17. package/dist/chunk-EBKVKN75.js.map +1 -0
  18. package/dist/{chunk-EOLLMEAH.js → chunk-EJBK7I4F.js} +3 -3
  19. package/dist/chunk-EJBK7I4F.js.map +1 -0
  20. package/dist/{chunk-K2I6XIK5.js → chunk-KSTZIULO.js} +4 -4
  21. package/dist/{chunk-Z7PQCAVK.js → chunk-LQ6PYFU6.js} +4 -4
  22. package/dist/chunk-MYQIQ27N.js +118 -0
  23. package/dist/chunk-MYQIQ27N.js.map +1 -0
  24. package/dist/{chunk-5Y7W3XR6.js → chunk-OTR44OH6.js} +24 -5
  25. package/dist/chunk-OTR44OH6.js.map +1 -0
  26. package/dist/{chunk-WPXNN6QS.js → chunk-RUYLXR5F.js} +11 -8
  27. package/dist/chunk-RUYLXR5F.js.map +1 -0
  28. package/dist/{chunk-7LKAMLV4.js → chunk-T6SCOJF4.js} +4 -4
  29. package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
  30. package/dist/{chunk-I6MG4M3F.js → chunk-VNBC3VXM.js} +2 -2
  31. package/dist/{chunk-YPWODKD5.js → chunk-W2UIDI3R.js} +5 -5
  32. package/dist/chunk-W4HOHZVF.js +1 -0
  33. package/dist/{chunk-WRUUSZDJ.js → chunk-WWGYCIJX.js} +3 -3
  34. package/dist/{chunk-RC23QROE.js → chunk-XDIIVIIK.js} +79 -5
  35. package/dist/chunk-XDIIVIIK.js.map +1 -0
  36. package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
  37. package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
  38. package/dist/{chunk-4OMHBMZJ.js → chunk-YLPAPPLW.js} +3 -3
  39. package/dist/chunk-YLPAPPLW.js.map +1 -0
  40. package/dist/{chunk-BIO6F7YI.js → chunk-ZPL74UQN.js} +4 -2
  41. package/dist/{chunk-BIO6F7YI.js.map → chunk-ZPL74UQN.js.map} +1 -1
  42. package/dist/runtime/base-classes/index.js +15 -15
  43. package/dist/runtime/shared/openapi/index.js +3 -3
  44. package/dist/runtime/subsystems/auth/auth.module.js +3 -3
  45. package/dist/runtime/subsystems/auth/index.js +7 -7
  46. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  47. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +3 -3
  48. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.d.ts +2 -1
  49. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +7 -6
  50. package/dist/runtime/subsystems/bridge/bridge.module.js +17 -16
  51. package/dist/runtime/subsystems/bridge/event-flow.service.js +3 -3
  52. package/dist/runtime/subsystems/bridge/index.js +24 -23
  53. package/dist/runtime/subsystems/cache/cache.module.js +1 -1
  54. package/dist/runtime/subsystems/cache/index.js +3 -3
  55. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +20 -0
  56. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -3
  57. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  58. package/dist/runtime/subsystems/events/events.module.d.ts +14 -0
  59. package/dist/runtime/subsystems/events/events.module.js +6 -5
  60. package/dist/runtime/subsystems/events/index.js +9 -8
  61. package/dist/runtime/subsystems/index.d.ts +5 -1
  62. package/dist/runtime/subsystems/index.js +108 -96
  63. package/dist/runtime/subsystems/integration/build-change-source.js +3 -3
  64. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  65. package/dist/runtime/subsystems/integration/index.js +35 -35
  66. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  67. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  68. package/dist/runtime/subsystems/integration/integration.module.js +5 -5
  69. package/dist/runtime/subsystems/integration/poll-change-source.d.ts +1 -1
  70. package/dist/runtime/subsystems/integration/poll-change-source.js +1 -1
  71. package/dist/runtime/subsystems/integration/webhook-change-source.d.ts +4 -3
  72. package/dist/runtime/subsystems/integration/webhook-change-source.js +1 -1
  73. package/dist/runtime/subsystems/jobs/index.d.ts +2 -1
  74. package/dist/runtime/subsystems/jobs/index.js +30 -18
  75. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +3 -2
  76. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  77. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +2 -1
  78. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +3 -2
  79. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
  80. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +2 -2
  81. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  82. package/dist/runtime/subsystems/jobs/job-worker.d.ts +28 -0
  83. package/dist/runtime/subsystems/jobs/job-worker.js +3 -2
  84. package/dist/runtime/subsystems/jobs/job-worker.module.js +9 -8
  85. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +12 -7
  86. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +7 -6
  87. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.d.ts +13 -1
  88. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.js +3 -1
  89. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +85 -0
  90. package/dist/runtime/subsystems/jobs/pg-notify.js +14 -0
  91. package/dist/runtime/subsystems/jobs/pg-notify.js.map +1 -0
  92. package/dist/runtime/subsystems/observability/index.js +7 -7
  93. package/dist/runtime/subsystems/observability/observability.module.js +4 -4
  94. package/dist/runtime/subsystems/observability/observability.service.js +3 -3
  95. package/dist/runtime/subsystems/storage/index.js +4 -4
  96. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  97. package/dist/src/cli/index.js +57 -19
  98. package/dist/src/cli/index.js.map +1 -1
  99. package/dist/src/index.js +11 -11
  100. package/package.json +1 -1
  101. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +27 -0
  102. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +108 -4
  103. package/runtime/subsystems/events/events.module.ts +14 -0
  104. package/runtime/subsystems/index.ts +27 -0
  105. package/runtime/subsystems/integration/poll-change-source.ts +10 -7
  106. package/runtime/subsystems/integration/webhook-change-source.ts +12 -8
  107. package/runtime/subsystems/jobs/index.ts +10 -0
  108. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +29 -2
  109. package/runtime/subsystems/jobs/job-worker.module.ts +11 -0
  110. package/runtime/subsystems/jobs/job-worker.ts +98 -0
  111. package/runtime/subsystems/jobs/jobs-domain.module.ts +22 -7
  112. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +13 -0
  113. package/runtime/subsystems/jobs/pg-notify.ts +216 -0
  114. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +14 -0
  115. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +13 -4
  116. package/dist/chunk-24CWKBK5.js.map +0 -1
  117. package/dist/chunk-4JLJYWJC.js.map +0 -1
  118. package/dist/chunk-4OMHBMZJ.js.map +0 -1
  119. package/dist/chunk-5Y7W3XR6.js.map +0 -1
  120. package/dist/chunk-EOLLMEAH.js.map +0 -1
  121. package/dist/chunk-KMZCQASO.js.map +0 -1
  122. package/dist/chunk-L7BNNRGI.js.map +0 -1
  123. package/dist/chunk-RC23QROE.js.map +0 -1
  124. package/dist/chunk-UTN4GBPQ.js +0 -1
  125. package/dist/chunk-WPXNN6QS.js.map +0 -1
  126. /package/dist/{chunk-WEVWJKOW.js.map → chunk-24WXSC3C.js.map} +0 -0
  127. /package/dist/{chunk-32BMMV4H.js.map → chunk-5RT7JGKT.js.map} +0 -0
  128. /package/dist/{chunk-JRVNVKN6.js.map → chunk-BHZP6LOV.js.map} +0 -0
  129. /package/dist/{chunk-4MVGAMUA.js.map → chunk-BK5ICA2F.js.map} +0 -0
  130. /package/dist/{chunk-4RFHUZXU.js.map → chunk-BULPAAD3.js.map} +0 -0
  131. /package/dist/{chunk-OZZJDRGW.js.map → chunk-CEWLVVAH.js.map} +0 -0
  132. /package/dist/{chunk-YTN6BKWA.js.map → chunk-DRCLNYH7.js.map} +0 -0
  133. /package/dist/{chunk-TNXH7BJS.js.map → chunk-E45CSC33.js.map} +0 -0
  134. /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
  135. /package/dist/{chunk-Z7PQCAVK.js.map → chunk-LQ6PYFU6.js.map} +0 -0
  136. /package/dist/{chunk-7LKAMLV4.js.map → chunk-T6SCOJF4.js.map} +0 -0
  137. /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
  138. /package/dist/{chunk-I6MG4M3F.js.map → chunk-VNBC3VXM.js.map} +0 -0
  139. /package/dist/{chunk-YPWODKD5.js.map → chunk-W2UIDI3R.js.map} +0 -0
  140. /package/dist/{chunk-UTN4GBPQ.js.map → chunk-W4HOHZVF.js.map} +0 -0
  141. /package/dist/{chunk-WRUUSZDJ.js.map → chunk-WWGYCIJX.js.map} +0 -0
  142. /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
  143. /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/poll-change-source.ts"],"sourcesContent":["/**\n * Integration subsystem — `PollChangeSource<T>` primitive (#226-3, ADR-033).\n *\n * Generic poll-mode `IChangeSource<T>` implementation parameterized by a\n * parsed `DetectionConfig` (poll mode) and a consumer-supplied\n * `PollFetchCallback<T>`. The primitive owns:\n *\n * - filter resolution (flat-AND vocabulary per epic decision Q3 — richer\n * boolean expressions deferred);\n * - field mapping → `Change.externalId` derivation;\n * - cursor strategy passthrough (the orchestrator passes the prior cursor\n * by-value per ADR-033 / #226-2; the callback yields the next cursor\n * per record; the primitive simply stamps it onto `Change<T>`);\n * - `Change<T>.source` provenance (`'poll'` by default);\n * - middleware-chain composition (the `ChangeMiddleware<T>` shape\n * locked in #226-1).\n *\n * Shape locks (decision memo Q5):\n * - `PollFetchContext = { subscription, cursor, filters }` — explicitly\n * NO `userId` / `tenantId`. Run-scope identity is closed over by the\n * consumer at adapter construction (or resolved inside the callback\n * via consumer services). Threading it through the seam would force\n * port expansion every time run-context grows.\n *\n * The adapter callback returns `{ record: T; cursor: PollCursor }` — the\n * primitive does not reach into the record to extract a cursor itself.\n * `cursor.field` from `DetectionConfig.poll.cursor` is metadata for codegen\n * + adapters; the primitive trusts what the callback yielded.\n */\n\nimport type {\n DetectionConfig,\n ResolvedFilter,\n} from './detection-config.schema';\nimport type {\n Change,\n ChangeSource,\n IChangeSource,\n IntegrationSubscriptionView,\n} from './integration-change-source.protocol';\nimport type {\n ChangeIterator,\n ChangeMiddleware,\n} from './integration-middleware.protocol';\n\n// ============================================================================\n// Cursor + adapter callback shapes\n// ============================================================================\n\n/**\n * Opaque poll-cursor shape. Each provider/entity pair binds it concretely\n * via the cursor strategy (`{ systemModstamp }`, `{ replayId }`, etc.); the\n * primitive treats it as an opaque value to pass through.\n */\nexport type PollCursor = unknown;\n\n/**\n * The context the primitive forwards to the adapter callback. Locked to\n * exactly three fields per decision memo Q5 — `userId` / `tenantId` are\n * NOT here on purpose.\n */\nexport interface PollFetchContext {\n readonly subscription: IntegrationSubscriptionView;\n readonly cursor: PollCursor | null;\n readonly filters: readonly ResolvedFilter[];\n}\n\n/**\n * Consumer-supplied fetch callback. Returns an async iterable of\n * `{ record, cursor }` pairs — `record` is already the canonical `T`\n * (the adapter does provider-side translation), `cursor` is the post-record\n * cursor the orchestrator should persist if the run completes successfully.\n */\nexport type PollFetchCallback<T> = (\n ctx: PollFetchContext,\n) => AsyncIterable<{ record: T; cursor: PollCursor }>;\n\n// ============================================================================\n// Constructor options\n// ============================================================================\n\nexport interface PollChangeSourceOptions<T> {\n /** Consumer-supplied fetch callback. */\n readonly adapter: PollFetchCallback<T>;\n /**\n * Parsed detection config. MUST be `mode: 'poll'`; the constructor\n * throws if a webhook config is supplied. Codegen-emitted factories\n * call `DetectionConfigSchema.parse(...)` upstream so this is a safety\n * net, not the primary validation point.\n */\n readonly config: DetectionConfig;\n /**\n * Optional middleware chain. First element is the outermost layer:\n * sees `(subscription, cursor)` first and yielded `Change<T>` last.\n * Locked shape (#226-1) — the primitive composes them with its own\n * `listChanges` implementation as the innermost iterator.\n */\n readonly middlewares?: ReadonlyArray<ChangeMiddleware<T>>;\n /**\n * Optional human label for run logs (e.g. `'salesforce-poll-opportunity'`).\n * Defaults to a derived label based on the subscription domain at\n * construction time fallback — adapters are encouraged to provide one.\n */\n readonly label?: string;\n}\n\n// ============================================================================\n// PollChangeSource<T>\n// ============================================================================\n\nexport class PollChangeSource<T> implements IChangeSource<T> {\n public readonly label: string;\n\n private readonly adapter: PollFetchCallback<T>;\n private readonly filters: readonly ResolvedFilter[];\n private readonly externalIdSourceField: string;\n private readonly source: ChangeSource;\n /**\n * When `poll.provenance === 'cdc'`, the field on the emitted record from\n * which `Change<T>.dedupKey` is read — sourced from `poll.cursor.field`\n * (Stripe-style event endpoints carry the event id on the record itself\n * and the cursor advances over the same id). `null` for default poll\n * provenance, which does NOT populate `dedupKey`.\n */\n private readonly dedupKeySourceField: string | null;\n private readonly composed: ChangeIterator<T>;\n\n constructor(opts: PollChangeSourceOptions<T>) {\n if (opts.config.mode !== 'poll') {\n throw new Error(\n `PollChangeSource requires DetectionConfig.mode === 'poll'; got '${(opts.config as { mode: string }).mode}'`,\n );\n }\n const config = opts.config;\n\n // Field mapping: locate the canonical `external_id` target. Adapters\n // emit T already-mapped, but the primitive needs to know which key on\n // T carries the external id so it can stamp `Change.externalId`. Source\n // of truth is the mapping table — codegen emits it from YAML, the\n // primitive reads it here.\n const externalIdMapping = config.mapping.find(\n (m) => m.target === 'external_id',\n );\n if (!externalIdMapping) {\n throw new Error(\n \"PollChangeSource: DetectionConfig.mapping must include an entry with target 'external_id' so emitted Change<T>.externalId can be populated\",\n );\n }\n this.externalIdSourceField = externalIdMapping.target;\n\n this.adapter = opts.adapter;\n this.filters = config.filters;\n // Provenance: `mode: 'poll'` defaults to `'poll'`; opt into `'cdc'` via\n // `poll.provenance` (Stripe-style event endpoints — wired in #226-4).\n // CDC provenance also stamps `dedupKey` from the cursor's `field`, since\n // those endpoints surface a per-event id on each record (the same id the\n // cursor advances over).\n const isCdc = config.poll.provenance === 'cdc';\n this.source = isCdc ? 'cdc' : 'poll';\n this.dedupKeySourceField = isCdc ? config.poll.cursor.field : null;\n\n this.label =\n opts.label ?? `poll-change-source:${externalIdMapping.source}`;\n\n // Compose middleware chain. The terminal iterator is `this.fetch`\n // bound to `this`. First middleware in the array is the outermost\n // layer (sees subscription/cursor first, yielded changes last).\n const inner: ChangeIterator<T> = (sub, cur) => this.fetch(sub, cur);\n const middlewares = opts.middlewares ?? [];\n this.composed = middlewares.reduceRight<ChangeIterator<T>>(\n (next, mw) => mw(next),\n inner,\n );\n }\n\n listChanges(\n subscription: IntegrationSubscriptionView,\n cursor: unknown | null,\n ): AsyncIterable<Change<T>> {\n return this.composed(subscription, cursor);\n }\n\n private async *fetch(\n subscription: IntegrationSubscriptionView,\n cursor: unknown | null,\n ): AsyncIterable<Change<T>> {\n const ctx: PollFetchContext = {\n subscription,\n cursor: cursor as PollCursor | null,\n filters: this.filters,\n };\n\n for await (const { record, cursor: nextCursor } of this.adapter(ctx)) {\n const externalIdRaw = (record as Record<string, unknown>)[\n this.externalIdSourceField\n ];\n if (typeof externalIdRaw !== 'string' || externalIdRaw.length === 0) {\n throw new Error(\n `PollChangeSource: record missing string '${this.externalIdSourceField}' — emitted records MUST carry the canonical external id keyed by the mapping target`,\n );\n }\n let dedupKey: string | undefined;\n if (this.dedupKeySourceField !== null) {\n const dedupRaw = (record as Record<string, unknown>)[\n this.dedupKeySourceField\n ];\n if (typeof dedupRaw !== 'string' || dedupRaw.length === 0) {\n throw new Error(\n `PollChangeSource: cdc-provenance record missing string '${this.dedupKeySourceField}' — when poll.provenance === 'cdc' the cursor.field must be present on each record so dedupKey can be populated`,\n );\n }\n dedupKey = dedupRaw;\n }\n\n const change: Change<T> = {\n externalId: externalIdRaw,\n // Polling cannot distinguish create vs. update vs. delete on its\n // own — all yielded records are surfaced as 'updated'. The\n // orchestrator's diff stage classifies create-vs-update against\n // local state; soft-delete detection is out of scope for the\n // primitive (consumer drives via tombstone records or a separate\n // sweep — see ADR-033).\n operation: 'updated',\n record,\n cursor: nextCursor,\n source: this.source,\n ...(dedupKey !== undefined ? { dedupKey } : {}),\n };\n yield change;\n }\n }\n}\n"],"mappings":";AA8GO,IAAM,mBAAN,MAAsD;AAAA,EAC3C;AAAA,EAEC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAkC;AAC5C,QAAI,KAAK,OAAO,SAAS,QAAQ;AAC/B,YAAM,IAAI;AAAA,QACR,mEAAoE,KAAK,OAA4B,IAAI;AAAA,MAC3G;AAAA,IACF;AACA,UAAM,SAAS,KAAK;AAOpB,UAAM,oBAAoB,OAAO,QAAQ;AAAA,MACvC,CAAC,MAAM,EAAE,WAAW;AAAA,IACtB;AACA,QAAI,CAAC,mBAAmB;AACtB,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,SAAK,wBAAwB,kBAAkB;AAE/C,SAAK,UAAU,KAAK;AACpB,SAAK,UAAU,OAAO;AAMtB,UAAM,QAAQ,OAAO,KAAK,eAAe;AACzC,SAAK,SAAS,QAAQ,QAAQ;AAC9B,SAAK,sBAAsB,QAAQ,OAAO,KAAK,OAAO,QAAQ;AAE9D,SAAK,QACH,KAAK,SAAS,sBAAsB,kBAAkB,MAAM;AAK9D,UAAM,QAA2B,CAAC,KAAK,QAAQ,KAAK,MAAM,KAAK,GAAG;AAClE,UAAM,cAAc,KAAK,eAAe,CAAC;AACzC,SAAK,WAAW,YAAY;AAAA,MAC1B,CAAC,MAAM,OAAO,GAAG,IAAI;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AAAA,EAEA,YACE,cACA,QAC0B;AAC1B,WAAO,KAAK,SAAS,cAAc,MAAM;AAAA,EAC3C;AAAA,EAEA,OAAe,MACb,cACA,QAC0B;AAC1B,UAAM,MAAwB;AAAA,MAC5B;AAAA,MACA;AAAA,MACA,SAAS,KAAK;AAAA,IAChB;AAEA,qBAAiB,EAAE,QAAQ,QAAQ,WAAW,KAAK,KAAK,QAAQ,GAAG,GAAG;AACpE,YAAM,gBAAiB,OACrB,KAAK,qBACP;AACA,UAAI,OAAO,kBAAkB,YAAY,cAAc,WAAW,GAAG;AACnE,cAAM,IAAI;AAAA,UACR,4CAA4C,KAAK,qBAAqB;AAAA,QACxE;AAAA,MACF;AACA,UAAI;AACJ,UAAI,KAAK,wBAAwB,MAAM;AACrC,cAAM,WAAY,OAChB,KAAK,mBACP;AACA,YAAI,OAAO,aAAa,YAAY,SAAS,WAAW,GAAG;AACzD,gBAAM,IAAI;AAAA,YACR,2DAA2D,KAAK,mBAAmB;AAAA,UACrF;AAAA,QACF;AACA,mBAAW;AAAA,MACb;AAEA,YAAM,SAAoB;AAAA,QACxB,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAOZ,WAAW;AAAA,QACX;AAAA,QACA,QAAQ;AAAA,QACR,QAAQ,KAAK;AAAA,QACb,GAAI,aAAa,SAAY,EAAE,SAAS,IAAI,CAAC;AAAA,MAC/C;AACA,YAAM;AAAA,IACR;AAAA,EACF;AACF;","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/integration/webhook-change-source.ts"],"sourcesContent":["/**\n * Integration subsystem — `WebhookChangeSource<T>` primitive (#226-4, ADR-033).\n *\n * Generic webhook-mode `IChangeSource<T>` implementation parameterized by a\n * parsed `DetectionConfig` (webhook mode) and a consumer-supplied\n * `WebhookFetchCallback<T>` that iterates a consumer-owned inbound staging\n * queue. The primitive owns:\n *\n * - canonical `Change<T>.source = 'webhook'` stamping;\n * - `dedupKey` derivation from the configured `webhook.eventIdField` on\n * the emitted record;\n * - `externalId` derivation from the mapping table's `external_id` target\n * (mirrors `PollChangeSource`);\n * - middleware-chain composition via the locked `ChangeMiddleware<T>` shape\n * (#226-1) — same composition seam as the poll primitive.\n *\n * The primitive is **passive**: it iterates whatever the consumer-owned\n * queue yields. It does NOT synchronously drive the orchestrator, does NOT\n * own a transport, and does NOT manage acks. The inbound staging table\n * schema is consumer-owned and deferred per ADR-0002 §Phase 4 — the\n * `WebhookFetchCallback<T>` is the queue contract the consumer injects.\n *\n * Shape locks (decision memo Q5, mirrored from poll primitive):\n * - `WebhookFetchContext = { subscription, cursor }` — explicitly NO\n * `userId` / `tenantId`. Run-scope identity is closed over by the\n * consumer at queue construction or resolved inside the callback via\n * consumer services. There are no `filters` on the webhook context —\n * filtering is done at registration / on the staging row, not at the\n * port seam (the queue is already filtered by the time the primitive\n * iterates).\n *\n * Long-lived streaming CDC primitives (SFDC Pub-Sub gRPC, Debezium/Kafka,\n * Postgres logical replication) are deferred to `#226-8` — they need a\n * fundamentally different lifecycle (`subscribe(onChange, onError)`,\n * server-paced backpressure, ack-on-yield) and shouldn't be retrofitted\n * into either this primitive or the poll primitive.\n */\n\nimport type { DetectionConfig } from './detection-config.schema';\nimport type {\n Change,\n IChangeSource,\n IntegrationSubscriptionView,\n} from './integration-change-source.protocol';\nimport type {\n ChangeIterator,\n ChangeMiddleware,\n} from './integration-middleware.protocol';\n\n// ============================================================================\n// Cursor + queue callback shapes\n// ============================================================================\n\n/**\n * Opaque webhook cursor shape. Webhook mode typically has a cursor of\n * `{ ts: ISO-string }` (last drained staging-row timestamp) but the\n * primitive treats it as opaque. Consumer-owned queue iterators interpret\n * it however the staging schema needs.\n */\nexport type WebhookCursor = unknown;\n\n/**\n * Context the primitive forwards to the queue iterator. Locked to exactly\n * two fields per the same Q5 reasoning that locks `PollFetchContext` — no\n * `userId` / `tenantId`.\n */\nexport interface WebhookFetchContext {\n readonly subscription: IntegrationSubscriptionView;\n readonly cursor: WebhookCursor | null;\n}\n\n/**\n * Consumer-supplied queue iterator. Returns an async iterable of\n * `{ record }` pairs — the consumer drains the inbound staging queue and\n * emits already-mapped canonical records `T`. The primitive stamps\n * `source: 'webhook'` and `dedupKey` from the record's configured\n * `webhook.eventIdField`; the consumer is the one who decided when a\n * staging row is \"ready\" to drain.\n *\n * Webhook mode has no per-record cursor advance — the staging-row drain\n * order is consumer policy (FIFO by ingestion timestamp, by event id, etc.)\n * and is opaque to the primitive. The orchestrator's last-yielded cursor\n * is whatever the consumer chooses to surface, if anything.\n */\nexport type WebhookFetchCallback<T> = (\n ctx: WebhookFetchContext,\n) => AsyncIterable<{ record: T; cursor?: WebhookCursor }>;\n\n// ============================================================================\n// Constructor options\n// ============================================================================\n\nexport interface WebhookChangeSourceOptions<T> {\n /** Consumer-supplied inbound queue iterator. */\n readonly queue: WebhookFetchCallback<T>;\n /**\n * Parsed detection config. MUST be `mode: 'webhook'`; the constructor\n * throws if a poll config is supplied. Codegen-emitted factories call\n * `DetectionConfigSchema.parse(...)` upstream so this is a safety net,\n * not the primary validation point.\n */\n readonly config: DetectionConfig;\n /**\n * Optional middleware chain. Same shape and composition rules as\n * `PollChangeSource` — first element is the outermost layer.\n */\n readonly middlewares?: ReadonlyArray<ChangeMiddleware<T>>;\n /**\n * Optional human label for run logs (e.g. `'stripe-webhook-charge'`).\n * Defaults to a derived label based on the mapping at construction.\n */\n readonly label?: string;\n}\n\n// ============================================================================\n// WebhookChangeSource<T>\n// ============================================================================\n\nexport class WebhookChangeSource<T> implements IChangeSource<T> {\n public readonly label: string;\n\n private readonly queue: WebhookFetchCallback<T>;\n private readonly externalIdSourceField: string;\n private readonly eventIdSourceField: string;\n private readonly composed: ChangeIterator<T>;\n\n constructor(opts: WebhookChangeSourceOptions<T>) {\n if (opts.config.mode !== 'webhook') {\n throw new Error(\n `WebhookChangeSource requires DetectionConfig.mode === 'webhook'; got '${(opts.config as { mode: string }).mode}'`,\n );\n }\n const config = opts.config;\n\n // Field mapping: locate the canonical `external_id` target — mirrors the\n // poll primitive's contract. Adapters emit records already-mapped; the\n // primitive needs to know which key on T carries the external id so it\n // can stamp `Change.externalId`.\n const externalIdMapping = config.mapping.find(\n (m) => m.target === 'external_id',\n );\n if (!externalIdMapping) {\n throw new Error(\n \"WebhookChangeSource: DetectionConfig.mapping must include an entry with target 'external_id' so emitted Change<T>.externalId can be populated\",\n );\n }\n this.externalIdSourceField = externalIdMapping.target;\n this.eventIdSourceField = config.webhook.eventIdField;\n\n this.queue = opts.queue;\n\n this.label =\n opts.label ?? `webhook-change-source:${externalIdMapping.source}`;\n\n // Compose middleware chain — same shape as PollChangeSource.\n const inner: ChangeIterator<T> = (sub, cur) => this.fetch(sub, cur);\n const middlewares = opts.middlewares ?? [];\n this.composed = middlewares.reduceRight<ChangeIterator<T>>(\n (next, mw) => mw(next),\n inner,\n );\n }\n\n listChanges(\n subscription: IntegrationSubscriptionView,\n cursor: unknown | null,\n ): AsyncIterable<Change<T>> {\n return this.composed(subscription, cursor);\n }\n\n private async *fetch(\n subscription: IntegrationSubscriptionView,\n cursor: unknown | null,\n ): AsyncIterable<Change<T>> {\n const ctx: WebhookFetchContext = {\n subscription,\n cursor: cursor as WebhookCursor | null,\n };\n\n for await (const { record, cursor: nextCursor } of this.queue(ctx)) {\n const externalIdRaw = (record as Record<string, unknown>)[\n this.externalIdSourceField\n ];\n if (typeof externalIdRaw !== 'string' || externalIdRaw.length === 0) {\n throw new Error(\n `WebhookChangeSource: record missing string '${this.externalIdSourceField}' — emitted records MUST carry the canonical external id keyed by the mapping target`,\n );\n }\n const eventIdRaw = (record as Record<string, unknown>)[\n this.eventIdSourceField\n ];\n if (typeof eventIdRaw !== 'string' || eventIdRaw.length === 0) {\n throw new Error(\n `WebhookChangeSource: record missing string '${this.eventIdSourceField}' — webhook records MUST carry the event id (DetectionConfig.webhook.eventIdField) so Change<T>.dedupKey can be populated`,\n );\n }\n\n const change: Change<T> = {\n externalId: externalIdRaw,\n // Webhook mode cannot distinguish create vs. update vs. delete on\n // its own — the orchestrator's diff stage handles classification.\n // Tombstone / soft-delete detection is consumer-driven (same as\n // poll mode — see ADR-033).\n operation: 'updated',\n record,\n cursor: nextCursor ?? null,\n source: 'webhook',\n dedupKey: eventIdRaw,\n };\n yield change;\n }\n }\n}\n"],"mappings":";AAsHO,IAAM,sBAAN,MAAyD;AAAA,EAC9C;AAAA,EAEC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAqC;AAC/C,QAAI,KAAK,OAAO,SAAS,WAAW;AAClC,YAAM,IAAI;AAAA,QACR,yEAA0E,KAAK,OAA4B,IAAI;AAAA,MACjH;AAAA,IACF;AACA,UAAM,SAAS,KAAK;AAMpB,UAAM,oBAAoB,OAAO,QAAQ;AAAA,MACvC,CAAC,MAAM,EAAE,WAAW;AAAA,IACtB;AACA,QAAI,CAAC,mBAAmB;AACtB,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,SAAK,wBAAwB,kBAAkB;AAC/C,SAAK,qBAAqB,OAAO,QAAQ;AAEzC,SAAK,QAAQ,KAAK;AAElB,SAAK,QACH,KAAK,SAAS,yBAAyB,kBAAkB,MAAM;AAGjE,UAAM,QAA2B,CAAC,KAAK,QAAQ,KAAK,MAAM,KAAK,GAAG;AAClE,UAAM,cAAc,KAAK,eAAe,CAAC;AACzC,SAAK,WAAW,YAAY;AAAA,MAC1B,CAAC,MAAM,OAAO,GAAG,IAAI;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AAAA,EAEA,YACE,cACA,QAC0B;AAC1B,WAAO,KAAK,SAAS,cAAc,MAAM;AAAA,EAC3C;AAAA,EAEA,OAAe,MACb,cACA,QAC0B;AAC1B,UAAM,MAA2B;AAAA,MAC/B;AAAA,MACA;AAAA,IACF;AAEA,qBAAiB,EAAE,QAAQ,QAAQ,WAAW,KAAK,KAAK,MAAM,GAAG,GAAG;AAClE,YAAM,gBAAiB,OACrB,KAAK,qBACP;AACA,UAAI,OAAO,kBAAkB,YAAY,cAAc,WAAW,GAAG;AACnE,cAAM,IAAI;AAAA,UACR,+CAA+C,KAAK,qBAAqB;AAAA,QAC3E;AAAA,MACF;AACA,YAAM,aAAc,OAClB,KAAK,kBACP;AACA,UAAI,OAAO,eAAe,YAAY,WAAW,WAAW,GAAG;AAC7D,cAAM,IAAI;AAAA,UACR,+CAA+C,KAAK,kBAAkB;AAAA,QACxE;AAAA,MACF;AAEA,YAAM,SAAoB;AAAA,QACxB,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA,QAKZ,WAAW;AAAA,QACX;AAAA,QACA,QAAQ,cAAc;AAAA,QACtB,QAAQ;AAAA,QACR,UAAU;AAAA,MACZ;AACA,YAAM;AAAA,IACR;AAAA,EACF;AACF;","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":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/jobs/jobs-domain.module.ts"],"sourcesContent":["/**\n * JobsDomainModule — `DynamicModule.forRoot({ backend })` factory wiring\n * the three jobs-domain protocol tokens to a backend implementation\n * (ADR-022, JOB-5).\n *\n * Mirrors `EventsModule.forRoot()` exactly:\n * - `global: true` so consumer entity modules don't have to import this\n * individually — `JOB_ORCHESTRATOR` / `JOB_RUN_SERVICE` /\n * `JOB_STEP_SERVICE` are available project-wide.\n * - One backend at a time (Drizzle for production, Memory for tests).\n *\n * Backend swappability follows the core/extension protocol from CLAUDE.md:\n * the three tokens are the **core contract**; backend-specific tunables\n * live under `extensions.<backend>` so opting into a feature is explicit\n * and the type system reserves the slot.\n */\nimport { Module, type DynamicModule, type Provider } from '@nestjs/common';\nimport { DRIZZLE } from '../../constants/tokens';\nimport {\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n} from './jobs-domain.tokens';\nimport { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';\nimport { DrizzleJobRunService } from './job-run-service.drizzle-backend';\nimport { DrizzleJobStepService } from './job-step-service.drizzle-backend';\n// #6 — `BullMQJobOrchestrator` is lazy-loaded only when `backend: 'bullmq'`\n// is selected. The backend file is filtered out of drizzle/memory installs\n// (see `backendFileFilter`); a non-literal dynamic import below sidesteps\n// consumer-side tsc resolution of an absent file.\nimport { MemoryJobOrchestrator } from './job-orchestrator.memory-backend';\nimport { MemoryJobRunService } from './job-run-service.memory-backend';\nimport { MemoryJobStepService } from './job-step-service.memory-backend';\nimport { MemoryJobStore } from './memory-job-store';\nimport {\n BULLMQ_CONNECTION,\n BULLMQ_RESOLVED_CONFIG,\n resolveBullMqConfig,\n type BullMqExtensionsConfig,\n} from './bullmq.config';\n\n/**\n * Drizzle backend extensions surface. None are wired into the Drizzle\n * orchestrator yet — this is the **typed reservation** for the LISTEN/NOTIFY\n * + tunable poll-interval extensions called out in ADR-022. App code\n * passing these today is parsed but not yet dispatched; when the\n * Drizzle orchestrator grows the consumer hooks, opt-in code paths will\n * read directly from these fields.\n */\nexport interface DrizzleBackendExtensions {\n /** Use Postgres LISTEN/NOTIFY to wake the polling loop. Default false. */\n listenNotify?: boolean;\n /** Polling interval when LISTEN/NOTIFY is off (ms). Default 1000. */\n pollIntervalMs?: number;\n}\n\nexport interface JobsDomainModuleOptions {\n backend: 'drizzle' | 'memory' | 'bullmq';\n /**\n * Backend-specific extensions. Only the matching backend's extensions\n * are read at boot; non-matching keys are ignored. This is the\n * core/extension protocol surface — see CLAUDE.md.\n */\n extensions?: {\n drizzle?: DrizzleBackendExtensions;\n /**\n * BullMQ backend extensions (BULLMQ-1). Snake_case mirrors the YAML\n * under `jobs.extensions.bullmq`. `redis_url` falls back to\n * `process.env.REDIS_URL` then `redis://localhost:6379`.\n */\n bullmq?: BullMqExtensionsConfig;\n };\n /** Multi-tenancy opt-in. Wired by JOB-8; module signature stays stable. */\n multiTenant?: boolean;\n}\n\n@Module({})\nexport class JobsDomainModule {\n static forRoot(opts: JobsDomainModuleOptions): DynamicModule {\n const multiTenant = opts.multiTenant ?? false;\n\n const providers: Provider[] = [\n // JOB-8 — boolean provider consumed by the four service-layer backends.\n // Always provided (even when `multiTenant === false`) so `@Inject`\n // always resolves; backends short-circuit the enforcement path when\n // the value is `false`. See `jobs-domain.tokens.ts` for the claim-loop\n // cross-tenant-by-design decision.\n { provide: JOBS_MULTI_TENANT, useValue: multiTenant },\n ];\n\n if (opts.backend === 'memory') {\n // The store is a plain class — wired as a singleton `useValue` so\n // unit tests can pull it out via `.get(MemoryJobStore)` for direct\n // assertions (matches the access pattern in JOB-4 specs).\n const store = new MemoryJobStore();\n providers.push({ provide: MemoryJobStore, useValue: store });\n providers.push(MemoryJobStepService);\n providers.push({ provide: JOB_STEP_SERVICE, useExisting: MemoryJobStepService });\n providers.push(MemoryJobOrchestrator);\n providers.push({ provide: JOB_ORCHESTRATOR, useExisting: MemoryJobOrchestrator });\n providers.push(MemoryJobRunService);\n providers.push({ provide: JOB_RUN_SERVICE, useExisting: MemoryJobRunService });\n } else if (opts.backend === 'bullmq') {\n // BULLMQ-1 — BullMQ orchestrator over a Postgres source of truth. The\n // run/step services stay Drizzle (domain reads + `listForScope` are\n // Postgres queries, unchanged per spec). Only the orchestrator's\n // claim/dispatch half swaps to BullMQ.\n //\n // #6 — the bullmq backend module is filtered out of drizzle/memory\n // installs (no `bullmq` peer dep, no consumer-side tsc compile of an\n // unused file). The factory below dynamic-imports it via a non-literal\n // specifier so TS treats the module type as `any` and never tries to\n // resolve the absent file on a drizzle/memory consumer.\n const resolved = resolveBullMqConfig(opts.extensions?.bullmq);\n providers.push({ provide: BULLMQ_CONNECTION, useValue: resolved.connection });\n providers.push({ provide: BULLMQ_RESOLVED_CONFIG, useValue: resolved });\n providers.push({\n provide: JOB_ORCHESTRATOR,\n useFactory: async (...args: unknown[]): Promise<object> => {\n const specifier = './job-orchestrator.bullmq-backend';\n const mod = (await import(specifier)) as {\n BullMQJobOrchestrator: new (...args: unknown[]) => object;\n };\n return new mod.BullMQJobOrchestrator(...args);\n },\n // The bullmq orchestrator constructor mirrors DrizzleJobOrchestrator's\n // injection list: DRIZZLE + JOBS_MULTI_TENANT + the resolved BullMQ\n // tokens. Importing token references would force a static dep on the\n // tokens file in this module's import graph; using the existing\n // symbols already in scope is sufficient.\n inject: [DRIZZLE, JOBS_MULTI_TENANT, BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG],\n });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n } else {\n providers.push({ provide: JOB_ORCHESTRATOR, useClass: DrizzleJobOrchestrator });\n providers.push({ provide: JOB_RUN_SERVICE, useClass: DrizzleJobRunService });\n providers.push({ provide: JOB_STEP_SERVICE, useClass: DrizzleJobStepService });\n }\n\n const exports = [\n JOB_ORCHESTRATOR,\n JOB_RUN_SERVICE,\n JOB_STEP_SERVICE,\n JOBS_MULTI_TENANT,\n ];\n // BULLMQ-1 — only export the BullMQ tokens when they were actually\n // provided. Nest throws \"exported but not provided\" otherwise. Exported so\n // JobWorkerModule (which imports this module) can read the resolved\n // connection/config to spawn BullMQ workers.\n if (opts.backend === 'bullmq') {\n exports.push(BULLMQ_CONNECTION, BULLMQ_RESOLVED_CONFIG);\n }\n\n return {\n module: JobsDomainModule,\n global: true,\n providers,\n exports,\n };\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgBA,SAAS,cAAiD;AA8DnD,IAAM,mBAAN,MAAuB;AAAA,EAC5B,OAAO,QAAQ,MAA8C;AAC3D,UAAM,cAAc,KAAK,eAAe;AAExC,UAAM,YAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAM5B,EAAE,SAAS,mBAAmB,UAAU,YAAY;AAAA,IACtD;AAEA,QAAI,KAAK,YAAY,UAAU;AAI7B,YAAM,QAAQ,IAAI,eAAe;AACjC,gBAAU,KAAK,EAAE,SAAS,gBAAgB,UAAU,MAAM,CAAC;AAC3D,gBAAU,KAAK,oBAAoB;AACnC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,qBAAqB,CAAC;AAC/E,gBAAU,KAAK,qBAAqB;AACpC,gBAAU,KAAK,EAAE,SAAS,kBAAkB,aAAa,sBAAsB,CAAC;AAChF,gBAAU,KAAK,mBAAmB;AAClC,gBAAU,KAAK,EAAE,SAAS,iBAAiB,aAAa,oBAAoB,CAAC;AAAA,IAC/E,WAAW,KAAK,YAAY,UAAU;AAWpC,YAAM,WAAW,oBAAoB,KAAK,YAAY,MAAM;AAC5D,gBAAU,KAAK,EAAE,SAAS,mBAAmB,UAAU,SAAS,WAAW,CAAC;AAC5E,gBAAU,KAAK,EAAE,SAAS,wBAAwB,UAAU,SAAS,CAAC;AACtE,gBAAU,KAAK;AAAA,QACb,SAAS;AAAA,QACT,YAAY,UAAU,SAAqC;AACzD,gBAAM,YAAY;AAClB,gBAAM,MAAO,MAAM,OAAO;AAG1B,iBAAO,IAAI,IAAI,sBAAsB,GAAG,IAAI;AAAA,QAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMA,QAAQ,CAAC,SAAS,mBAAmB,mBAAmB,sBAAsB;AAAA,MAChF,CAAC;AACD,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E,OAAO;AACL,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,uBAAuB,CAAC;AAC9E,gBAAU,KAAK,EAAE,SAAS,iBAAiB,UAAU,qBAAqB,CAAC;AAC3E,gBAAU,KAAK,EAAE,SAAS,kBAAkB,UAAU,sBAAsB,CAAC;AAAA,IAC/E;AAEA,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAKA,QAAI,KAAK,YAAY,UAAU;AAC7B,cAAQ,KAAK,mBAAmB,sBAAsB;AAAA,IACxD;AAEA,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AApFa,mBAAN;AAAA,EADN,OAAO,CAAC,CAAC;AAAA,GACG;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../runtime/subsystems/bridge/bridge-outbox-drain-hook.ts"],"sourcesContent":["/**\n * BridgeOutboxDrainHook — drains-time bridge fanout writer (BRIDGE-4,\n * ADR-023 Phase 2).\n *\n * Implements `IBridgeOutboxDrainHook`. Called by `DrizzleEventBus`'s\n * modified `processBatch` once per drained event, INSIDE the per-event\n * transaction. For every trigger registered against the event's type in\n * the codegen-emitted `bridgeRegistry`, writes:\n *\n * 1. `bridge_delivery` ledger row — `INSERT … ON CONFLICT (event_id,\n * trigger_id) DO NOTHING RETURNING id`. Empty result ⇒ Case B\n * facade-eager pre-write OR drain-replay collision; skip wrapper\n * insert for that trigger; sibling triggers still fire.\n * 2. `job_run` wrapper row — `type='@framework/bridge_delivery'`,\n * `pool='events_<direction>'`, `input={ deliveryId }`,\n * `trigger_source='event'`, `trigger_ref=event.id`. The wrapper is\n * what the framework `BridgeDeliveryHandler` (BRIDGE-5) eventually\n * claims via the worker that polls the corresponding reserved pool.\n *\n * Null `event.metadata.direction` is tolerated: the hook logs a one-line\n * warning per event and returns zeros without writing rows. The drain's\n * `processed_at` stamp + subscriber dispatch still fire normally.\n * Direction is null only for events published via the legacy\n * `IEventBus.publish(...)` path (`TypedEventBus.publish` always sets it);\n * such events are out of scope for bridge fanout.\n *\n * The wrapper insert generates its own `id` via Drizzle's `defaultRandom`\n * — we don't `RETURNING id` because nobody needs it at drain time;\n * `BridgeDeliveryHandler` later looks up the wrapper via the\n * `bridge_delivery.wrapper_run_id` link if needed. This keeps the drain\n * one-round-trip-per-trigger.\n */\nimport { Inject, Injectable, Logger, Optional } from '@nestjs/common';\nimport { randomUUID } from 'node:crypto';\nimport { eq } from 'drizzle-orm';\n\nimport type { DomainEvent, DrizzleTransaction } from '../events/event-bus.protocol';\nimport { bridgeDelivery } from './bridge-delivery.schema';\nimport { jobRuns } from '../jobs/job-orchestration.schema';\n\nimport { BRIDGE_REGISTRY } from './bridge.tokens';\nimport type {\n BridgeOutboxDrainResult,\n BridgeRegistry,\n BridgeTriggerEntry,\n IBridgeOutboxDrainHook,\n} from './bridge.protocol';\nimport { BRIDGE_DELIVERY_JOB_TYPE } from './bridge-delivery-handler';\nimport type { EventTypeName } from '../events/event-registry';\n\n/** Reserved pools the wrapper rows route into; ADR-022 / ADR-024. */\nconst POOL_BY_DIRECTION: Record<string, string> = {\n inbound: 'events_inbound',\n change: 'events_change',\n outbound: 'events_outbound',\n};\n\n@Injectable()\nexport class BridgeOutboxDrainHook implements IBridgeOutboxDrainHook {\n private readonly logger = new Logger(BridgeOutboxDrainHook.name);\n private warnedNullDirection = false;\n private readonly warnedAuditTypes = new Set<string>();\n\n constructor(\n @Optional()\n @Inject(BRIDGE_REGISTRY)\n private readonly registry: BridgeRegistry = {},\n ) {}\n\n async processEvent(\n event: DomainEvent,\n tx: DrizzleTransaction,\n ): Promise<BridgeOutboxDrainResult> {\n // Audit-tier guard (defense-in-depth — AUDIT-4). Audit events are not\n // bridge-eligible: the codegen-side validator (AUDIT-2) blocks the\n // registry from listing them as triggers. Reaching this branch means\n // registry/runtime drift — an out-of-band `bridge_trigger` insert, or\n // version skew during deploy. Refuse fanout, surface drift via WARN.\n if (event.metadata?.['tier'] === 'audit') {\n this.warnAuditBlockedOnce(event);\n return {\n delivered: 0,\n dedupSkips: 0,\n triggerCount: 0,\n auditBlocked: 1,\n };\n }\n\n const triggers = this.lookupTriggers(event.type);\n if (triggers.length === 0) {\n return {\n delivered: 0,\n dedupSkips: 0,\n triggerCount: 0,\n auditBlocked: 0,\n };\n }\n\n const direction =\n (event.metadata?.['direction'] as string | undefined) ?? null;\n const tenantId =\n (event.metadata?.['tenantId'] as string | null | undefined) ?? null;\n const wrapperPool = direction ? POOL_BY_DIRECTION[direction] : undefined;\n\n if (!wrapperPool) {\n // Null direction (or an unrecognised one — defensive). Bridge\n // fanout requires a routed wrapper pool; without one we can't\n // spawn. Log once per process so misconfiguration surfaces.\n if (!this.warnedNullDirection) {\n this.warnedNullDirection = true;\n this.logger.warn(\n `Skipping bridge fanout for events with null/unknown direction. ` +\n `event.id=${event.id} event.type=${event.type} ` +\n `direction=${String(direction)}. The wrapper pool is derived ` +\n `from direction (events_<direction>); publishers must use ` +\n `TypedEventBus.publish() so direction is stamped on the ` +\n `outbox row.`,\n );\n }\n return {\n delivered: 0,\n dedupSkips: 0,\n triggerCount: triggers.length,\n auditBlocked: 0,\n };\n }\n\n let delivered = 0;\n let dedupSkips = 0;\n const client = tx as unknown as {\n insert: (table: unknown) => {\n values: (v: unknown) => {\n onConflictDoNothing: (opts: unknown) => {\n returning: (cols: unknown) => Promise<{ id: string }[]>;\n };\n } & {\n // wrapper insert path — no ON CONFLICT\n // (typed loosely via the same helper return shape)\n };\n };\n };\n\n for (const trigger of triggers) {\n const deliveryId = randomUUID();\n const wrapperRunId = randomUUID();\n\n // FK ORDER (BRIDGE / 0.15.2): `bridge_delivery.wrapper_run_id` REFERENCES\n // `job_run(id)` is a plain (non-deferrable) FK, so the referenced\n // wrapper `job_run` MUST exist before the delivery row that points at it\n // is inserted — otherwise Postgres rejects the delivery insert\n // immediately. (The codegen unit tests mock `tx`, so they never\n // exercised this ordering against a real FK; package-mode bridge\n // deliveries are the first to do so.) We therefore insert the wrapper\n // run FIRST, then the delivery. Idempotency is unchanged: the delivery\n // keeps its `ON CONFLICT (event_id, trigger_id) DO NOTHING RETURNING`,\n // and when the delivery conflicts (outbox replay or facade-eager Case B)\n // we DELETE the just-inserted orphan wrapper run in the same tx, so a\n // skipped delivery leaves no stray `job_run` for a worker to claim.\n\n // 1. Wrapper job_run insert. We carry the deliveryId into the wrapper\n // input so BridgeDeliveryHandler.run(ctx) can locate the row via\n // repo.findDeliveryById(ctx.input.deliveryId).\n await (tx as unknown as { insert: typeof client.insert })\n .insert(jobRuns)\n .values({\n id: wrapperRunId,\n jobType: BRIDGE_DELIVERY_JOB_TYPE,\n jobVersion: 1,\n rootRunId: wrapperRunId,\n pool: wrapperPool,\n status: 'pending',\n input: { deliveryId },\n triggerSource: 'event',\n triggerRef: event.id,\n tenantId,\n });\n\n // 2. bridge_delivery insert with ON CONFLICT DO NOTHING + RETURNING.\n const inserted = await (tx as unknown as {\n insert: typeof client.insert;\n })\n .insert(bridgeDelivery)\n .values({\n id: deliveryId,\n eventId: event.id,\n triggerId: trigger.triggerId,\n wrapperRunId,\n status: 'pending',\n tenantId,\n })\n .onConflictDoNothing({\n target: [bridgeDelivery.eventId, bridgeDelivery.triggerId],\n })\n .returning({ id: bridgeDelivery.id });\n\n if (inserted.length === 0) {\n // Case B (facade pre-wrote `delivered`) or drain replay — the delivery\n // already exists, so this trigger is a no-op. Remove the orphan wrapper\n // run we speculatively inserted above so no worker claims it. Sibling\n // triggers still fire.\n await (tx as unknown as {\n delete: (table: unknown) => {\n where: (cond: unknown) => Promise<unknown>;\n };\n })\n .delete(jobRuns)\n .where(eq(jobRuns.id, wrapperRunId));\n dedupSkips++;\n continue;\n }\n\n delivered++;\n }\n\n return {\n delivered,\n dedupSkips,\n triggerCount: triggers.length,\n auditBlocked: 0,\n };\n }\n\n private warnAuditBlockedOnce(event: DomainEvent): void {\n if (this.warnedAuditTypes.has(event.type)) return;\n this.warnedAuditTypes.add(event.type);\n this.logger.warn(\n `Bridge guard blocked audit-tier event '${event.type}' (event.id=${event.id}). ` +\n `Registry says this event is not bridge-eligible; a bridge_trigger row exists ` +\n `out-of-band. Investigate registry/runtime drift.`,\n );\n }\n\n private lookupTriggers(\n eventType: string,\n ): BridgeTriggerEntry[] {\n const candidates = this.registry[eventType as EventTypeName];\n return (candidates ?? []) as BridgeTriggerEntry[];\n }\n}\n"],"mappings":"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","names":[]}