@pattern-stack/codegen 0.8.1 → 0.9.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 (107) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
  3. package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
  4. package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
  5. package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
  6. package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
  7. package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
  8. package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
  9. package/dist/runtime/subsystems/bridge/index.js +837 -182
  10. package/dist/runtime/subsystems/bridge/index.js.map +1 -1
  11. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
  12. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
  13. package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
  14. package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
  15. package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
  16. package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
  17. package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
  18. package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
  19. package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
  20. package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
  21. package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
  22. package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
  23. package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
  24. package/dist/runtime/subsystems/events/events.module.js +177 -3
  25. package/dist/runtime/subsystems/events/events.module.js.map +1 -1
  26. package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
  27. package/dist/runtime/subsystems/events/events.tokens.js +2 -0
  28. package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
  29. package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
  30. package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
  31. package/dist/runtime/subsystems/events/index.d.ts +2 -1
  32. package/dist/runtime/subsystems/events/index.js +178 -3
  33. package/dist/runtime/subsystems/events/index.js.map +1 -1
  34. package/dist/runtime/subsystems/index.d.ts +1 -0
  35. package/dist/runtime/subsystems/index.js +1194 -264
  36. package/dist/runtime/subsystems/index.js.map +1 -1
  37. package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
  38. package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
  39. package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
  40. package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
  41. package/dist/runtime/subsystems/jobs/index.js +861 -201
  42. package/dist/runtime/subsystems/jobs/index.js.map +1 -1
  43. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
  44. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
  45. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
  46. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
  47. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
  48. package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
  49. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
  50. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
  51. package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
  52. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
  53. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
  54. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
  55. package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
  56. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
  57. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
  58. package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
  59. package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
  60. package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
  61. package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
  62. package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
  63. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
  64. package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
  65. package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
  66. package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
  67. package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
  68. package/dist/runtime/subsystems/observability/index.d.ts +4 -3
  69. package/dist/runtime/subsystems/observability/index.js +109 -2
  70. package/dist/runtime/subsystems/observability/index.js.map +1 -1
  71. package/dist/runtime/subsystems/observability/observability.module.js +109 -2
  72. package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
  73. package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
  74. package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
  75. package/dist/runtime/subsystems/observability/observability.service.js +109 -2
  76. package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
  77. package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
  78. package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
  79. package/dist/src/cli/index.js +30 -6
  80. package/dist/src/cli/index.js.map +1 -1
  81. package/package.json +1 -1
  82. package/runtime/subsystems/bridge/bridge.module.ts +5 -0
  83. package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
  84. package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
  85. package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
  86. package/runtime/subsystems/events/event-read.protocol.ts +97 -0
  87. package/runtime/subsystems/events/events.module.ts +18 -2
  88. package/runtime/subsystems/events/events.tokens.ts +16 -0
  89. package/runtime/subsystems/events/index.ts +7 -0
  90. package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
  91. package/runtime/subsystems/jobs/index.ts +22 -0
  92. package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
  93. package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
  94. package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
  95. package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
  96. package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
  97. package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
  98. package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
  99. package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
  100. package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
  101. package/runtime/subsystems/observability/index.ts +8 -0
  102. package/runtime/subsystems/observability/observability.protocol.ts +76 -0
  103. package/runtime/subsystems/observability/observability.service.ts +148 -1
  104. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
  105. package/templates/relationship/new/prompt.js +8 -5
  106. package/templates/subsystem/jobs/worker.ejs.t +30 -7
  107. package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -149,6 +149,11 @@ export class BridgeModule implements OnModuleInit {
149
149
 
150
150
  async onModuleInit(): Promise<void> {
151
151
  if (!this.workerOpts) return;
152
+ // BULLMQ-1 Phase 1 — `allPools: true` activates every pool (reserved
153
+ // `events_*` included), so the reserved-pool guarantee holds by
154
+ // construction. Short-circuit pass without inspecting the (typically
155
+ // omitted) explicit `pools` list.
156
+ if (this.workerOpts.allPools) return;
152
157
  const activePools = this.workerOpts.pools ?? [];
153
158
  const missing = BRIDGE_RESERVED_POOLS.filter(
154
159
  (p) => !activePools.includes(p),
@@ -29,10 +29,20 @@
29
29
  * via EventsModule.forRoot({ backend: '...' }) without touching use cases.
30
30
  */
31
31
  import { Injectable, OnModuleDestroy, OnModuleInit, Inject, Logger, Optional } from '@nestjs/common';
32
- import { eq, and, inArray, asc, type SQL } from 'drizzle-orm';
32
+ import { eq, and, inArray, asc, desc, gte, lt, or, sql, type SQL } from 'drizzle-orm';
33
33
  import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
34
+ import type {
35
+ EventPage,
36
+ IEventReadPort,
37
+ ListEventsQuery,
38
+ } from './event-read.protocol';
39
+ import {
40
+ clampEventLimit,
41
+ decodeEventCursor,
42
+ encodeEventCursor,
43
+ } from './event-keyset-cursor';
34
44
  import type { DrizzleClient } from '../../types/drizzle';
35
- import { domainEvents } from './domain-events.schema';
45
+ import { domainEvents, type DomainEventRecord } from './domain-events.schema';
36
46
  import { DRIZZLE } from '../../constants/tokens';
37
47
  import { EVENTS_MODULE_OPTIONS } from './events.tokens';
38
48
  import type { EventsModuleOptions } from './events.module';
@@ -76,8 +86,43 @@ function toInsertValues(event: DomainEvent) {
76
86
  };
77
87
  }
78
88
 
89
+ /**
90
+ * Project a raw `domain_events` row into the narrow `EventSummary` shape.
91
+ * Shared with the memory backend via this helper kept module-local to each
92
+ * backend (the events subsystem has no cross-backend projection file yet;
93
+ * the two are byte-identical and small).
94
+ */
95
+ function toEventSummary(r: DomainEventRecord) {
96
+ const metadata = (r.metadata ?? undefined) as
97
+ | Record<string, unknown>
98
+ | undefined;
99
+ const rootRunId = metadata?.['rootRunId'];
100
+ return {
101
+ id: r.id,
102
+ type: r.type,
103
+ aggregateId: r.aggregateId,
104
+ aggregateType: r.aggregateType,
105
+ status: r.status,
106
+ pool: r.pool,
107
+ direction: r.direction,
108
+ tier: r.tier,
109
+ rootRunId: typeof rootRunId === 'string' ? rootRunId : null,
110
+ tenantId: r.tenantId,
111
+ occurredAt:
112
+ r.occurredAt instanceof Date
113
+ ? r.occurredAt
114
+ : new Date(r.occurredAt as unknown as string),
115
+ processedAt:
116
+ r.processedAt == null
117
+ ? null
118
+ : r.processedAt instanceof Date
119
+ ? r.processedAt
120
+ : new Date(r.processedAt as unknown as string),
121
+ };
122
+ }
123
+
79
124
  @Injectable()
80
- export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy {
125
+ export class DrizzleEventBus implements IEventBus, IEventReadPort, OnModuleInit, OnModuleDestroy {
81
126
  private readonly logger = new Logger(DrizzleEventBus.name);
82
127
  private polling = false;
83
128
  private pollTimer: ReturnType<typeof setTimeout> | null = null;
@@ -178,6 +223,67 @@ export class DrizzleEventBus implements IEventBus, OnModuleInit, OnModuleDestroy
178
223
  };
179
224
  }
180
225
 
226
+ // ============================================================================
227
+ // IEventReadPort (OBS-LIST-1)
228
+ // ============================================================================
229
+
230
+ async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {
231
+ const limit = clampEventLimit(query.limit);
232
+ const conditions: SQL<unknown>[] = [];
233
+
234
+ if (query.poolId) conditions.push(eq(domainEvents.pool, query.poolId));
235
+ if (query.direction)
236
+ conditions.push(eq(domainEvents.direction, query.direction));
237
+ if (query.since) conditions.push(gte(domainEvents.occurredAt, query.since));
238
+ if (query.rootRunId) {
239
+ // Filter on the JSON correlation id: metadata->>'rootRunId'.
240
+ conditions.push(
241
+ sql`${domainEvents.metadata}->>'rootRunId' = ${query.rootRunId}`,
242
+ );
243
+ }
244
+ if (query.tenantId !== undefined) {
245
+ conditions.push(
246
+ query.tenantId === null
247
+ ? (sql`${domainEvents.tenantId} is null` as SQL<unknown>)
248
+ : eq(domainEvents.tenantId, query.tenantId),
249
+ );
250
+ }
251
+
252
+ // Keyset seek: WHERE (occurred_at, id) < (cursorOccurredAt, cursorId).
253
+ if (query.cursor) {
254
+ const keyset = decodeEventCursor(query.cursor);
255
+ if (keyset) {
256
+ conditions.push(
257
+ or(
258
+ lt(domainEvents.occurredAt, keyset.occurredAt),
259
+ and(
260
+ eq(domainEvents.occurredAt, keyset.occurredAt),
261
+ lt(domainEvents.id, keyset.id),
262
+ ),
263
+ )!,
264
+ );
265
+ }
266
+ }
267
+
268
+ const rows = (await this.db
269
+ .select()
270
+ .from(domainEvents)
271
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
272
+ .orderBy(desc(domainEvents.occurredAt), desc(domainEvents.id))
273
+ .limit(limit + 1)) as DomainEventRecord[];
274
+
275
+ const hasMore = rows.length > limit;
276
+ const page = hasMore ? rows.slice(0, limit) : rows;
277
+ const items = page.map(toEventSummary);
278
+ const last = page[page.length - 1];
279
+ const nextCursor =
280
+ hasMore && last
281
+ ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })
282
+ : null;
283
+
284
+ return { items, nextCursor };
285
+ }
286
+
181
287
  // ============================================================================
182
288
  // Polling
183
289
  // ============================================================================
@@ -21,11 +21,52 @@
21
21
  */
22
22
  import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
23
23
  import type { DomainEvent, IEventBus } from './event-bus.protocol';
24
+ import type {
25
+ EventPage,
26
+ EventSummary,
27
+ IEventReadPort,
28
+ ListEventsQuery,
29
+ } from './event-read.protocol';
30
+ import {
31
+ clampEventLimit,
32
+ decodeEventCursor,
33
+ encodeEventCursor,
34
+ } from './event-keyset-cursor';
24
35
  import { EVENTS_MODULE_OPTIONS } from './events.tokens';
25
36
  import type { EventsModuleOptions } from './events.module';
26
37
 
38
+ /**
39
+ * Project an in-memory `DomainEvent` into the narrow `EventSummary` shape.
40
+ * The memory backend has no first-class columns, so `pool` / `direction` /
41
+ * `tier` / `tenantId` / `rootRunId` are read from `metadata` (mirroring how
42
+ * the Drizzle backend stamps them onto columns at publish time). `status`
43
+ * is reported as `'processed'` — the memory bus dispatches synchronously,
44
+ * so once an event is in `publishedEvents` it has been handled.
45
+ */
46
+ function toEventSummary(event: DomainEvent): EventSummary {
47
+ const metadata = event.metadata;
48
+ const str = (key: string): string | null => {
49
+ const v = metadata?.[key];
50
+ return typeof v === 'string' ? v : null;
51
+ };
52
+ return {
53
+ id: event.id,
54
+ type: event.type,
55
+ aggregateId: event.aggregateId,
56
+ aggregateType: event.aggregateType,
57
+ status: 'processed',
58
+ pool: str('pool'),
59
+ direction: str('direction'),
60
+ tier: str('tier') ?? 'domain',
61
+ rootRunId: str('rootRunId'),
62
+ tenantId: str('tenantId'),
63
+ occurredAt: event.occurredAt,
64
+ processedAt: event.occurredAt,
65
+ };
66
+ }
67
+
27
68
  @Injectable()
28
- export class MemoryEventBus implements IEventBus {
69
+ export class MemoryEventBus implements IEventBus, IEventReadPort {
29
70
  private readonly logger = new Logger(MemoryEventBus.name);
30
71
 
31
72
  /** All events published since construction (or last clear). */
@@ -86,6 +127,67 @@ export class MemoryEventBus implements IEventBus {
86
127
  };
87
128
  }
88
129
 
130
+ // ============================================================================
131
+ // IEventReadPort (OBS-LIST-1)
132
+ // ============================================================================
133
+
134
+ async listEvents(query: ListEventsQuery = {}): Promise<EventPage> {
135
+ const limit = clampEventLimit(query.limit);
136
+ const keyset = query.cursor ? decodeEventCursor(query.cursor) : null;
137
+
138
+ const str = (e: DomainEvent, key: string): string | null => {
139
+ const v = e.metadata?.[key];
140
+ return typeof v === 'string' ? v : null;
141
+ };
142
+
143
+ const matched = this.publishedEvents.filter((e) => {
144
+ if (query.poolId && str(e, 'pool') !== query.poolId) return false;
145
+ if (query.direction && str(e, 'direction') !== query.direction)
146
+ return false;
147
+ if (query.rootRunId && str(e, 'rootRunId') !== query.rootRunId)
148
+ return false;
149
+ if (query.since && e.occurredAt.getTime() < query.since.getTime())
150
+ return false;
151
+ if (query.tenantId !== undefined) {
152
+ const t = str(e, 'tenantId');
153
+ if (query.tenantId === null) {
154
+ if (t !== null) return false;
155
+ } else if (t !== query.tenantId) {
156
+ return false;
157
+ }
158
+ }
159
+ return true;
160
+ });
161
+
162
+ // Order occurred_at DESC, id DESC to match the Drizzle backend keyset.
163
+ matched.sort((a, b) => {
164
+ const dt = b.occurredAt.getTime() - a.occurredAt.getTime();
165
+ if (dt !== 0) return dt;
166
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
167
+ });
168
+
169
+ const seeked = keyset
170
+ ? matched.filter((e) => {
171
+ const ct = e.occurredAt.getTime();
172
+ const kt = keyset.occurredAt.getTime();
173
+ if (ct < kt) return true;
174
+ if (ct > kt) return false;
175
+ return e.id < keyset.id;
176
+ })
177
+ : matched;
178
+
179
+ const hasMore = seeked.length > limit;
180
+ const page = hasMore ? seeked.slice(0, limit) : seeked;
181
+ const items = page.map(toEventSummary);
182
+ const last = page[page.length - 1];
183
+ const nextCursor =
184
+ hasMore && last
185
+ ? encodeEventCursor({ occurredAt: last.occurredAt, id: last.id })
186
+ : null;
187
+
188
+ return { items, nextCursor };
189
+ }
190
+
89
191
  /** Remove all published events and subscriptions. Useful in beforeEach. */
90
192
  clear(): void {
91
193
  this.publishedEvents.length = 0;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Keyset (seek) cursor codec for `IEventReadPort.listEvents` (OBS-LIST-1).
3
+ *
4
+ * The list is ordered `occurred_at DESC, id DESC`. The cursor encodes the
5
+ * `(occurredAt, id)` of the last row on the previous page so the next page
6
+ * seeks with `WHERE (occurred_at, id) < (cursorOccurredAt, cursorId)`.
7
+ *
8
+ * The cursor is opaque to consumers: a base64url-encoded JSON tuple. Shape
9
+ * is an implementation detail — never parse it outside this module.
10
+ *
11
+ * Mirrors the jobs keyset codec; kept separate because the events subsystem
12
+ * must not depend on `runtime/subsystems/jobs/`.
13
+ */
14
+
15
+ export interface EventKeyset {
16
+ occurredAt: Date;
17
+ id: string;
18
+ }
19
+
20
+ /** Default page size when `limit` is omitted. */
21
+ export const DEFAULT_EVENT_LIST_LIMIT = 50;
22
+ /** Hard upper bound on page size. */
23
+ export const MAX_EVENT_LIST_LIMIT = 200;
24
+
25
+ /** Clamp a caller-supplied `limit` into `[1, MAX_EVENT_LIST_LIMIT]`. */
26
+ export function clampEventLimit(limit: number | undefined): number {
27
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) {
28
+ return DEFAULT_EVENT_LIST_LIMIT;
29
+ }
30
+ const floored = Math.floor(limit);
31
+ if (floored < 1) return 1;
32
+ if (floored > MAX_EVENT_LIST_LIMIT) return MAX_EVENT_LIST_LIMIT;
33
+ return floored;
34
+ }
35
+
36
+ export function encodeEventCursor(keyset: EventKeyset): string {
37
+ const tuple = [keyset.occurredAt.toISOString(), keyset.id];
38
+ return Buffer.from(JSON.stringify(tuple), 'utf8').toString('base64url');
39
+ }
40
+
41
+ /**
42
+ * Decode an opaque cursor back into its `(occurredAt, id)` keyset. Returns
43
+ * `null` for malformed input so user-supplied garbage is treated as "start
44
+ * from the beginning" rather than throwing.
45
+ */
46
+ export function decodeEventCursor(cursor: string): EventKeyset | null {
47
+ try {
48
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
49
+ const parsed = JSON.parse(json) as unknown;
50
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
51
+ const [iso, id] = parsed;
52
+ if (typeof iso !== 'string' || typeof id !== 'string') return null;
53
+ const occurredAt = new Date(iso);
54
+ if (Number.isNaN(occurredAt.getTime())) return null;
55
+ return { occurredAt, id };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * IEventReadPort — read-side port over `domain_events` (OBS-LIST-1).
3
+ *
4
+ * The publish/subscribe `IEventBus` (EVENT_BUS) is a *write + dispatch*
5
+ * port; it deliberately does not expose tabular reads beyond `findById`.
6
+ * The observability combiner needs a paginated, filterable list of
7
+ * `domain_events` for its events viewer, so we add a dedicated read port
8
+ * rather than overloading `IEventBus`.
9
+ *
10
+ * Keeping reads on a separate port means:
11
+ * - the combiner can compose it `@Optional()` independently of EVENT_BUS;
12
+ * - the Redis backend (which retains no history) simply does not provide
13
+ * it — there is no "list" semantics to fake;
14
+ * - the write/dispatch surface stays minimal.
15
+ *
16
+ * Both `DrizzleEventBus` and `MemoryEventBus` implement this port (they
17
+ * already hold the rows / in-memory log); `EventsModule.forRoot` binds the
18
+ * `EVENT_READ_PORT` token to the same instance for drizzle/memory backends.
19
+ */
20
+
21
+ import type { DomainEvent } from './event-bus.protocol';
22
+
23
+ /**
24
+ * Filter + keyset-pagination input for `IEventReadPort.listEvents`.
25
+ *
26
+ * Ordered `occurred_at DESC, id DESC`. `rootRunId` filters on the JSON
27
+ * `metadata->>'rootRunId'` — the correlation id stamped by the jobs/bridge
28
+ * machinery so an event can be traced back to the run tree that emitted it.
29
+ */
30
+ export interface ListEventsQuery {
31
+ /** Filter on `metadata->>'rootRunId'` (correlation id). */
32
+ rootRunId?: string;
33
+ /** Filter on the first-class `pool` column. */
34
+ poolId?: string;
35
+ /** Filter on the first-class `direction` column (inbound|change|outbound). */
36
+ direction?: string;
37
+ /** Lower bound on `occurred_at` (inclusive). */
38
+ since?: Date;
39
+ /** Opaque keyset cursor from a previous page's `nextCursor`. */
40
+ cursor?: string;
41
+ /** Page size. Backend clamps to a sane default + max. */
42
+ limit?: number;
43
+ /**
44
+ * Multi-tenancy filter on the first-class `tenant_id` column. Only
45
+ * meaningful when the consumer publishes tenant-scoped events
46
+ * (`events.multi_tenant: true`); otherwise leave undefined.
47
+ * - `string` — filter `tenant_id = :tenantId`.
48
+ * - `null` — filter `tenant_id IS NULL`.
49
+ * - `undefined` — no tenant filter.
50
+ */
51
+ tenantId?: string | null;
52
+ }
53
+
54
+ /**
55
+ * Summary row for the events list. A narrow projection over `domain_events`
56
+ * carrying what the viewer + correlation timeline need. `rootRunId` is
57
+ * surfaced (lifted out of `metadata`) so the timeline can stitch without a
58
+ * second metadata dig.
59
+ */
60
+ export interface EventSummary {
61
+ id: string;
62
+ type: string;
63
+ aggregateId: string;
64
+ aggregateType: string;
65
+ status: string;
66
+ pool: string | null;
67
+ direction: string | null;
68
+ tier: string;
69
+ rootRunId: string | null;
70
+ tenantId: string | null;
71
+ occurredAt: Date;
72
+ processedAt: Date | null;
73
+ }
74
+
75
+ /**
76
+ * One page of `listEvents` results. `nextCursor` is `null` when there are
77
+ * no more rows.
78
+ */
79
+ export interface EventPage {
80
+ items: EventSummary[];
81
+ nextCursor: string | null;
82
+ }
83
+
84
+ export interface IEventReadPort {
85
+ /**
86
+ * Paginated, filterable list of `domain_events` (OBS-LIST-1). Newest
87
+ * first (`occurred_at` desc, `id` desc keyset tie-break). Returns an
88
+ * `EventPage` with an opaque `nextCursor` for keyset pagination.
89
+ */
90
+ listEvents(query?: ListEventsQuery): Promise<EventPage>;
91
+ }
92
+
93
+ /** A `DomainEvent` whose metadata may carry a `rootRunId` correlation id. */
94
+ export function rootRunIdOf(event: DomainEvent): string | null {
95
+ const v = event.metadata?.['rootRunId'];
96
+ return typeof v === 'string' ? v : null;
97
+ }
@@ -47,6 +47,7 @@
47
47
  import { Module, type DynamicModule, type Provider } from '@nestjs/common';
48
48
  import {
49
49
  EVENT_BUS,
50
+ EVENT_READ_PORT,
50
51
  EVENTS_MODULE_OPTIONS,
51
52
  EVENTS_MULTI_TENANT,
52
53
  REDIS_URL,
@@ -180,10 +181,21 @@ export class EventsModule {
180
181
  REDIS_URL,
181
182
  ],
182
183
  },
184
+ {
185
+ // Read port (OBS-LIST-1). Drizzle + memory backends implement
186
+ // IEventReadPort on the EVENT_BUS instance; the redis backend
187
+ // retains no history, so EVENT_READ_PORT resolves to `null` and
188
+ // optional consumers (the observability combiner) degrade to
189
+ // empty results.
190
+ provide: EVENT_READ_PORT,
191
+ useFactory: (options: EventsModuleOptions, bus: unknown) =>
192
+ options.backend === 'redis' ? null : bus,
193
+ inject: [EVENTS_MODULE_OPTIONS, EVENT_BUS],
194
+ },
183
195
  TypedEventBus,
184
196
  { provide: TYPED_EVENT_BUS, useExisting: TypedEventBus },
185
197
  ],
186
- exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
198
+ exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
187
199
  };
188
200
  }
189
201
 
@@ -222,9 +234,13 @@ export class EventsModule {
222
234
  providers: [
223
235
  { provide: EVENTS_MODULE_OPTIONS, useValue: options },
224
236
  provider,
237
+ // Read port (OBS-LIST-1): drizzle + memory backends implement
238
+ // IEventReadPort on the same instance as EVENT_BUS. The redis
239
+ // backend retains no history and does not provide this token.
240
+ { provide: EVENT_READ_PORT, useExisting: EVENT_BUS },
225
241
  ...buildTypedBusProviders(multiTenant),
226
242
  ],
227
- exports: [EVENT_BUS, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
243
+ exports: [EVENT_BUS, EVENT_READ_PORT, TYPED_EVENT_BUS, EVENTS_MULTI_TENANT],
228
244
  };
229
245
  }
230
246
  }
@@ -11,6 +11,22 @@
11
11
  */
12
12
  export const EVENT_BUS = 'EVENT_BUS' as const;
13
13
 
14
+ /**
15
+ * Injection token for the read-side `IEventReadPort` over `domain_events`
16
+ * (OBS-LIST-1).
17
+ *
18
+ * Bound by `EventsModule.forRoot` to the same backend instance as
19
+ * `EVENT_BUS` for the `drizzle` and `memory` backends (both implement
20
+ * `IEventReadPort`). The `redis` backend retains no history and therefore
21
+ * does NOT provide this token — consumers composing it (e.g. the
22
+ * observability combiner) inject it `@Optional()` and degrade to empty
23
+ * results.
24
+ *
25
+ * String constant (not Symbol) so it matches by value across import
26
+ * boundaries — same convention as `EVENT_BUS`.
27
+ */
28
+ export const EVENT_READ_PORT = 'EVENT_READ_PORT' as const;
29
+
14
30
  /**
15
31
  * Injection token for the generated `TypedEventBus` facade.
16
32
  *
@@ -4,8 +4,15 @@
4
4
  * Import the module in AppModule, inject the bus via EVENT_BUS token.
5
5
  */
6
6
  export type { DomainEvent, IEventBus, DrizzleTransaction } from './event-bus.protocol';
7
+ export type {
8
+ IEventReadPort,
9
+ ListEventsQuery,
10
+ EventSummary,
11
+ EventPage,
12
+ } from './event-read.protocol';
7
13
  export {
8
14
  EVENT_BUS,
15
+ EVENT_READ_PORT,
9
16
  EVENTS_MODULE_OPTIONS,
10
17
  EVENTS_MULTI_TENANT,
11
18
  TYPED_EVENT_BUS,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * BullMQ backend configuration surface (BULLMQ-1, ADR-022 extension slot).
3
+ *
4
+ * The core `IJobOrchestrator` contract is backend-agnostic; everything in
5
+ * this file is BullMQ-specific and lives behind the
6
+ * `jobs.extensions.bullmq.*` config namespace (CLAUDE.md core/extension
7
+ * protocol). The Drizzle backend never reads any of it.
8
+ */
9
+ import type { ConnectionOptions } from 'bullmq';
10
+ import { loadPoolConfig, type PoolConfig } from './pool-config.loader';
11
+
12
+ /**
13
+ * Typed shape of `codegen.config.yaml: jobs.extensions.bullmq`. Snake_case
14
+ * because it mirrors the YAML the consumer authors.
15
+ *
16
+ * ```yaml
17
+ * jobs:
18
+ * backend: bullmq
19
+ * extensions:
20
+ * bullmq:
21
+ * redis_url: redis://localhost:6379 # or env REDIS_URL
22
+ * queue_prefix: myapp # optional namespace (ADR-022 OQ)
23
+ * bull_board:
24
+ * enabled: true
25
+ * mount_path: /api/admin/queues
26
+ * ```
27
+ */
28
+ export interface BullMqExtensionsConfig {
29
+ /**
30
+ * Redis/Valkey connection URL. When omitted, the runtime resolves
31
+ * `process.env.REDIS_URL`, then falls back to `redis://localhost:6379`.
32
+ */
33
+ redis_url?: string;
34
+ /**
35
+ * Optional queue-name prefix to avoid collisions when several codegen apps
36
+ * share one Redis (ADR-022 §"BullMQ queue naming collisions"). Applied to
37
+ * every pool queue alias.
38
+ */
39
+ queue_prefix?: string;
40
+ /**
41
+ * Bull Board dashboard — opt-in extension (not core). Mounting is the
42
+ * consumer's responsibility (it needs the consumer's Express/Nest adapter +
43
+ * admin auth); we only carry the config. See README + spec §Extensions.
44
+ */
45
+ bull_board?: {
46
+ enabled: boolean;
47
+ mount_path?: string;
48
+ };
49
+ }
50
+
51
+ /**
52
+ * The runtime form after `redis_url`/env resolution. This is what the
53
+ * orchestrator + worker actually consume.
54
+ */
55
+ export interface BullMqResolvedConfig {
56
+ connection: ConnectionOptions;
57
+ queuePrefix?: string;
58
+ bullBoard?: { enabled: boolean; mountPath: string };
59
+ }
60
+
61
+ /** DI token for the resolved BullMQ `ConnectionOptions` (ioredis-compatible). */
62
+ export const BULLMQ_CONNECTION = Symbol('BULLMQ_CONNECTION');
63
+
64
+ /** DI token for the full resolved BullMQ config (prefix + bull board). */
65
+ export const BULLMQ_RESOLVED_CONFIG = Symbol('BULLMQ_RESOLVED_CONFIG');
66
+
67
+ const DEFAULT_REDIS_URL = 'redis://localhost:6379';
68
+ const DEFAULT_BULL_BOARD_MOUNT = '/admin/queues';
69
+
70
+ /**
71
+ * Resolve the BullMQ runtime config from the extension block.
72
+ *
73
+ * Precedence for the connection URL:
74
+ * 1. explicit `extensions.bullmq.redis_url`
75
+ * 2. `process.env.REDIS_URL`
76
+ * 3. `redis://localhost:6379`
77
+ *
78
+ * Returns a `{ url }` connection shape — BullMQ/ioredis accept a URL string
79
+ * via the `{ url }` ConnectionOptions form.
80
+ */
81
+ export function resolveBullMqConfig(
82
+ ext: BullMqExtensionsConfig | undefined,
83
+ ): BullMqResolvedConfig {
84
+ const url =
85
+ ext?.redis_url ?? process.env.REDIS_URL ?? DEFAULT_REDIS_URL;
86
+
87
+ const resolved: BullMqResolvedConfig = {
88
+ connection: { url } as ConnectionOptions,
89
+ queuePrefix: ext?.queue_prefix,
90
+ };
91
+ if (ext?.bull_board?.enabled) {
92
+ resolved.bullBoard = {
93
+ enabled: true,
94
+ mountPath: ext.bull_board.mount_path ?? DEFAULT_BULL_BOARD_MOUNT,
95
+ };
96
+ }
97
+ return resolved;
98
+ }
99
+
100
+ /**
101
+ * Resolve the BullMQ queue name for a *logical pool name*. The orchestrator
102
+ * and worker MUST agree on this mapping or jobs are enqueued onto a queue
103
+ * nobody consumes. Both derive it identically:
104
+ *
105
+ * 1. Look up the pool's `queue` alias (e.g. `jobs-batch`) in the resolved
106
+ * pool config — the same alias `JobWorkerModule.onModuleInit` logs and
107
+ * that the BullMQ `Worker` binds to.
108
+ * 2. Fall back to the logical pool name when the pool is unknown (defensive;
109
+ * still a stable, colon-free identifier).
110
+ * 3. Apply the optional `queue_prefix` namespace for multi-app Redis
111
+ * sharing — `:` is fine in the *queue name* (it is only forbidden in the
112
+ * `jobId`, hence the sha1 there).
113
+ *
114
+ * `poolConfig` defaults to the cached `loadPoolConfig()` so callers that only
115
+ * hold the logical pool name (the orchestrator) don't need to thread the map.
116
+ */
117
+ export function resolvePoolQueueName(
118
+ pool: string,
119
+ config: BullMqResolvedConfig | null | undefined,
120
+ poolConfig: PoolConfig = loadPoolConfig(),
121
+ ): string {
122
+ const alias = poolConfig.get(pool)?.queue ?? pool;
123
+ const prefix = config?.queuePrefix;
124
+ return prefix ? `${prefix}:${alias}` : alias;
125
+ }
@@ -42,6 +42,9 @@ export type {
42
42
  RescheduleForScopeOptions,
43
43
  PoolStatusCount,
44
44
  JobRunFailure,
45
+ ListJobRunsQuery,
46
+ JobRunSummary,
47
+ JobRunPage,
45
48
  } from './job-run-service.protocol';
46
49
 
47
50
  // ─── JOB-2: step-service protocol ──────────────────────────────────────────
@@ -76,6 +79,24 @@ export type {
76
79
  export { DrizzleJobOrchestrator } from './job-orchestrator.drizzle-backend';
77
80
  export { DrizzleJobRunService } from './job-run-service.drizzle-backend';
78
81
  export { DrizzleJobStepService } from './job-step-service.drizzle-backend';
82
+
83
+ // ─── BULLMQ-1: BullMQ backend (additive; opt-in via jobs.backend: bullmq) ──
84
+ export {
85
+ BullMQJobOrchestrator,
86
+ sha1JobId,
87
+ } from './job-orchestrator.bullmq-backend';
88
+ export {
89
+ BullMQJobWorker,
90
+ type BullMQJobWorkerOptions,
91
+ } from './job-worker.bullmq-backend';
92
+ export {
93
+ BULLMQ_CONNECTION,
94
+ BULLMQ_RESOLVED_CONFIG,
95
+ resolveBullMqConfig,
96
+ resolvePoolQueueName,
97
+ type BullMqExtensionsConfig,
98
+ type BullMqResolvedConfig,
99
+ } from './bullmq.config';
79
100
  export {
80
101
  JobWorker,
81
102
  JOB_WORKER_OPTIONS,
@@ -115,6 +136,7 @@ export {
115
136
  export {
116
137
  loadPoolConfig,
117
138
  allNonReservedPoolNames,
139
+ allPoolNames,
118
140
  FRAMEWORK_POOLS,
119
141
  RESERVED_POOL_NAMES,
120
142
  type PoolConfig,