@pattern-stack/codegen 0.15.3 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/consumer-skills/integration/change-sources-and-sinks.md +1 -1
  3. package/dist/{chunk-GCYKMF22.js → chunk-24WXSC3C.js} +6 -6
  4. package/dist/{chunk-32DOFN3T.js → chunk-2WDX6I7T.js} +2 -2
  5. package/dist/{chunk-WWGYCIJX.js → chunk-43SBT72G.js} +2 -2
  6. package/dist/{chunk-FBGHYQIZ.js → chunk-5LXOJGO2.js} +6 -6
  7. package/dist/{chunk-32BMMV4H.js → chunk-5RT7JGKT.js} +5 -5
  8. package/dist/{chunk-3NMCDN7L.js → chunk-5TK7MEN4.js} +2 -2
  9. package/dist/chunk-5TK7MEN4.js.map +1 -0
  10. package/dist/{chunk-4H3PETLM.js → chunk-AYC2HEAL.js} +12 -9
  11. package/dist/chunk-AYC2HEAL.js.map +1 -0
  12. package/dist/{chunk-27ETSJ2X.js → chunk-COGHTKXY.js} +2 -2
  13. package/dist/{chunk-IYNSRIGR.js → chunk-CRBVI4GE.js} +5 -5
  14. package/dist/{chunk-J7JMVS2B.js → chunk-CZQUOIDY.js} +4 -4
  15. package/dist/{chunk-O37C3YE6.js → chunk-DGYTSCKN.js} +14 -8
  16. package/dist/chunk-DGYTSCKN.js.map +1 -0
  17. package/dist/{chunk-L7BNNRGI.js → chunk-DLG62MQY.js} +26 -6
  18. package/dist/chunk-DLG62MQY.js.map +1 -0
  19. package/dist/{chunk-TNXH7BJS.js → chunk-E45CSC33.js} +2 -2
  20. package/dist/{chunk-4JLJYWJC.js → chunk-H6FO2ZDJ.js} +99 -11
  21. package/dist/chunk-H6FO2ZDJ.js.map +1 -0
  22. package/dist/{chunk-5Y7W3XR6.js → chunk-IT6FRTEW.js} +30 -11
  23. package/dist/chunk-IT6FRTEW.js.map +1 -0
  24. package/dist/{chunk-RC23QROE.js → chunk-JM3T27ZW.js} +78 -4
  25. package/dist/chunk-JM3T27ZW.js.map +1 -0
  26. package/dist/{chunk-Z7PQCAVK.js → chunk-LQ6PYFU6.js} +4 -4
  27. package/dist/chunk-MYQIQ27N.js +118 -0
  28. package/dist/chunk-MYQIQ27N.js.map +1 -0
  29. package/dist/{chunk-YTN6BKWA.js → chunk-NXNVTXKG.js} +5 -5
  30. package/dist/{chunk-RDVTWIYY.js → chunk-QSJ3J4HE.js} +5 -5
  31. package/dist/{chunk-4MVGAMUA.js → chunk-RUSUZZAF.js} +4 -4
  32. package/dist/{chunk-4RFHUZXU.js → chunk-T4YJRD22.js} +4 -4
  33. package/dist/{chunk-7YGORYZD.js → chunk-T6C4LFLC.js} +4 -4
  34. package/dist/{chunk-OGIZXGPY.js → chunk-TDEHU73T.js} +4 -4
  35. package/dist/{chunk-YLPAPPLW.js → chunk-TIZXQU26.js} +36 -9
  36. package/dist/chunk-TIZXQU26.js.map +1 -0
  37. package/dist/{chunk-EOLLMEAH.js → chunk-TKVTEUBD.js} +3 -3
  38. package/dist/chunk-TKVTEUBD.js.map +1 -0
  39. package/dist/{chunk-YPWODKD5.js → chunk-W2UIDI3R.js} +5 -5
  40. package/dist/chunk-W4HOHZVF.js +1 -0
  41. package/dist/{chunk-DCCZB4UC.js → chunk-XWBK3XJK.js} +4 -4
  42. package/dist/{chunk-SR7F3TJY.js → chunk-YK5JEVLX.js} +4 -4
  43. package/dist/{chunk-BIO6F7YI.js → chunk-ZPL74UQN.js} +4 -2
  44. package/dist/{chunk-BIO6F7YI.js.map → chunk-ZPL74UQN.js.map} +1 -1
  45. package/dist/runtime/base-classes/index.js +22 -22
  46. package/dist/runtime/subsystems/analytics/analytics.module.js +2 -2
  47. package/dist/runtime/subsystems/analytics/index.js +4 -4
  48. package/dist/runtime/subsystems/auth/auth.module.js +1 -1
  49. package/dist/runtime/subsystems/auth/index.js +7 -7
  50. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js +3 -3
  51. package/dist/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.js +1 -1
  52. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.d.ts +2 -1
  53. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js +6 -5
  54. package/dist/runtime/subsystems/bridge/bridge.module.js +16 -15
  55. package/dist/runtime/subsystems/bridge/event-flow.service.js +3 -3
  56. package/dist/runtime/subsystems/bridge/index.js +16 -15
  57. package/dist/runtime/subsystems/cache/cache.drizzle-backend.js +2 -2
  58. package/dist/runtime/subsystems/cache/cache.module.js +3 -3
  59. package/dist/runtime/subsystems/cache/index.js +5 -5
  60. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +20 -0
  61. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +4 -3
  62. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +2 -2
  63. package/dist/runtime/subsystems/events/events.module.d.ts +14 -0
  64. package/dist/runtime/subsystems/events/events.module.js +6 -5
  65. package/dist/runtime/subsystems/events/index.js +12 -11
  66. package/dist/runtime/subsystems/index.js +88 -87
  67. package/dist/runtime/subsystems/integration/build-change-source.js +2 -2
  68. package/dist/runtime/subsystems/integration/detection-config.schema.d.ts +23 -15
  69. package/dist/runtime/subsystems/integration/detection-config.schema.js +1 -1
  70. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  71. package/dist/runtime/subsystems/integration/index.js +17 -17
  72. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +2 -2
  73. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +2 -2
  74. package/dist/runtime/subsystems/integration/integration.module.js +4 -4
  75. package/dist/runtime/subsystems/integration/webhook-change-source.d.ts +36 -6
  76. package/dist/runtime/subsystems/integration/webhook-change-source.js +1 -1
  77. package/dist/runtime/subsystems/jobs/index.d.ts +2 -1
  78. package/dist/runtime/subsystems/jobs/index.js +42 -30
  79. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +6 -5
  80. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -1
  81. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.d.ts +2 -1
  82. package/dist/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.js +4 -3
  83. package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +3 -3
  84. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +3 -3
  85. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  86. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +3 -3
  87. package/dist/runtime/subsystems/jobs/job-worker.d.ts +28 -0
  88. package/dist/runtime/subsystems/jobs/job-worker.js +4 -3
  89. package/dist/runtime/subsystems/jobs/job-worker.module.js +11 -10
  90. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +12 -7
  91. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -8
  92. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.d.ts +13 -1
  93. package/dist/runtime/subsystems/jobs/jobs-domain.tokens.js +3 -1
  94. package/dist/runtime/subsystems/jobs/pg-notify.d.ts +85 -0
  95. package/dist/runtime/subsystems/jobs/pg-notify.js +14 -0
  96. package/dist/runtime/subsystems/jobs/pg-notify.js.map +1 -0
  97. package/dist/runtime/subsystems/observability/index.js +4 -4
  98. package/dist/runtime/subsystems/observability/observability.module.js +4 -4
  99. package/dist/runtime/subsystems/observability/observability.service.js +3 -3
  100. package/dist/runtime/subsystems/storage/index.js +4 -4
  101. package/dist/runtime/subsystems/storage/storage.module.js +2 -2
  102. package/dist/src/cli/index.js +53 -15
  103. package/dist/src/cli/index.js.map +1 -1
  104. package/dist/src/index.d.ts +11 -11
  105. package/dist/src/index.js +9 -9
  106. package/package.json +1 -1
  107. package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +27 -0
  108. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +108 -4
  109. package/runtime/subsystems/events/events.module.ts +14 -0
  110. package/runtime/subsystems/integration/detection-config.schema.ts +64 -54
  111. package/runtime/subsystems/integration/webhook-change-source.ts +187 -133
  112. package/runtime/subsystems/jobs/index.ts +10 -0
  113. package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +29 -2
  114. package/runtime/subsystems/jobs/job-worker.module.ts +11 -0
  115. package/runtime/subsystems/jobs/job-worker.ts +98 -0
  116. package/runtime/subsystems/jobs/jobs-domain.module.ts +22 -7
  117. package/runtime/subsystems/jobs/jobs-domain.tokens.ts +13 -0
  118. package/runtime/subsystems/jobs/pg-notify.ts +216 -0
  119. package/templates/subsystem/events-config/codegen-config-events-block.ejs.t +14 -0
  120. package/templates/subsystem/jobs-config/codegen-config-jobs-block.ejs.t +13 -4
  121. package/dist/chunk-3NMCDN7L.js.map +0 -1
  122. package/dist/chunk-4H3PETLM.js.map +0 -1
  123. package/dist/chunk-4JLJYWJC.js.map +0 -1
  124. package/dist/chunk-5Y7W3XR6.js.map +0 -1
  125. package/dist/chunk-EOLLMEAH.js.map +0 -1
  126. package/dist/chunk-L7BNNRGI.js.map +0 -1
  127. package/dist/chunk-O37C3YE6.js.map +0 -1
  128. package/dist/chunk-RC23QROE.js.map +0 -1
  129. package/dist/chunk-UTN4GBPQ.js +0 -1
  130. package/dist/chunk-YLPAPPLW.js.map +0 -1
  131. /package/dist/{chunk-GCYKMF22.js.map → chunk-24WXSC3C.js.map} +0 -0
  132. /package/dist/{chunk-32DOFN3T.js.map → chunk-2WDX6I7T.js.map} +0 -0
  133. /package/dist/{chunk-WWGYCIJX.js.map → chunk-43SBT72G.js.map} +0 -0
  134. /package/dist/{chunk-FBGHYQIZ.js.map → chunk-5LXOJGO2.js.map} +0 -0
  135. /package/dist/{chunk-32BMMV4H.js.map → chunk-5RT7JGKT.js.map} +0 -0
  136. /package/dist/{chunk-27ETSJ2X.js.map → chunk-COGHTKXY.js.map} +0 -0
  137. /package/dist/{chunk-IYNSRIGR.js.map → chunk-CRBVI4GE.js.map} +0 -0
  138. /package/dist/{chunk-J7JMVS2B.js.map → chunk-CZQUOIDY.js.map} +0 -0
  139. /package/dist/{chunk-TNXH7BJS.js.map → chunk-E45CSC33.js.map} +0 -0
  140. /package/dist/{chunk-Z7PQCAVK.js.map → chunk-LQ6PYFU6.js.map} +0 -0
  141. /package/dist/{chunk-YTN6BKWA.js.map → chunk-NXNVTXKG.js.map} +0 -0
  142. /package/dist/{chunk-RDVTWIYY.js.map → chunk-QSJ3J4HE.js.map} +0 -0
  143. /package/dist/{chunk-4MVGAMUA.js.map → chunk-RUSUZZAF.js.map} +0 -0
  144. /package/dist/{chunk-4RFHUZXU.js.map → chunk-T4YJRD22.js.map} +0 -0
  145. /package/dist/{chunk-7YGORYZD.js.map → chunk-T6C4LFLC.js.map} +0 -0
  146. /package/dist/{chunk-OGIZXGPY.js.map → chunk-TDEHU73T.js.map} +0 -0
  147. /package/dist/{chunk-YPWODKD5.js.map → chunk-W2UIDI3R.js.map} +0 -0
  148. /package/dist/{chunk-UTN4GBPQ.js.map → chunk-W4HOHZVF.js.map} +0 -0
  149. /package/dist/{chunk-DCCZB4UC.js.map → chunk-XWBK3XJK.js.map} +0 -0
  150. /package/dist/{chunk-SR7F3TJY.js.map → chunk-YK5JEVLX.js.map} +0 -0
@@ -1681,11 +1681,11 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
1681
1681
  }>, z.ZodObject<{
1682
1682
  mode: z.ZodLiteral<"webhook">;
1683
1683
  webhook: z.ZodObject<{
1684
- eventIdField: z.ZodString;
1684
+ eventIdField: z.ZodOptional<z.ZodString>;
1685
1685
  }, "strip", z.ZodTypeAny, {
1686
- eventIdField: string;
1686
+ eventIdField?: string | undefined;
1687
1687
  }, {
1688
- eventIdField: string;
1688
+ eventIdField?: string | undefined;
1689
1689
  }>;
1690
1690
  mapping: z.ZodArray<z.ZodObject<{
1691
1691
  source: z.ZodString;
@@ -1715,7 +1715,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
1715
1715
  }>, "many">>;
1716
1716
  }, "strip", z.ZodTypeAny, {
1717
1717
  webhook: {
1718
- eventIdField: string;
1718
+ eventIdField?: string | undefined;
1719
1719
  };
1720
1720
  mode: "webhook";
1721
1721
  mapping: {
@@ -1730,7 +1730,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
1730
1730
  }[];
1731
1731
  }, {
1732
1732
  webhook: {
1733
- eventIdField: string;
1733
+ eventIdField?: string | undefined;
1734
1734
  };
1735
1735
  mode: "webhook";
1736
1736
  mapping: {
@@ -2156,7 +2156,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
2156
2156
  }[];
2157
2157
  } | {
2158
2158
  webhook: {
2159
- eventIdField: string;
2159
+ eventIdField?: string | undefined;
2160
2160
  };
2161
2161
  mode: "webhook";
2162
2162
  mapping: {
@@ -2366,7 +2366,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
2366
2366
  }[] | undefined;
2367
2367
  } | {
2368
2368
  webhook: {
2369
- eventIdField: string;
2369
+ eventIdField?: string | undefined;
2370
2370
  };
2371
2371
  mode: "webhook";
2372
2372
  mapping: {
@@ -2576,7 +2576,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
2576
2576
  }[];
2577
2577
  } | {
2578
2578
  webhook: {
2579
- eventIdField: string;
2579
+ eventIdField?: string | undefined;
2580
2580
  };
2581
2581
  mode: "webhook";
2582
2582
  mapping: {
@@ -2786,7 +2786,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
2786
2786
  }[] | undefined;
2787
2787
  } | {
2788
2788
  webhook: {
2789
- eventIdField: string;
2789
+ eventIdField?: string | undefined;
2790
2790
  };
2791
2791
  mode: "webhook";
2792
2792
  mapping: {
@@ -2996,7 +2996,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
2996
2996
  }[];
2997
2997
  } | {
2998
2998
  webhook: {
2999
- eventIdField: string;
2999
+ eventIdField?: string | undefined;
3000
3000
  };
3001
3001
  mode: "webhook";
3002
3002
  mapping: {
@@ -3206,7 +3206,7 @@ declare const EntityDefinitionSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
3206
3206
  }[] | undefined;
3207
3207
  } | {
3208
3208
  webhook: {
3209
- eventIdField: string;
3209
+ eventIdField?: string | undefined;
3210
3210
  };
3211
3211
  mode: "webhook";
3212
3212
  mapping: {
package/dist/src/index.js CHANGED
@@ -44,27 +44,27 @@ import {
44
44
  validateOrchestrationProject,
45
45
  validatePatternComposition,
46
46
  validatePatternProject
47
- } from "../chunk-32DOFN3T.js";
47
+ } from "../chunk-2WDX6I7T.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
- import "../chunk-3NMCDN7L.js";
56
+ import "../chunk-5TK7MEN4.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";
63
- import "../chunk-WWGYCIJX.js";
64
+ import "../chunk-43SBT72G.js";
64
65
  import "../chunk-4MF3HKJA.js";
65
- import "../chunk-YLPAPPLW.js";
66
+ import "../chunk-TIZXQU26.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.1",
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,10 +22,12 @@
22
22
  * primitive while emitting `Change<T>.source = 'cdc'`. Long-lived
23
23
  * streaming CDC (SFDC Pub-Sub, Debezium) is a separate primitive
24
24
  * deferred to #226-8.
25
- * - `webhook` mode requires `eventIdField` so `WebhookChangeSource<T>`
26
- * can populate `Change<T>.dedupKey` from the inbound staging row.
25
+ * - `webhook` mode's `eventIdField` is optional: `WebhookChangeSource<T>`
26
+ * prefers an `eventId` yielded by the queue iterator and falls back to the
27
+ * `eventIdField` record extraction (precedence: yielded eventId >
28
+ * eventIdField extraction > undefined dedupKey).
27
29
  */
28
- import { z } from 'zod';
30
+ import { z } from "zod";
29
31
 
30
32
  // ============================================================================
31
33
  // Field mapping — provider field → canonical target
@@ -37,9 +39,9 @@ import { z } from 'zod';
37
39
  * etc.); the schema does not enumerate transforms — adapters interpret them.
38
40
  */
39
41
  export const FieldMappingSchema = z.object({
40
- source: z.string().min(1),
41
- target: z.string().min(1),
42
- transform: z.string().min(1).optional(),
42
+ source: z.string().min(1),
43
+ target: z.string().min(1),
44
+ transform: z.string().min(1).optional(),
43
45
  });
44
46
 
45
47
  export type FieldMapping = z.infer<typeof FieldMappingSchema>;
@@ -54,9 +56,9 @@ export type FieldMapping = z.infer<typeof FieldMappingSchema>;
54
56
  * adapters interpret per provider.
55
57
  */
56
58
  export const ResolvedFilterSchema = z.object({
57
- field: z.string().min(1),
58
- op: z.enum(['eq', 'neq', 'in', 'nin', 'gt', 'gte', 'lt', 'lte']),
59
- value: z.unknown(),
59
+ field: z.string().min(1),
60
+ op: z.enum(["eq", "neq", "in", "nin", "gt", "gte", "lt", "lte"]),
61
+ value: z.unknown(),
60
62
  });
61
63
 
62
64
  export type ResolvedFilter = z.infer<typeof ResolvedFilterSchema>;
@@ -66,23 +68,23 @@ export type ResolvedFilter = z.infer<typeof ResolvedFilterSchema>;
66
68
  // ============================================================================
67
69
 
68
70
  const SystemModstampCursorSchema = z.object({
69
- kind: z.literal('systemModstamp'),
70
- field: z.string().min(1),
71
+ kind: z.literal("systemModstamp"),
72
+ field: z.string().min(1),
71
73
  });
72
74
 
73
75
  const ReplayIdCursorSchema = z.object({
74
- kind: z.literal('replayId'),
75
- field: z.string().min(1),
76
+ kind: z.literal("replayId"),
77
+ field: z.string().min(1),
76
78
  });
77
79
 
78
80
  const TimestampCursorSchema = z.object({
79
- kind: z.literal('timestamp'),
80
- field: z.string().min(1),
81
+ kind: z.literal("timestamp"),
82
+ field: z.string().min(1),
81
83
  });
82
84
 
83
85
  const EventIdCursorSchema = z.object({
84
- kind: z.literal('eventId'),
85
- field: z.string().min(1),
86
+ kind: z.literal("eventId"),
87
+ field: z.string().min(1),
86
88
  });
87
89
 
88
90
  /**
@@ -91,8 +93,8 @@ const EventIdCursorSchema = z.object({
91
93
  * `field` is metadata for codegen/adapters (the response key the token lives on).
92
94
  */
93
95
  const HistoryIdCursorSchema = z.object({
94
- kind: z.literal('historyId'),
95
- field: z.string().min(1),
96
+ kind: z.literal("historyId"),
97
+ field: z.string().min(1),
96
98
  });
97
99
 
98
100
  /**
@@ -100,17 +102,17 @@ const HistoryIdCursorSchema = z.object({
100
102
  * same divisibility profile as `historyId`.
101
103
  */
102
104
  const SyncTokenCursorSchema = z.object({
103
- kind: z.literal('syncToken'),
104
- field: z.string().min(1),
105
+ kind: z.literal("syncToken"),
106
+ field: z.string().min(1),
105
107
  });
106
108
 
107
- export const CursorStrategySchema = z.discriminatedUnion('kind', [
108
- SystemModstampCursorSchema,
109
- ReplayIdCursorSchema,
110
- TimestampCursorSchema,
111
- EventIdCursorSchema,
112
- HistoryIdCursorSchema,
113
- SyncTokenCursorSchema,
109
+ export const CursorStrategySchema = z.discriminatedUnion("kind", [
110
+ SystemModstampCursorSchema,
111
+ ReplayIdCursorSchema,
112
+ TimestampCursorSchema,
113
+ EventIdCursorSchema,
114
+ HistoryIdCursorSchema,
115
+ SyncTokenCursorSchema,
114
116
  ]);
115
117
 
116
118
  export type CursorStrategy = z.infer<typeof CursorStrategySchema>;
@@ -135,18 +137,20 @@ export type CursorStrategy = z.infer<typeof CursorStrategySchema>;
135
137
  * `eventId` is classified atomic conservatively: a generic opaque id is treated
136
138
  * all-or-nothing unless a concrete strategy proves it monotonically resumable.
137
139
  */
138
- export const CURSOR_DIVISIBILITY: Readonly<Record<CursorStrategy['kind'], boolean>> = {
139
- systemModstamp: true,
140
- timestamp: true,
141
- replayId: true,
142
- eventId: false,
143
- historyId: false,
144
- syncToken: false,
140
+ export const CURSOR_DIVISIBILITY: Readonly<
141
+ Record<CursorStrategy["kind"], boolean>
142
+ > = {
143
+ systemModstamp: true,
144
+ timestamp: true,
145
+ replayId: true,
146
+ eventId: false,
147
+ historyId: false,
148
+ syncToken: false,
145
149
  };
146
150
 
147
151
  /** Predicate form of {@link CURSOR_DIVISIBILITY}. */
148
- export function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {
149
- return CURSOR_DIVISIBILITY[kind];
152
+ export function isDivisibleCursor(kind: CursorStrategy["kind"]): boolean {
153
+ return CURSOR_DIVISIBILITY[kind];
150
154
  }
151
155
 
152
156
  // ============================================================================
@@ -159,19 +163,25 @@ export function isDivisibleCursor(kind: CursorStrategy['kind']): boolean {
159
163
  * `field` — used for Stripe-style event endpoints. Defaults to `'poll'`.
160
164
  */
161
165
  export const PollDetectionSchema = z.object({
162
- cursor: CursorStrategySchema,
163
- provenance: z.enum(['poll', 'cdc']).optional(),
166
+ cursor: CursorStrategySchema,
167
+ provenance: z.enum(["poll", "cdc"]).optional(),
164
168
  });
165
169
 
166
170
  export type PollDetection = z.infer<typeof PollDetectionSchema>;
167
171
 
168
172
  /**
169
- * Webhook-mode block. `eventIdField` names the column in the consumer-owned
170
- * inbound staging row that `WebhookChangeSource<T>` reads to set
171
- * `Change<T>.dedupKey`.
173
+ * Webhook-mode block. `eventIdField`, when present, names the field on the
174
+ * emitted canonical record that `WebhookChangeSource<T>` reads to set
175
+ * `Change<T>.dedupKey` — used only as the fallback when the queue iterator
176
+ * does NOT yield an `eventId` alongside the record.
177
+ *
178
+ * `eventIdField` is **optional**: a queue iterator that always yields an
179
+ * `eventId` (vendor delivery metadata, the preferred channel) need not declare
180
+ * a record field for it. dedupKey precedence is: yielded `eventId` >
181
+ * `eventIdField` record extraction > undefined.
172
182
  */
173
183
  export const WebhookDetectionSchema = z.object({
174
- eventIdField: z.string().min(1),
184
+ eventIdField: z.string().min(1).optional(),
175
185
  });
176
186
 
177
187
  export type WebhookDetection = z.infer<typeof WebhookDetectionSchema>;
@@ -181,17 +191,17 @@ export type WebhookDetection = z.infer<typeof WebhookDetectionSchema>;
181
191
  // ============================================================================
182
192
 
183
193
  const PollModeSchema = z.object({
184
- mode: z.literal('poll'),
185
- poll: PollDetectionSchema,
186
- mapping: z.array(FieldMappingSchema).min(1),
187
- filters: z.array(ResolvedFilterSchema).default([]),
194
+ mode: z.literal("poll"),
195
+ poll: PollDetectionSchema,
196
+ mapping: z.array(FieldMappingSchema).min(1),
197
+ filters: z.array(ResolvedFilterSchema).default([]),
188
198
  });
189
199
 
190
200
  const WebhookModeSchema = z.object({
191
- mode: z.literal('webhook'),
192
- webhook: WebhookDetectionSchema,
193
- mapping: z.array(FieldMappingSchema).min(1),
194
- filters: z.array(ResolvedFilterSchema).default([]),
201
+ mode: z.literal("webhook"),
202
+ webhook: WebhookDetectionSchema,
203
+ mapping: z.array(FieldMappingSchema).min(1),
204
+ filters: z.array(ResolvedFilterSchema).default([]),
195
205
  });
196
206
 
197
207
  /**
@@ -201,9 +211,9 @@ const WebhookModeSchema = z.object({
201
211
  * (Stripe-style event endpoints) is expressed via `mode: 'poll'` with
202
212
  * `poll.provenance: 'cdc'`.
203
213
  */
204
- export const DetectionConfigSchema = z.discriminatedUnion('mode', [
205
- PollModeSchema,
206
- WebhookModeSchema,
214
+ export const DetectionConfigSchema = z.discriminatedUnion("mode", [
215
+ PollModeSchema,
216
+ WebhookModeSchema,
207
217
  ]);
208
218
 
209
219
  export type DetectionConfig = z.infer<typeof DetectionConfigSchema>;