@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.
- package/CHANGELOG.md +29 -0
- package/dist/runtime/subsystems/bridge/bridge-delivery-handler.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge-outbox-drain-hook.js.map +1 -1
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +930 -275
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/event-flow.service.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +3 -0
- package/dist/runtime/subsystems/bridge/index.js +837 -182
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js +92 -1
- package/dist/runtime/subsystems/events/event-bus.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js +99 -0
- package/dist/runtime/subsystems/events/event-bus.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-bus.redis-backend.js.map +1 -1
- package/dist/runtime/subsystems/events/event-keyset-cursor.d.ts +32 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js +38 -0
- package/dist/runtime/subsystems/events/event-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/events/event-read.protocol.d.ts +94 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js +9 -0
- package/dist/runtime/subsystems/events/event-read.protocol.js.map +1 -0
- package/dist/runtime/subsystems/events/events.module.js +177 -3
- package/dist/runtime/subsystems/events/events.module.js.map +1 -1
- package/dist/runtime/subsystems/events/events.tokens.d.ts +16 -1
- package/dist/runtime/subsystems/events/events.tokens.js +2 -0
- package/dist/runtime/subsystems/events/events.tokens.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/bus.js.map +1 -1
- package/dist/runtime/subsystems/events/generated/index.js.map +1 -1
- package/dist/runtime/subsystems/events/index.d.ts +2 -1
- package/dist/runtime/subsystems/events/index.js +178 -3
- package/dist/runtime/subsystems/events/index.js.map +1 -1
- package/dist/runtime/subsystems/index.d.ts +1 -0
- package/dist/runtime/subsystems/index.js +1194 -264
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/bullmq.config.d.ts +98 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js +143 -0
- package/dist/runtime/subsystems/jobs/bullmq.config.js.map +1 -0
- package/dist/runtime/subsystems/jobs/index.d.ts +6 -2
- package/dist/runtime/subsystems/jobs/index.js +861 -201
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.d.ts +107 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +922 -0
- package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.d.ts +52 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js +57 -0
- package/dist/runtime/subsystems/jobs/job-run-keyset-cursor.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js +81 -1
- package/dist/runtime/subsystems/jobs/job-run-service.drizzle-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.d.ts +2 -1
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +81 -0
- package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-run-service.protocol.d.ts +74 -1
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.d.ts +48 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js +374 -0
- package/dist/runtime/subsystems/jobs/job-worker.bullmq-backend.js.map +1 -0
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +42 -4
- package/dist/runtime/subsystems/jobs/job-worker.module.js +832 -178
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.d.ts +10 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +519 -20
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.d.ts +9 -1
- package/dist/runtime/subsystems/jobs/pool-config.loader.js +4 -0
- package/dist/runtime/subsystems/jobs/pool-config.loader.js.map +1 -1
- package/dist/runtime/subsystems/observability/index.d.ts +4 -3
- package/dist/runtime/subsystems/observability/index.js +109 -2
- package/dist/runtime/subsystems/observability/index.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.module.js +109 -2
- package/dist/runtime/subsystems/observability/observability.module.js.map +1 -1
- package/dist/runtime/subsystems/observability/observability.protocol.d.ts +63 -2
- package/dist/runtime/subsystems/observability/observability.service.d.ts +21 -3
- package/dist/runtime/subsystems/observability/observability.service.js +109 -2
- package/dist/runtime/subsystems/observability/observability.service.js.map +1 -1
- package/dist/runtime/subsystems/observability/reporters/bridge-metrics.reporter.d.ts +1 -0
- package/dist/runtime/subsystems/observability/reporters/index.d.ts +1 -0
- package/dist/src/cli/index.js +30 -6
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/runtime/subsystems/bridge/bridge.module.ts +5 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +109 -3
- package/runtime/subsystems/events/event-bus.memory-backend.ts +103 -1
- package/runtime/subsystems/events/event-keyset-cursor.ts +59 -0
- package/runtime/subsystems/events/event-read.protocol.ts +97 -0
- package/runtime/subsystems/events/events.module.ts +18 -2
- package/runtime/subsystems/events/events.tokens.ts +16 -0
- package/runtime/subsystems/events/index.ts +7 -0
- package/runtime/subsystems/jobs/bullmq.config.ts +125 -0
- package/runtime/subsystems/jobs/index.ts +22 -0
- package/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.ts +381 -0
- package/runtime/subsystems/jobs/job-run-keyset-cursor.ts +88 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +59 -1
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +53 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +77 -0
- package/runtime/subsystems/jobs/job-worker.bullmq-backend.ts +311 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +124 -10
- package/runtime/subsystems/jobs/jobs-domain.module.ts +40 -21
- package/runtime/subsystems/jobs/pool-config.loader.ts +11 -0
- package/runtime/subsystems/observability/index.ts +8 -0
- package/runtime/subsystems/observability/observability.protocol.ts +76 -0
- package/runtime/subsystems/observability/observability.service.ts +148 -1
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -12
- package/templates/relationship/new/prompt.js +8 -5
- package/templates/subsystem/jobs/worker.ejs.t +30 -7
- package/templates/subsystem/sync/sync-audit.schema.ejs.t +12 -16
package/package.json
CHANGED
|
@@ -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,
|