@pattern-stack/codegen 0.15.3 → 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 (132) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/{chunk-GCYKMF22.js → chunk-24WXSC3C.js} +6 -6
  3. package/dist/{chunk-O37C3YE6.js → chunk-3RWMQC3K.js} +23 -17
  4. package/dist/chunk-3RWMQC3K.js.map +1 -0
  5. package/dist/{chunk-4JLJYWJC.js → chunk-4PFF3ED4.js} +98 -10
  6. package/dist/chunk-4PFF3ED4.js.map +1 -0
  7. package/dist/{chunk-32BMMV4H.js → chunk-5RT7JGKT.js} +5 -5
  8. package/dist/{chunk-RDVTWIYY.js → chunk-BHZP6LOV.js} +8 -8
  9. package/dist/{chunk-4MVGAMUA.js → chunk-BK5ICA2F.js} +4 -4
  10. package/dist/{chunk-4RFHUZXU.js → chunk-BULPAAD3.js} +2 -2
  11. package/dist/{chunk-IYNSRIGR.js → chunk-CEWLVVAH.js} +6 -6
  12. package/dist/{chunk-27ETSJ2X.js → chunk-COGHTKXY.js} +2 -2
  13. package/dist/{chunk-J7JMVS2B.js → chunk-DKKFTHHI.js} +4 -4
  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-4H3PETLM.js → chunk-RUYLXR5F.js} +15 -12
  27. package/dist/chunk-RUYLXR5F.js.map +1 -0
  28. package/dist/{chunk-7YGORYZD.js → chunk-T6C4LFLC.js} +4 -4
  29. package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
  30. package/dist/{chunk-FBGHYQIZ.js → chunk-VNBC3VXM.js} +5 -5
  31. package/dist/{chunk-YPWODKD5.js → chunk-W2UIDI3R.js} +5 -5
  32. package/dist/chunk-W4HOHZVF.js +1 -0
  33. package/dist/{chunk-RC23QROE.js → chunk-XDIIVIIK.js} +79 -5
  34. package/dist/chunk-XDIIVIIK.js.map +1 -0
  35. package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
  36. package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
  37. package/dist/{chunk-BIO6F7YI.js → chunk-ZPL74UQN.js} +4 -2
  38. package/dist/{chunk-BIO6F7YI.js.map → chunk-ZPL74UQN.js.map} +1 -1
  39. package/dist/runtime/base-classes/index.js +22 -22
  40. package/dist/runtime/shared/openapi/index.js +3 -3
  41. package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
  42. package/dist/runtime/subsystems/analytics/index.js +4 -4
  43. package/dist/runtime/subsystems/auth/index.js +1 -1
  44. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  45. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +3 -3
  46. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.d.ts +2 -1
  47. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +7 -6
  48. package/dist/runtime/subsystems/bridge/bridge.module.js +20 -19
  49. package/dist/runtime/subsystems/bridge/event-flow.service.js +3 -3
  50. package/dist/runtime/subsystems/bridge/index.js +22 -21
  51. package/dist/runtime/subsystems/cache/cache.drizzle-backend.js +2 -2
  52. package/dist/runtime/subsystems/cache/cache.module.js +3 -3
  53. package/dist/runtime/subsystems/cache/index.js +5 -5
  54. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +20 -0
  55. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -3
  56. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  57. package/dist/runtime/subsystems/events/events.module.d.ts +14 -0
  58. package/dist/runtime/subsystems/events/events.module.js +6 -5
  59. package/dist/runtime/subsystems/events/index.js +12 -11
  60. package/dist/runtime/subsystems/index.js +65 -64
  61. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  62. package/dist/runtime/subsystems/integration/index.js +14 -14
  63. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  64. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  65. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  66. package/dist/runtime/subsystems/jobs/index.d.ts +2 -1
  67. package/dist/runtime/subsystems/jobs/index.js +44 -32
  68. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +6 -5
  69. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  70. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +2 -1
  71. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +3 -2
  72. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +2 -2
  73. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  74. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +3 -3
  75. package/dist/runtime/subsystems/jobs/job-worker.d.ts +28 -0
  76. package/dist/runtime/subsystems/jobs/job-worker.js +3 -2
  77. package/dist/runtime/subsystems/jobs/job-worker.module.js +12 -11
  78. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +12 -7
  79. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +10 -9
  80. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.d.ts +13 -1
  81. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.js +3 -1
  82. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +85 -0
  83. package/dist/runtime/subsystems/jobs/pg-notify.js +14 -0
  84. package/dist/runtime/subsystems/jobs/pg-notify.js.map +1 -0
  85. package/dist/runtime/subsystems/observability/index.js +4 -4
  86. package/dist/runtime/subsystems/observability/observability.module.js +4 -4
  87. package/dist/runtime/subsystems/observability/observability.service.js +3 -3
  88. package/dist/runtime/subsystems/storage/index.js +4 -4
  89. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  90. package/dist/src/cli/index.js +49 -11
  91. package/dist/src/cli/index.js.map +1 -1
  92. package/dist/src/index.js +5 -5
  93. package/package.json +1 -1
  94. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +27 -0
  95. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +108 -4
  96. package/runtime/subsystems/events/events.module.ts +14 -0
  97. package/runtime/subsystems/jobs/index.ts +10 -0
  98. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +29 -2
  99. package/runtime/subsystems/jobs/job-worker.module.ts +11 -0
  100. package/runtime/subsystems/jobs/job-worker.ts +98 -0
  101. package/runtime/subsystems/jobs/jobs-domain.module.ts +22 -7
  102. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +13 -0
  103. package/runtime/subsystems/jobs/pg-notify.ts +216 -0
  104. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +14 -0
  105. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +13 -4
  106. package/dist/chunk-4H3PETLM.js.map +0 -1
  107. package/dist/chunk-4JLJYWJC.js.map +0 -1
  108. package/dist/chunk-5Y7W3XR6.js.map +0 -1
  109. package/dist/chunk-EOLLMEAH.js.map +0 -1
  110. package/dist/chunk-L7BNNRGI.js.map +0 -1
  111. package/dist/chunk-O37C3YE6.js.map +0 -1
  112. package/dist/chunk-RC23QROE.js.map +0 -1
  113. package/dist/chunk-UTN4GBPQ.js +0 -1
  114. /package/dist/{chunk-GCYKMF22.js.map → chunk-24WXSC3C.js.map} +0 -0
  115. /package/dist/{chunk-32BMMV4H.js.map → chunk-5RT7JGKT.js.map} +0 -0
  116. /package/dist/{chunk-RDVTWIYY.js.map → chunk-BHZP6LOV.js.map} +0 -0
  117. /package/dist/{chunk-4MVGAMUA.js.map → chunk-BK5ICA2F.js.map} +0 -0
  118. /package/dist/{chunk-4RFHUZXU.js.map → chunk-BULPAAD3.js.map} +0 -0
  119. /package/dist/{chunk-IYNSRIGR.js.map → chunk-CEWLVVAH.js.map} +0 -0
  120. /package/dist/{chunk-27ETSJ2X.js.map → chunk-COGHTKXY.js.map} +0 -0
  121. /package/dist/{chunk-J7JMVS2B.js.map → chunk-DKKFTHHI.js.map} +0 -0
  122. /package/dist/{chunk-YTN6BKWA.js.map → chunk-DRCLNYH7.js.map} +0 -0
  123. /package/dist/{chunk-TNXH7BJS.js.map → chunk-E45CSC33.js.map} +0 -0
  124. /package/dist/{chunk-K2I6XIK5.js.map → chunk-KSTZIULO.js.map} +0 -0
  125. /package/dist/{chunk-Z7PQCAVK.js.map → chunk-LQ6PYFU6.js.map} +0 -0
  126. /package/dist/{chunk-7YGORYZD.js.map → chunk-T6C4LFLC.js.map} +0 -0
  127. /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
  128. /package/dist/{chunk-FBGHYQIZ.js.map → chunk-VNBC3VXM.js.map} +0 -0
  129. /package/dist/{chunk-YPWODKD5.js.map → chunk-W2UIDI3R.js.map} +0 -0
  130. /package/dist/{chunk-UTN4GBPQ.js.map → chunk-W4HOHZVF.js.map} +0 -0
  131. /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
  132. /package/dist/{chunk-SR7F3TJY.js.map → chunk-YK5JEVLX.js.map} +0 -0
package/dist/src/index.js CHANGED
@@ -46,17 +46,18 @@ import {
46
46
  validatePatternProject
47
47
  } from "../chunk-32DOFN3T.js";
48
48
  import "../chunk-KVOWSC5S.js";
49
- import "../chunk-GCYKMF22.js";
49
+ import "../chunk-24WXSC3C.js";
50
50
  import "../chunk-EO2QPOKH.js";
51
51
  import "../chunk-PRWIX6UW.js";
52
- import "../chunk-DCCZB4UC.js";
52
+ import "../chunk-XWBK3XJK.js";
53
53
  import "../chunk-AHV4GDYM.js";
54
- import "../chunk-SR7F3TJY.js";
54
+ import "../chunk-YK5JEVLX.js";
55
55
  import "../chunk-SQDOBLBP.js";
56
56
  import "../chunk-3NMCDN7L.js";
57
57
  import "../chunk-4KNXX6TI.js";
58
58
  import "../chunk-3CJFPU6Q.js";
59
- import "../chunk-OGIZXGPY.js";
59
+ import "../chunk-TDEHU73T.js";
60
+ import "../chunk-S7C6TIIF.js";
60
61
  import "../chunk-MZ6GV4YF.js";
61
62
  import "../chunk-LG57S2SC.js";
62
63
  import "../chunk-HNWZFNKP.js";
@@ -64,7 +65,6 @@ import "../chunk-WWGYCIJX.js";
64
65
  import "../chunk-4MF3HKJA.js";
65
66
  import "../chunk-YLPAPPLW.js";
66
67
  import "../chunk-36U5UGIO.js";
67
- import "../chunk-S7C6TIIF.js";
68
68
  import "../chunk-U64T4YZE.js";
69
69
  import "../chunk-2E224ZSN.js";
70
70
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,6 +47,8 @@ import type {
47
47
  } from './bridge.protocol';
48
48
  import { BRIDGE_DELIVERY_JOB_TYPE } from './bridge-delivery-handler';
49
49
  import type { EventTypeName } from '../events/event-registry';
50
+ import { JOBS_LISTEN_NOTIFY } from '../jobs/jobs-domain.tokens';
51
+ import { JOBS_WAKE_CHANNEL, pgNotify } from '../jobs/pg-notify';
50
52
 
51
53
  /** Reserved pools the wrapper rows route into; ADR-022 / ADR-024. */
52
54
  const POOL_BY_DIRECTION: Record<string, string> = {
@@ -65,6 +67,15 @@ export class BridgeOutboxDrainHook implements IBridgeOutboxDrainHook {
65
67
  @Optional()
66
68
  @Inject(BRIDGE_REGISTRY)
67
69
  private readonly registry: BridgeRegistry = {},
70
+ // LISTEN-NOTIFY-1 — when true, the wrapper `job_run` insert below emits an
71
+ // in-tx `pg_notify(codegen_jobs_wake, <wrapperPool>)` so the reserved-pool
72
+ // worker wakes the instant the per-event drain tx commits — otherwise the
73
+ // bridge hop alone would still cost a full poll interval. `@Optional()`
74
+ // defaulting false so the hook keeps working when jobs isn't installed
75
+ // (bridge can drive non-jobs consumers) or in vendored/test wiring.
76
+ @Optional()
77
+ @Inject(JOBS_LISTEN_NOTIFY)
78
+ private readonly listenNotify: boolean = false,
68
79
  ) {}
69
80
 
70
81
  async processEvent(
@@ -209,6 +220,22 @@ export class BridgeOutboxDrainHook implements IBridgeOutboxDrainHook {
209
220
  continue;
210
221
  }
211
222
 
223
+ // LISTEN-NOTIFY-1 — the wrapper run is real and claimable; wake its
224
+ // reserved-pool worker on commit (D7). Same `tx` as the inserts above, so
225
+ // delivery is gated on the per-event drain tx committing. Best-effort: a
226
+ // notify failure is non-fatal (the reserved-pool worker still polls).
227
+ if (this.listenNotify) {
228
+ try {
229
+ await pgNotify(tx, JOBS_WAKE_CHANNEL, wrapperPool);
230
+ } catch (err) {
231
+ this.logger.warn(
232
+ `pg_notify(${JOBS_WAKE_CHANNEL}, ${wrapperPool}) failed for ` +
233
+ `wrapper run ${wrapperRunId}: ${(err as Error).message} ` +
234
+ `(non-fatal — the reserved-pool worker still polls).`,
235
+ );
236
+ }
237
+ }
238
+
212
239
  delivered++;
213
240
  }
214
241
 
@@ -48,6 +48,11 @@ import { EVENTS_MODULE_OPTIONS } from './events.tokens';
48
48
  import type { EventsModuleOptions } from './events.module';
49
49
  import { BRIDGE_OUTBOX_DRAIN_HOOK } from '../bridge/bridge.tokens';
50
50
  import type { IBridgeOutboxDrainHook } from '../bridge/bridge.protocol';
51
+ import {
52
+ EVENTS_WAKE_CHANNEL,
53
+ PgNotifyListener,
54
+ pgNotify,
55
+ } from '../jobs/pg-notify';
51
56
 
52
57
  /** How long to wait between polling cycles (ms). */
53
58
  const POLL_INTERVAL_MS = 1_000;
@@ -138,6 +143,14 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
138
143
  private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();
139
144
  private readonly opts: EventsModuleOptions;
140
145
 
146
+ // LISTEN-NOTIFY-1 — dedicated wake listener + debounce state. `null` when
147
+ // `listenNotify` is off (the common case); polling is the only driver then.
148
+ private notifyListener: PgNotifyListener | null = null;
149
+ /** True while a wake-driven drain is in flight (debounce gate). */
150
+ private wakeDraining = false;
151
+ /** A notify arrived mid-drain → re-drain once when the current drain ends. */
152
+ private wakeRecheckPending = false;
153
+
141
154
  constructor(
142
155
  @Inject(DRIZZLE) private readonly db: DrizzleClient,
143
156
  @Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,
@@ -168,6 +181,28 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
168
181
  async onModuleInit(): Promise<void> {
169
182
  this.polling = true;
170
183
  this.schedulePoll();
184
+
185
+ // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer. A
186
+ // notify for one of this drainer's pools triggers an immediate drain; the
187
+ // interval timer above stays the durability heartbeat. Startup is
188
+ // fire-and-forget — a connect failure self-heals via the listener's backoff.
189
+ if (this.opts.listenNotify) {
190
+ const pool = (this.db as unknown as { $client?: unknown }).$client;
191
+ if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {
192
+ this.logger.warn(
193
+ `listen_notify enabled but the Drizzle client exposes no pg Pool ` +
194
+ `($client.connect missing) — falling back to interval polling only.`,
195
+ );
196
+ } else {
197
+ this.notifyListener = new PgNotifyListener({
198
+ channel: EVENTS_WAKE_CHANNEL,
199
+ pool: pool as { connect(): Promise<never> },
200
+ label: 'events',
201
+ onNotify: (payload) => this.onWake(payload),
202
+ });
203
+ await this.notifyListener.start();
204
+ }
205
+ }
171
206
  }
172
207
 
173
208
  async onModuleDestroy(): Promise<void> {
@@ -176,6 +211,45 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
176
211
  clearTimeout(this.pollTimer);
177
212
  this.pollTimer = null;
178
213
  }
214
+ if (this.notifyListener) {
215
+ try {
216
+ await this.notifyListener.stop();
217
+ } catch (err) {
218
+ this.logger.error(`notify listener stop failed: ${err}`);
219
+ }
220
+ this.notifyListener = null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Wake handler — a `codegen_events_wake` notification arrived. A pool-filtered
226
+ * drainer (`opts.pools` set) ignores payloads naming a pool it doesn't own; an
227
+ * all-pools drainer wakes for any. Debounced: a notify mid-drain just flags a
228
+ * re-check so a burst collapses to at most one extra drain (D3).
229
+ */
230
+ private onWake(payload: string): void {
231
+ if (!this.polling) return;
232
+ const pools = this.opts.pools;
233
+ if (pools && pools.length > 0 && !pools.includes(payload)) return;
234
+ if (this.wakeDraining) {
235
+ this.wakeRecheckPending = true;
236
+ return;
237
+ }
238
+ void this.drainOnWake();
239
+ }
240
+
241
+ private async drainOnWake(): Promise<void> {
242
+ this.wakeDraining = true;
243
+ try {
244
+ do {
245
+ this.wakeRecheckPending = false;
246
+ await this.processBatch();
247
+ } while (this.wakeRecheckPending && this.polling);
248
+ } catch (err) {
249
+ this.logger.error(`wake drain error: ${err}`);
250
+ } finally {
251
+ this.wakeDraining = false;
252
+ }
179
253
  }
180
254
 
181
255
  // ============================================================================
@@ -185,16 +259,46 @@ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit,
185
259
  async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {
186
260
  const client = (tx ?? this.db) as DrizzleClient;
187
261
  const multiTenant = this.opts.multiTenant ?? false;
188
- await client.insert(domainEvents).values(toInsertValues(event, multiTenant));
262
+ const values = toInsertValues(event, multiTenant);
263
+ await client.insert(domainEvents).values(values);
264
+ // LISTEN-NOTIFY-1 — wake the drainer on commit (D2: emitted through the same
265
+ // `client`, so a rolled-back publish emits no phantom wake). The pool is the
266
+ // payload; the drainer re-runs its own pool-filtered claim on wake.
267
+ await this.emitWakeNotify(client, [values.pool]);
189
268
  }
190
269
 
191
270
  async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {
192
271
  if (events.length === 0) return;
193
272
  const client = (tx ?? this.db) as DrizzleClient;
194
273
  const multiTenant = this.opts.multiTenant ?? false;
195
- await client
196
- .insert(domainEvents)
197
- .values(events.map((e) => toInsertValues(e, multiTenant)));
274
+ const valuesList = events.map((e) => toInsertValues(e, multiTenant));
275
+ await client.insert(domainEvents).values(valuesList);
276
+ // De-dup pools so a batch into one lane emits a single wake.
277
+ await this.emitWakeNotify(client, valuesList.map((v) => v.pool));
278
+ }
279
+
280
+ /**
281
+ * Emit one in-tx `pg_notify(codegen_events_wake, <pool>)` per distinct pool in
282
+ * the just-inserted batch. No-op unless `listenNotify` is on. Best-effort: a
283
+ * notify failure is non-fatal (interval polling still drains the rows), so we
284
+ * log + swallow rather than failing the publish.
285
+ */
286
+ private async emitWakeNotify(
287
+ client: DrizzleClient,
288
+ pools: Array<string | null>,
289
+ ): Promise<void> {
290
+ if (!this.opts.listenNotify) return;
291
+ const distinct = new Set(pools.map((p) => p ?? ''));
292
+ for (const pool of distinct) {
293
+ try {
294
+ await pgNotify(client, EVENTS_WAKE_CHANNEL, pool);
295
+ } catch (err) {
296
+ this.logger.warn(
297
+ `pg_notify(${EVENTS_WAKE_CHANNEL}, '${pool}') failed: ${err} ` +
298
+ `(non-fatal — interval polling still drains the outbox).`,
299
+ );
300
+ }
301
+ }
198
302
  }
199
303
 
200
304
  async findById(eventId: string): Promise<DomainEvent | null> {
@@ -96,6 +96,20 @@ export interface EventsModuleOptions {
96
96
  * cannot stall change-event propagation (see ADR-022).
97
97
  */
98
98
  pools?: string[];
99
+ /**
100
+ * LISTEN-NOTIFY-1 — when `true` (drizzle backend only), the drainer holds a
101
+ * dedicated listener connection and LISTENs on `codegen_events_wake`. Each
102
+ * `publish`/`publishMany` emits an in-tx `pg_notify(codegen_events_wake,
103
+ * <pool>)` so the drainer wakes the moment the publishing transaction commits,
104
+ * instead of waiting for the next poll tick. Polling continues unchanged as
105
+ * the fallback heartbeat; a lost notify degrades to poll latency, never to
106
+ * lost work. Defaults to `false`.
107
+ *
108
+ * Ignored by the memory + redis backends (memory dispatches inline; redis has
109
+ * its own fan-out). Requires a direct (non-transaction-pooler) connection —
110
+ * see the events/jobs config block re: PgBouncer.
111
+ */
112
+ listenNotify?: boolean;
99
113
  /**
100
114
  * Multi-tenancy opt-in (EVT-6).
101
115
  *
@@ -22,6 +22,7 @@ export {
22
22
  JOB_RUN_SERVICE,
23
23
  JOB_STEP_SERVICE,
24
24
  JOBS_MULTI_TENANT,
25
+ JOBS_LISTEN_NOTIFY,
25
26
  } from './jobs-domain.tokens';
26
27
 
27
28
  // ─── JOB-2: orchestrator protocol ──────────────────────────────────────────
@@ -111,6 +112,15 @@ export {
111
112
  buildStaleSweepQuery,
112
113
  } from './job-worker';
113
114
  export type { JobWorkerOptions } from './job-worker';
115
+
116
+ // ─── LISTEN-NOTIFY-1: Postgres LISTEN/NOTIFY wakeups ───────────────────────
117
+ export {
118
+ PgNotifyListener,
119
+ pgNotify,
120
+ JOBS_WAKE_CHANNEL,
121
+ EVENTS_WAKE_CHANNEL,
122
+ } from './pg-notify';
123
+ export type { PgNotifyListenerOptions } from './pg-notify';
114
124
  export {
115
125
  JobCollisionError,
116
126
  JobNotReplayableError,
@@ -7,7 +7,7 @@
7
7
  * No `job_queue` table, no executor port. See `docs/specs/JOB-3.md`.
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
- import { Inject, Injectable, Logger } from '@nestjs/common';
10
+ import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
11
11
  import { and, desc, eq, gt, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
12
12
  import type { DrizzleClient } from '../../types/drizzle';
13
13
  import type { DrizzleTransaction } from '../events/event-bus.protocol';
@@ -34,7 +34,8 @@ import {
34
34
  MissingTenantIdError,
35
35
  } from './jobs-errors';
36
36
  import { jobSteps } from './job-orchestration.schema';
37
- import { JOBS_MULTI_TENANT } from './jobs-domain.tokens';
37
+ import { JOBS_MULTI_TENANT, JOBS_LISTEN_NOTIFY } from './jobs-domain.tokens';
38
+ import { JOBS_WAKE_CHANNEL, pgNotify } from './pg-notify';
38
39
 
39
40
  /**
40
41
  * Terminal statuses — transitions into these are final. Used by `cancel`
@@ -83,6 +84,13 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
83
84
  constructor(
84
85
  @Inject(DRIZZLE) private readonly db: DrizzleClient,
85
86
  @Inject(JOBS_MULTI_TENANT) private readonly multiTenant: boolean,
87
+ // LISTEN-NOTIFY-1 — when true, `start()` emits an in-tx
88
+ // `pg_notify(codegen_jobs_wake, <pool>)` so a `listen_notify` worker wakes
89
+ // on enqueue-commit. `@Optional()` defaulting to false so direct
90
+ // construction (integration tests not going through DI) keeps working.
91
+ @Optional()
92
+ @Inject(JOBS_LISTEN_NOTIFY)
93
+ private readonly listenNotify: boolean = false,
86
94
  ) {}
87
95
 
88
96
  /**
@@ -251,6 +259,25 @@ export class DrizzleJobOrchestrator implements IJobOrchestrator {
251
259
  })
252
260
  .returning();
253
261
 
262
+ // LISTEN-NOTIFY-1 — wake a listening worker the instant this enqueue
263
+ // commits. Emitted through the SAME `client` (the caller's tx when one was
264
+ // passed, else the pool) so delivery is gated on commit — a rolled-back
265
+ // enqueue emits no phantom wake (D2). The pool name is the payload; the
266
+ // worker re-runs its own pool-filtered claim query on wake. Polling is the
267
+ // fallback, so a failed notify is non-fatal: log + continue.
268
+ if (this.listenNotify) {
269
+ const wakePool = (inserted as JobRunRow).pool;
270
+ try {
271
+ await pgNotify(client, JOBS_WAKE_CHANNEL, wakePool);
272
+ } catch (err) {
273
+ this.logger.warn(
274
+ `pg_notify(${JOBS_WAKE_CHANNEL}, ${wakePool}) failed for run ` +
275
+ `${(inserted as JobRunRow).id}: ${(err as Error).message} ` +
276
+ `(non-fatal — interval polling still claims the run).`,
277
+ );
278
+ }
279
+ }
280
+
254
281
  return inserted as JobRun;
255
282
  }
256
283
 
@@ -245,11 +245,22 @@ export class JobWorkerOrchestrator implements OnModuleInit, OnModuleDestroy {
245
245
  // naming; it MUST NOT be passed as the claim-filter pool, or the
246
246
  // worker will never match any row and the pool silently never
247
247
  // drains. See v0.4.4 fix notes.
248
+ // LISTEN-NOTIFY-1 — thread the drizzle extension knobs into each spawned
249
+ // worker. `pollIntervalMs` was always honored by JobWorker but never
250
+ // received a config value; `listenNotify` is the new wake opt-in. Only
251
+ // the drizzle backend reads these (bullmq has native wakeups + its own
252
+ // queue topology), so we ignore them under `backend: 'bullmq'`.
253
+ const drizzleExt =
254
+ backend === 'drizzle'
255
+ ? this.options.domainModuleExtensions?.drizzle
256
+ : undefined;
248
257
  const workerOptions: JobWorkerOptions = {
249
258
  pool: poolName,
250
259
  concurrency: def.concurrency,
251
260
  shutdownTimeoutMs:
252
261
  this.options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
262
+ pollIntervalMs: drizzleExt?.pollIntervalMs,
263
+ listenNotify: drizzleExt?.listenNotify,
253
264
  };
254
265
  const worker = this.options.workerFactory
255
266
  ? this.options.workerFactory(workerOptions)
@@ -37,6 +37,7 @@ import {
37
37
  type SpawnChildOptions,
38
38
  type StepOptions,
39
39
  } from './job-handler.base';
40
+ import { JOBS_WAKE_CHANNEL, PgNotifyListener } from './pg-notify';
40
41
 
41
42
  /**
42
43
  * Options accepted by `JobWorker`. JOB-5 threads these through module
@@ -59,6 +60,14 @@ export interface JobWorkerOptions {
59
60
  staleThresholdMs?: number;
60
61
  /** Max ms to wait for in-flight drain on SIGTERM. Default 30_000. */
61
62
  shutdownTimeoutMs?: number;
63
+ /**
64
+ * LISTEN-NOTIFY-1 — when true, hold a dedicated listener connection and
65
+ * LISTEN on `codegen_jobs_wake`. A notification naming this worker's `pool`
66
+ * triggers an immediate (debounced) claim cycle, so an enqueue is claimed in
67
+ * milliseconds instead of waiting for the next `pollIntervalMs` tick. Polling
68
+ * continues unchanged as the fallback heartbeat. Default false.
69
+ */
70
+ listenNotify?: boolean;
62
71
  }
63
72
 
64
73
  // ADR-037: namespaced `Symbol.for(...)` (via `tokenKey()`) — matches by value
@@ -192,6 +201,15 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
192
201
  private readonly staleThresholdMs: number;
193
202
  private readonly shutdownTimeoutMs: number;
194
203
 
204
+ // LISTEN-NOTIFY-1 — dedicated listener + debounce state. `null` when
205
+ // `listenNotify` is off (the common case); polling is the only driver then.
206
+ private readonly listenNotifyEnabled: boolean;
207
+ private notifyListener: PgNotifyListener | null = null;
208
+ /** True while a wake-driven claim cycle is in flight (debounce gate). */
209
+ private wakeDraining = false;
210
+ /** A notify arrived mid-cycle → re-check once when the cycle ends. */
211
+ private wakeRecheckPending = false;
212
+
195
213
  constructor(
196
214
  @Inject(DRIZZLE) private readonly db: DrizzleClient,
197
215
  @Inject(JOB_ORCHESTRATOR) private readonly orchestrator: IJobOrchestrator,
@@ -206,6 +224,7 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
206
224
  this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
207
225
  this.shutdownTimeoutMs =
208
226
  options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
227
+ this.listenNotifyEnabled = options.listenNotify ?? false;
209
228
 
210
229
  this.sigtermHandler = () => {
211
230
  if (this.sigtermHandled) return;
@@ -227,6 +246,74 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
227
246
  void this.sweepStaleClaims();
228
247
  }, this.staleSweeperIntervalMs);
229
248
  process.on('SIGTERM', this.sigtermHandler);
249
+
250
+ // LISTEN-NOTIFY-1 — start the wake listener ALONGSIDE the poll timer (never
251
+ // instead). A notify for this worker's pool drives an immediate claim cycle;
252
+ // the interval timer above stays the durability heartbeat. Listener startup
253
+ // is fire-and-forget: a connect failure self-heals via the listener's own
254
+ // backoff, and until it's up the poll loop is the sole driver.
255
+ if (this.listenNotifyEnabled) {
256
+ // The DRIZZLE provider wraps a `pg.Pool`, exposed by drizzle as `$client`.
257
+ const pool = (this.db as unknown as { $client?: unknown }).$client;
258
+ if (!pool || typeof (pool as { connect?: unknown }).connect !== 'function') {
259
+ this.logger.warn(
260
+ `listen_notify enabled but the Drizzle client exposes no pg Pool ` +
261
+ `($client.connect missing) — falling back to interval polling only.`,
262
+ );
263
+ } else {
264
+ this.notifyListener = new PgNotifyListener({
265
+ channel: JOBS_WAKE_CHANNEL,
266
+ pool: pool as { connect(): Promise<never> },
267
+ label: `jobs:${this.options.pool}`,
268
+ onNotify: (payload) => this.onWake(payload),
269
+ });
270
+ void this.notifyListener.start();
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Wake handler — a `codegen_jobs_wake` notification arrived. Only payloads
277
+ * naming THIS worker's pool are relevant (other pools have their own workers).
278
+ * Debounced: if a claim cycle is already running we just flag a re-check so a
279
+ * burst of N enqueues collapses to at most one extra cycle (D3).
280
+ */
281
+ private onWake(payload: string): void {
282
+ if (this.shuttingDown) return;
283
+ if (payload !== this.options.pool) return;
284
+ if (this.wakeDraining) {
285
+ this.wakeRecheckPending = true;
286
+ return;
287
+ }
288
+ void this.drainOnWake();
289
+ }
290
+
291
+ /**
292
+ * Claim-until-empty on a wake. Unlike the interval `pollAndProcess` (one
293
+ * claim per tick), a wake drains greedily up to the concurrency ceiling so a
294
+ * burst that arrived together is dispatched without waiting for N ticks. The
295
+ * `wakeRecheckPending` flag coalesces notifies that land mid-drain.
296
+ */
297
+ private async drainOnWake(): Promise<void> {
298
+ this.wakeDraining = true;
299
+ try {
300
+ do {
301
+ this.wakeRecheckPending = false;
302
+ // Claim while there's capacity; pollAndProcess no-ops at the ceiling.
303
+ let progressed = true;
304
+ while (
305
+ progressed &&
306
+ !this.shuttingDown &&
307
+ this.inFlight.size < this.options.concurrency
308
+ ) {
309
+ const before = this.inFlight.size;
310
+ await this.pollAndProcess();
311
+ progressed = this.inFlight.size > before;
312
+ }
313
+ } while (this.wakeRecheckPending && !this.shuttingDown);
314
+ } finally {
315
+ this.wakeDraining = false;
316
+ }
230
317
  }
231
318
 
232
319
  async onModuleDestroy(): Promise<void> {
@@ -246,6 +333,17 @@ export class JobWorker implements OnModuleInit, OnModuleDestroy {
246
333
  }
247
334
  process.removeListener('SIGTERM', this.sigtermHandler);
248
335
 
336
+ // LISTEN-NOTIFY-1 — release the listener connection so the process can exit
337
+ // cleanly. Best-effort; a failure here doesn't block the drain.
338
+ if (this.notifyListener) {
339
+ try {
340
+ await this.notifyListener.stop();
341
+ } catch (err) {
342
+ this.logger.error(`notify listener stop failed: ${(err as Error).message}`);
343
+ }
344
+ this.notifyListener = null;
345
+ }
346
+
249
347
  await this.drainInFlight();
250
348
 
251
349
  // Any rows still `running` past timeout → release back to pending.
@@ -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'));