@pattern-stack/codegen 0.4.1 → 0.4.3
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 +6 -0
- package/dist/runtime/subsystems/bridge/bridge.module.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/bridge.module.js +38 -21
- package/dist/runtime/subsystems/bridge/bridge.module.js.map +1 -1
- package/dist/runtime/subsystems/bridge/index.d.ts +1 -0
- package/dist/runtime/subsystems/bridge/index.js +29 -12
- package/dist/runtime/subsystems/bridge/index.js.map +1 -1
- package/dist/runtime/subsystems/index.js +31 -14
- package/dist/runtime/subsystems/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/index.d.ts +1 -0
- package/dist/runtime/subsystems/jobs/index.js +27 -10
- package/dist/runtime/subsystems/jobs/index.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js +9 -4
- package/dist/runtime/subsystems/jobs/job-orchestrator.memory-backend.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.js +6 -2
- package/dist/runtime/subsystems/jobs/job-worker.js.map +1 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.d.ts +3 -1
- package/dist/runtime/subsystems/jobs/job-worker.module.js +27 -10
- package/dist/runtime/subsystems/jobs/job-worker.module.js.map +1 -1
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js +9 -4
- package/dist/runtime/subsystems/jobs/jobs-domain.module.js.map +1 -1
- package/dist/src/cli/index.js +29 -2
- package/dist/src/cli/index.js.map +1 -1
- package/package.json +2 -1
- package/runtime/analytics/index.ts +31 -0
- package/runtime/analytics/metrics.ts +85 -0
- package/runtime/analytics/packs/crm-entity-measures.ts +20 -0
- package/runtime/analytics/packs/index.ts +5 -0
- package/runtime/analytics/packs/monetary-measures.ts +20 -0
- package/runtime/analytics/specs.ts +54 -0
- package/runtime/analytics/types.ts +105 -0
- package/runtime/base-classes/activity-entity-repository.ts +50 -0
- package/runtime/base-classes/activity-entity-service.ts +48 -0
- package/runtime/base-classes/base-read-use-cases.ts +88 -0
- package/runtime/base-classes/base-repository.ts +289 -0
- package/runtime/base-classes/base-service.ts +183 -0
- package/runtime/base-classes/index.ts +38 -0
- package/runtime/base-classes/knowledge-entity-repository.ts +12 -0
- package/runtime/base-classes/knowledge-entity-service.ts +14 -0
- package/runtime/base-classes/lifecycle-events.ts +152 -0
- package/runtime/base-classes/metadata-entity-repository.ts +80 -0
- package/runtime/base-classes/metadata-entity-service.ts +48 -0
- package/runtime/base-classes/synced-entity-repository.ts +57 -0
- package/runtime/base-classes/synced-entity-service.ts +50 -0
- package/runtime/base-classes/with-analytics.ts +22 -0
- package/runtime/constants/tokens.ts +29 -0
- package/runtime/eav-helpers.ts +74 -0
- package/runtime/pipes/zod-validation.pipe.ts +64 -0
- package/runtime/shared/openapi/error-response.dto.ts +24 -0
- package/runtime/shared/openapi/errors.ts +39 -0
- package/runtime/shared/openapi/index.ts +20 -0
- package/runtime/shared/openapi/registry.tokens.ts +13 -0
- package/runtime/shared/openapi/registry.ts +151 -0
- package/runtime/subsystems/analytics/analytics-query.protocol.ts +37 -0
- package/runtime/subsystems/analytics/analytics.module.ts +64 -0
- package/runtime/subsystems/analytics/analytics.tokens.ts +24 -0
- package/runtime/subsystems/analytics/cube-backend.ts +75 -0
- package/runtime/subsystems/analytics/index.ts +15 -0
- package/runtime/subsystems/analytics/noop-backend.ts +27 -0
- package/runtime/subsystems/auth/auth.module.ts +91 -0
- package/runtime/subsystems/auth/auth.tokens.ts +27 -0
- package/runtime/subsystems/auth/backends/encryption-key/env.ts +76 -0
- package/runtime/subsystems/auth/backends/oauth-state-store/in-memory.ts +42 -0
- package/runtime/subsystems/auth/index.ts +77 -0
- package/runtime/subsystems/auth/protocols/auth-strategy.ts +46 -0
- package/runtime/subsystems/auth/protocols/encryption-key.ts +21 -0
- package/runtime/subsystems/auth/protocols/integration-store.ts +66 -0
- package/runtime/subsystems/auth/protocols/oauth-state-store.ts +16 -0
- package/runtime/subsystems/auth/runtime/integration-broken.error.ts +21 -0
- package/runtime/subsystems/auth/runtime/oauth2-refresh.strategy.ts +189 -0
- package/runtime/subsystems/auth/runtime/session-expired.error.ts +39 -0
- package/runtime/subsystems/auth/runtime/with-auth-retry.ts +50 -0
- package/runtime/subsystems/bridge/assert-tenant-id.ts +57 -0
- package/runtime/subsystems/bridge/bridge-delivery-handler.ts +220 -0
- package/runtime/subsystems/bridge/bridge-delivery.drizzle-backend.ts +149 -0
- package/runtime/subsystems/bridge/bridge-delivery.memory-backend.ts +140 -0
- package/runtime/subsystems/bridge/bridge-delivery.schema.ts +142 -0
- package/runtime/subsystems/bridge/bridge-errors.ts +112 -0
- package/runtime/subsystems/bridge/bridge-outbox-drain-hook.ts +175 -0
- package/runtime/subsystems/bridge/bridge.module.ts +160 -0
- package/runtime/subsystems/bridge/bridge.protocol.ts +351 -0
- package/runtime/subsystems/bridge/bridge.tokens.ts +68 -0
- package/runtime/subsystems/bridge/event-flow.service.ts +175 -0
- package/runtime/subsystems/bridge/generated/.gitkeep +0 -0
- package/runtime/subsystems/bridge/generated/registry.ts +6 -0
- package/runtime/subsystems/bridge/index.ts +84 -0
- package/runtime/subsystems/bridge/reserved-pools.ts +36 -0
- package/runtime/subsystems/cache/cache.drizzle-backend.ts +150 -0
- package/runtime/subsystems/cache/cache.memory-backend.ts +116 -0
- package/runtime/subsystems/cache/cache.module.ts +115 -0
- package/runtime/subsystems/cache/cache.protocol.ts +45 -0
- package/runtime/subsystems/cache/cache.schema.ts +27 -0
- package/runtime/subsystems/cache/cache.tokens.ts +17 -0
- package/runtime/subsystems/cache/index.ts +22 -0
- package/runtime/subsystems/events/domain-events.schema.ts +77 -0
- package/runtime/subsystems/events/event-bus.drizzle-backend.ts +327 -0
- package/runtime/subsystems/events/event-bus.memory-backend.ts +142 -0
- package/runtime/subsystems/events/event-bus.protocol.ts +86 -0
- package/runtime/subsystems/events/event-bus.redis-backend.ts +304 -0
- package/runtime/subsystems/events/events-errors.ts +30 -0
- package/runtime/subsystems/events/events.module.ts +230 -0
- package/runtime/subsystems/events/events.tokens.ts +62 -0
- package/runtime/subsystems/events/generated/bus.ts +103 -0
- package/runtime/subsystems/events/generated/index.ts +7 -0
- package/runtime/subsystems/events/generated/registry.ts +84 -0
- package/runtime/subsystems/events/generated/schemas.ts +59 -0
- package/runtime/subsystems/events/generated/types.ts +94 -0
- package/runtime/subsystems/events/index.ts +21 -0
- package/runtime/subsystems/index.ts +63 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.multi-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/job-orchestration.schema.single-tenant.ts +217 -0
- package/runtime/subsystems/jobs/generated/scope-entity-type.ts +10 -0
- package/runtime/subsystems/jobs/index.ts +120 -0
- package/runtime/subsystems/jobs/job-handler.base.ts +206 -0
- package/runtime/subsystems/jobs/job-orchestration.schema.ts +217 -0
- package/runtime/subsystems/jobs/job-orchestrator.drizzle-backend.ts +536 -0
- package/runtime/subsystems/jobs/job-orchestrator.memory-backend.ts +860 -0
- package/runtime/subsystems/jobs/job-orchestrator.protocol.ts +179 -0
- package/runtime/subsystems/jobs/job-run-service.drizzle-backend.ts +171 -0
- package/runtime/subsystems/jobs/job-run-service.memory-backend.ts +165 -0
- package/runtime/subsystems/jobs/job-run-service.protocol.ts +79 -0
- package/runtime/subsystems/jobs/job-step-service.drizzle-backend.ts +66 -0
- package/runtime/subsystems/jobs/job-step-service.memory-backend.ts +119 -0
- package/runtime/subsystems/jobs/job-step-service.protocol.ts +53 -0
- package/runtime/subsystems/jobs/job-worker.module.ts +312 -0
- package/runtime/subsystems/jobs/job-worker.ts +624 -0
- package/runtime/subsystems/jobs/jobs-domain.module.ts +119 -0
- package/runtime/subsystems/jobs/jobs-domain.tokens.ts +30 -0
- package/runtime/subsystems/jobs/jobs-errors.ts +150 -0
- package/runtime/subsystems/jobs/memory-job-store.ts +35 -0
- package/runtime/subsystems/jobs/pool-config.loader.ts +218 -0
- package/runtime/subsystems/storage/index.ts +18 -0
- package/runtime/subsystems/storage/storage.local-backend.ts +113 -0
- package/runtime/subsystems/storage/storage.memory-backend.ts +78 -0
- package/runtime/subsystems/storage/storage.module.ts +60 -0
- package/runtime/subsystems/storage/storage.protocol.ts +78 -0
- package/runtime/subsystems/storage/storage.tokens.ts +9 -0
- package/runtime/subsystems/storage/storage.utils.ts +20 -0
- package/runtime/subsystems/sync/deep-equal.differ.ts +198 -0
- package/runtime/subsystems/sync/execute-sync.use-case.ts +334 -0
- package/runtime/subsystems/sync/index.ts +98 -0
- package/runtime/subsystems/sync/sync-audit.schema.ts +300 -0
- package/runtime/subsystems/sync/sync-change-source.protocol.ts +99 -0
- package/runtime/subsystems/sync/sync-cursor-store.drizzle-backend.ts +104 -0
- package/runtime/subsystems/sync/sync-cursor-store.memory-backend.ts +64 -0
- package/runtime/subsystems/sync/sync-cursor-store.protocol.ts +53 -0
- package/runtime/subsystems/sync/sync-errors.ts +54 -0
- package/runtime/subsystems/sync/sync-field-diff.protocol.ts +61 -0
- package/runtime/subsystems/sync/sync-loopback.protocol.ts +33 -0
- package/runtime/subsystems/sync/sync-run-recorder.drizzle-backend.ts +123 -0
- package/runtime/subsystems/sync/sync-run-recorder.memory-backend.ts +143 -0
- package/runtime/subsystems/sync/sync-run-recorder.protocol.ts +86 -0
- package/runtime/subsystems/sync/sync-sink.protocol.ts +55 -0
- package/runtime/subsystems/sync/sync.module.ts +156 -0
- package/runtime/subsystems/sync/sync.tokens.ts +57 -0
- package/runtime/types/drizzle.ts +23 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryEventBus — in-memory backend for the event bus.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches events synchronously to registered subscribers. The `tx`
|
|
5
|
+
* parameter is ignored — all events are dispatched immediately.
|
|
6
|
+
*
|
|
7
|
+
* Use this backend in tests to assert event publication without a database.
|
|
8
|
+
* Swap via EventsModule.forRoot({ backend: 'memory' }).
|
|
9
|
+
*
|
|
10
|
+
* Pool awareness (EVT-5):
|
|
11
|
+
* - Mirrors the `DrizzleEventBus` per-process restriction (EVT-4). When
|
|
12
|
+
* `opts.pools` is set, `publish`/`publishMany` still push the event into
|
|
13
|
+
* `publishedEvents` (so test code can assert the full set of emitted
|
|
14
|
+
* events regardless of pool filter), but handlers are NOT invoked for
|
|
15
|
+
* events whose `metadata.pool` is outside the configured pools.
|
|
16
|
+
* - `publishedEventsForPool(pool)` and `publishedEventsForDirection(dir)`
|
|
17
|
+
* helpers are provided for targeted assertions.
|
|
18
|
+
* - Shares the `EventsModuleOptions` shape (same token as Drizzle) rather
|
|
19
|
+
* than introducing a memory-only options type — the surface is the same
|
|
20
|
+
* and keeping them unified avoids drift between backends.
|
|
21
|
+
*/
|
|
22
|
+
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
23
|
+
import type { DomainEvent, IEventBus } from './event-bus.protocol';
|
|
24
|
+
import { EVENTS_MODULE_OPTIONS } from './events.tokens';
|
|
25
|
+
import type { EventsModuleOptions } from './events.module';
|
|
26
|
+
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class MemoryEventBus implements IEventBus {
|
|
29
|
+
private readonly logger = new Logger(MemoryEventBus.name);
|
|
30
|
+
|
|
31
|
+
/** All events published since construction (or last clear). */
|
|
32
|
+
readonly publishedEvents: DomainEvent[] = [];
|
|
33
|
+
|
|
34
|
+
private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();
|
|
35
|
+
private readonly opts: EventsModuleOptions;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
@Optional() @Inject(EVENTS_MODULE_OPTIONS) opts?: EventsModuleOptions,
|
|
39
|
+
) {
|
|
40
|
+
// Default so direct construction (e.g. `new MemoryEventBus()` from a
|
|
41
|
+
// unit test outside NestJS DI) keeps working without an explicit
|
|
42
|
+
// options object.
|
|
43
|
+
this.opts = opts ?? { backend: 'memory' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async publish(event: DomainEvent): Promise<void> {
|
|
47
|
+
// Always record the event — even if this process is configured with a
|
|
48
|
+
// pool filter that excludes it. Test code relies on `publishedEvents`
|
|
49
|
+
// being a complete log of what was published, not a filtered view.
|
|
50
|
+
this.publishedEvents.push(event);
|
|
51
|
+
|
|
52
|
+
if (this.shouldDispatch(event)) {
|
|
53
|
+
await this.dispatch(event);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async publishMany(events: DomainEvent[]): Promise<void> {
|
|
58
|
+
for (const event of events) {
|
|
59
|
+
await this.publish(event);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async findById(eventId: string): Promise<DomainEvent | null> {
|
|
64
|
+
return this.publishedEvents.find((e) => e.id === eventId) ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
subscribe<T extends DomainEvent = DomainEvent>(
|
|
68
|
+
eventType: string,
|
|
69
|
+
handler: (event: T) => Promise<void>,
|
|
70
|
+
): () => void {
|
|
71
|
+
if (!this.handlers.has(eventType)) {
|
|
72
|
+
this.handlers.set(eventType, new Set());
|
|
73
|
+
}
|
|
74
|
+
// Cast is safe — callers pass a typed handler; we store as the base type
|
|
75
|
+
const set = this.handlers.get(eventType)!;
|
|
76
|
+
const h = handler as (event: DomainEvent) => Promise<void>;
|
|
77
|
+
set.add(h);
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
set.delete(h);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove all published events and subscriptions. Useful in beforeEach. */
|
|
85
|
+
clear(): void {
|
|
86
|
+
this.publishedEvents.length = 0;
|
|
87
|
+
this.handlers.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Filter published events by `metadata.pool`. */
|
|
91
|
+
publishedEventsForPool(pool: string): DomainEvent[] {
|
|
92
|
+
return this.publishedEvents.filter((e) => e.metadata?.['pool'] === pool);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Filter published events by `metadata.direction`. */
|
|
96
|
+
publishedEventsForDirection(direction: string): DomainEvent[] {
|
|
97
|
+
return this.publishedEvents.filter((e) => e.metadata?.['direction'] === direction);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decide whether `event` should be dispatched to handlers given the
|
|
102
|
+
* current pool filter.
|
|
103
|
+
*
|
|
104
|
+
* Semantics (mirroring `DrizzleEventBus.processBatch`):
|
|
105
|
+
* - `opts.pools` undefined → dispatch everything (no filter).
|
|
106
|
+
* - `opts.pools` empty array → treated as "no filter" to match the
|
|
107
|
+
* Drizzle backend, where `pools && pools.length > 0` is the gate on
|
|
108
|
+
* the `inArray` WHERE clause. Empty arrays dispatch everything.
|
|
109
|
+
* - `opts.pools` non-empty → dispatch only when `event.metadata.pool`
|
|
110
|
+
* is in the list. Events without `metadata.pool` do NOT match — they
|
|
111
|
+
* are out of all configured pools by definition.
|
|
112
|
+
*/
|
|
113
|
+
private shouldDispatch(event: DomainEvent): boolean {
|
|
114
|
+
const pools = this.opts.pools;
|
|
115
|
+
if (!pools || pools.length === 0) return true;
|
|
116
|
+
const eventPool = event.metadata?.['pool'];
|
|
117
|
+
return typeof eventPool === 'string' && pools.includes(eventPool);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async dispatch(event: DomainEvent): Promise<void> {
|
|
121
|
+
const set = this.handlers.get(event.type);
|
|
122
|
+
if (!set) return;
|
|
123
|
+
|
|
124
|
+
let firstError: unknown;
|
|
125
|
+
for (const handler of set) {
|
|
126
|
+
try {
|
|
127
|
+
await handler(event);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.logger.error(
|
|
130
|
+
`Handler error for event type "${event.type}" (id: ${event.id}): ${err}`,
|
|
131
|
+
);
|
|
132
|
+
if (firstError === undefined) {
|
|
133
|
+
firstError = err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (firstError !== undefined) {
|
|
139
|
+
throw firstError;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events subsystem — protocol (port)
|
|
3
|
+
*
|
|
4
|
+
* IEventBus is the hexagonal port. Use cases inject this interface via
|
|
5
|
+
* EVENT_BUS token. They never depend on a specific backend implementation.
|
|
6
|
+
*
|
|
7
|
+
* The DrizzleTransaction type mirrors what the Drizzle client exposes
|
|
8
|
+
* so callers can pass a transaction for the outbox pattern.
|
|
9
|
+
*/
|
|
10
|
+
import type { DrizzleClient } from '../../types/drizzle';
|
|
11
|
+
|
|
12
|
+
// Derive the transaction type from the DrizzleClient so it stays in sync
|
|
13
|
+
// without introducing an additional import alias.
|
|
14
|
+
export type DrizzleTransaction = Parameters<
|
|
15
|
+
Parameters<DrizzleClient['transaction']>[0]
|
|
16
|
+
>[0];
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Domain event shape
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export interface DomainEvent {
|
|
23
|
+
/** UUID — used for deduplication and idempotency. */
|
|
24
|
+
readonly id: string;
|
|
25
|
+
/** Event type discriminator, e.g. 'contact_created'. */
|
|
26
|
+
readonly type: string;
|
|
27
|
+
/** ID of the aggregate that produced this event. */
|
|
28
|
+
readonly aggregateId: string;
|
|
29
|
+
/** Aggregate type name, e.g. 'contact'. */
|
|
30
|
+
readonly aggregateType: string;
|
|
31
|
+
/** Event-specific payload. */
|
|
32
|
+
readonly payload: Record<string, unknown>;
|
|
33
|
+
/** Wall-clock time the event occurred. */
|
|
34
|
+
readonly occurredAt: Date;
|
|
35
|
+
/** Optional routing / audit metadata. */
|
|
36
|
+
readonly metadata?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// IEventBus
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
export interface IEventBus {
|
|
44
|
+
/**
|
|
45
|
+
* Publish a single domain event.
|
|
46
|
+
*
|
|
47
|
+
* Pass `tx` to include the event in an ongoing Drizzle transaction
|
|
48
|
+
* (transactional outbox pattern). If the transaction rolls back, the
|
|
49
|
+
* event is never persisted.
|
|
50
|
+
*/
|
|
51
|
+
publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Publish multiple domain events atomically.
|
|
55
|
+
* Same transactional semantics as `publish`.
|
|
56
|
+
*/
|
|
57
|
+
publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Subscribe to events of the given type.
|
|
61
|
+
* Returns an unsubscribe function — call it to remove the handler.
|
|
62
|
+
*/
|
|
63
|
+
subscribe<T extends DomainEvent = DomainEvent>(
|
|
64
|
+
eventType: string,
|
|
65
|
+
handler: (event: T) => Promise<void>,
|
|
66
|
+
): () => void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lookup a single event by its id. Returns `null` when no event matches.
|
|
70
|
+
*
|
|
71
|
+
* Added in BRIDGE-5 (ADR-023 Phase 2). The bridge `BridgeDeliveryHandler`
|
|
72
|
+
* uses this to re-fetch the authoritative `domain_events` row at claim
|
|
73
|
+
* time so `triggers[].when` and `triggers[].map` callbacks see the
|
|
74
|
+
* committed payload, not a copy that may have drifted between drain and
|
|
75
|
+
* handler execution. Other consumers may use it for replay tooling and
|
|
76
|
+
* audit dashboards.
|
|
77
|
+
*
|
|
78
|
+
* Backends:
|
|
79
|
+
* - `MemoryEventBus` — searches its in-memory `publishedEvents` log.
|
|
80
|
+
* - `DrizzleEventBus` — `SELECT … FROM domain_events WHERE id = ? LIMIT 1`.
|
|
81
|
+
* - `RedisEventBus` — Redis Pub/Sub does not retain history; returns
|
|
82
|
+
* `null` (and logs a one-time warning at first call). Bridge usage
|
|
83
|
+
* of Redis backend is unsupported.
|
|
84
|
+
*/
|
|
85
|
+
findById(eventId: string): Promise<DomainEvent | null>;
|
|
86
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedisEventBus — Redis Pub/Sub backend for the event bus.
|
|
3
|
+
*
|
|
4
|
+
* Publishes events to Redis channels and dispatches incoming messages to
|
|
5
|
+
* registered in-process subscribers. Events are serialized as JSON strings.
|
|
6
|
+
*
|
|
7
|
+
* Channel naming:
|
|
8
|
+
* - Per-type channel: events:{event.type} (e.g. events:contact_created)
|
|
9
|
+
* - Catch-all channel: events:*
|
|
10
|
+
*
|
|
11
|
+
* Transactional semantics:
|
|
12
|
+
* The `tx` parameter (Drizzle transaction) is accepted to satisfy the
|
|
13
|
+
* IEventBus interface but has no effect — Redis Pub/Sub is not transactional.
|
|
14
|
+
* Events published with a `tx` argument are dispatched immediately without
|
|
15
|
+
* waiting for the surrounding transaction to commit. If you need
|
|
16
|
+
* at-least-once delivery tied to a database transaction, use DrizzleEventBus.
|
|
17
|
+
*
|
|
18
|
+
* Connection model:
|
|
19
|
+
* ioredis requires a dedicated connection for subscribers (a client in
|
|
20
|
+
* subscribe mode cannot issue regular commands). This backend creates two
|
|
21
|
+
* clients: one for publishing (`publisher`) and one for subscribing
|
|
22
|
+
* (`subscriber`). Both are connected on module init and disconnected on
|
|
23
|
+
* module destroy.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* EventsModule.forRoot({ backend: 'redis', redisUrl: 'redis://localhost:6379' })
|
|
27
|
+
*
|
|
28
|
+
* Requires `ioredis` — install it separately if you use this backend:
|
|
29
|
+
* npm install ioredis / bun add ioredis
|
|
30
|
+
*/
|
|
31
|
+
import { Injectable, OnModuleInit, OnModuleDestroy, Inject, Logger } from '@nestjs/common';
|
|
32
|
+
import type { DomainEvent, DrizzleTransaction, IEventBus } from './event-bus.protocol';
|
|
33
|
+
import { REDIS_URL } from './events.tokens';
|
|
34
|
+
|
|
35
|
+
/** Redis channel prefix for all domain events. */
|
|
36
|
+
const CHANNEL_PREFIX = 'events:';
|
|
37
|
+
/** Catch-all channel that receives every published event. */
|
|
38
|
+
const WILDCARD_CHANNEL = 'events:*';
|
|
39
|
+
|
|
40
|
+
// ioredis is an optional peer dependency; import lazily so consumers who do
|
|
41
|
+
// not use this backend do not need it on their classpath.
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
type RedisClient = any;
|
|
44
|
+
|
|
45
|
+
async function createRedisClient(url: string): Promise<RedisClient> {
|
|
46
|
+
let Redis: { new (url: string): RedisClient };
|
|
47
|
+
try {
|
|
48
|
+
const mod = await import('ioredis');
|
|
49
|
+
Redis = mod.default ?? mod;
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'RedisEventBus requires the "ioredis" package. Install it with: npm install ioredis',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return new Redis(url);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Injectable()
|
|
59
|
+
export class RedisEventBus implements IEventBus, OnModuleInit, OnModuleDestroy {
|
|
60
|
+
private readonly logger = new Logger(RedisEventBus.name);
|
|
61
|
+
|
|
62
|
+
private publisher: RedisClient | null = null;
|
|
63
|
+
private subscriber: RedisClient | null = null;
|
|
64
|
+
private connected = false;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* In-process subscriber registry. Handlers registered here are called when
|
|
68
|
+
* a message arrives on the subscriber client — keeping fan-out within the
|
|
69
|
+
* same process without an extra round-trip through Redis.
|
|
70
|
+
*/
|
|
71
|
+
private readonly handlers = new Map<string, Set<(event: DomainEvent) => Promise<void>>>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Track which event types have active Redis subscriptions.
|
|
75
|
+
* Used to avoid subscribing multiple times to the same type channel.
|
|
76
|
+
*/
|
|
77
|
+
private readonly subscribedTypes = new Set<string>();
|
|
78
|
+
|
|
79
|
+
constructor(@Inject(REDIS_URL) private readonly redisUrl: string) {}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Lifecycle
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
async onModuleInit(): Promise<void> {
|
|
86
|
+
this.publisher = await createRedisClient(this.redisUrl);
|
|
87
|
+
this.subscriber = await createRedisClient(this.redisUrl);
|
|
88
|
+
|
|
89
|
+
// Surface connection errors without crashing the process.
|
|
90
|
+
this.publisher.on('error', (err: Error) =>
|
|
91
|
+
this.logger.error(`Redis publisher error: ${err.message}`, err.stack),
|
|
92
|
+
);
|
|
93
|
+
this.subscriber.on('error', (err: Error) =>
|
|
94
|
+
this.logger.error(`Redis subscriber error: ${err.message}`, err.stack),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Set up message listener for per-type subscriptions.
|
|
98
|
+
// Subscriptions are created lazily when the first handler is registered for a type.
|
|
99
|
+
this.subscriber.on('message', (channel: string, message: string) => {
|
|
100
|
+
void this.handleMessage(channel, message);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.connected = true;
|
|
104
|
+
this.logger.log(`RedisEventBus connected to ${this.redisUrl}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async onModuleDestroy(): Promise<void> {
|
|
108
|
+
this.connected = false;
|
|
109
|
+
|
|
110
|
+
if (this.subscriber) {
|
|
111
|
+
// Unsubscribe from all channels and disconnect the subscriber.
|
|
112
|
+
// unsubscribe() with no args unsubscribes from all channels.
|
|
113
|
+
await this.subscriber.unsubscribe();
|
|
114
|
+
this.subscriber.disconnect();
|
|
115
|
+
this.subscriber = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.publisher) {
|
|
119
|
+
this.publisher.disconnect();
|
|
120
|
+
this.publisher = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.subscribedTypes.clear();
|
|
124
|
+
this.logger.log('RedisEventBus disconnected');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// IEventBus
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Publish a single event.
|
|
133
|
+
*
|
|
134
|
+
* `tx` is accepted but ignored — see module-level JSDoc for details.
|
|
135
|
+
*/
|
|
136
|
+
async publish(event: DomainEvent, tx?: DrizzleTransaction): Promise<void> {
|
|
137
|
+
void tx; // intentionally unused — Redis Pub/Sub is not transactional
|
|
138
|
+
this.assertConnected();
|
|
139
|
+
|
|
140
|
+
const payload = this.serialize(event);
|
|
141
|
+
const channel = `${CHANNEL_PREFIX}${event.type}`;
|
|
142
|
+
|
|
143
|
+
await this.publisher!.publish(channel, payload);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Publish multiple events using a pipeline so all PUBLISH commands are sent
|
|
148
|
+
* in a single round-trip.
|
|
149
|
+
*
|
|
150
|
+
* `tx` is accepted but ignored — see module-level JSDoc for details.
|
|
151
|
+
*/
|
|
152
|
+
async publishMany(events: DomainEvent[], tx?: DrizzleTransaction): Promise<void> {
|
|
153
|
+
void tx; // intentionally unused — Redis Pub/Sub is not transactional
|
|
154
|
+
if (events.length === 0) return;
|
|
155
|
+
this.assertConnected();
|
|
156
|
+
|
|
157
|
+
const pipeline = this.publisher!.pipeline();
|
|
158
|
+
for (const event of events) {
|
|
159
|
+
const payload = this.serialize(event);
|
|
160
|
+
const channel = `${CHANNEL_PREFIX}${event.type}`;
|
|
161
|
+
pipeline.publish(channel, payload);
|
|
162
|
+
}
|
|
163
|
+
await pipeline.exec();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register a handler for a specific event type.
|
|
168
|
+
* Returns an unsubscribe function — call it to remove the handler.
|
|
169
|
+
*
|
|
170
|
+
* On first handler for a type, subscribes to the per-type Redis channel.
|
|
171
|
+
* On removal of the last handler for a type, unsubscribes from the channel.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Lookup by id is unsupported on the Redis Pub/Sub backend — Pub/Sub
|
|
175
|
+
* does not retain history. Always returns `null`. Logs a warning the
|
|
176
|
+
* first time it's called so a misconfiguration surfaces visibly. Using
|
|
177
|
+
* the bridge with the Redis backend is unsupported (the bridge requires
|
|
178
|
+
* a durable event store).
|
|
179
|
+
*/
|
|
180
|
+
private warnedFindById = false;
|
|
181
|
+
async findById(_eventId: string): Promise<DomainEvent | null> {
|
|
182
|
+
if (!this.warnedFindById) {
|
|
183
|
+
this.warnedFindById = true;
|
|
184
|
+
this.logger.warn(
|
|
185
|
+
'RedisEventBus.findById is unsupported (Pub/Sub has no history). ' +
|
|
186
|
+
'The bridge subsystem requires a durable event store; switch to ' +
|
|
187
|
+
'DrizzleEventBus if you need bridge fanout.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
subscribe<T extends DomainEvent = DomainEvent>(
|
|
194
|
+
eventType: string,
|
|
195
|
+
handler: (event: T) => Promise<void>,
|
|
196
|
+
): () => void {
|
|
197
|
+
if (!this.handlers.has(eventType)) {
|
|
198
|
+
this.handlers.set(eventType, new Set());
|
|
199
|
+
// First handler for this type — subscribe to the per-type channel in Redis.
|
|
200
|
+
void this.subscribeToType(eventType);
|
|
201
|
+
}
|
|
202
|
+
const set = this.handlers.get(eventType)!;
|
|
203
|
+
const h = handler as (event: DomainEvent) => Promise<void>;
|
|
204
|
+
set.add(h);
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
set.delete(h);
|
|
208
|
+
// If no more handlers for this type, unsubscribe from the Redis channel.
|
|
209
|
+
if (set.size === 0) {
|
|
210
|
+
this.handlers.delete(eventType);
|
|
211
|
+
void this.unsubscribeFromType(eventType);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ============================================================================
|
|
217
|
+
// Internal helpers
|
|
218
|
+
// ============================================================================
|
|
219
|
+
|
|
220
|
+
private assertConnected(): void {
|
|
221
|
+
if (!this.connected || !this.publisher) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
'RedisEventBus is not connected. Ensure the module has been initialised before publishing.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private serialize(event: DomainEvent): string {
|
|
229
|
+
return JSON.stringify({
|
|
230
|
+
...event,
|
|
231
|
+
occurredAt: event.occurredAt.toISOString(),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private deserialize(raw: string): DomainEvent {
|
|
236
|
+
const parsed = JSON.parse(raw) as DomainEvent & { occurredAt: string };
|
|
237
|
+
return {
|
|
238
|
+
...parsed,
|
|
239
|
+
occurredAt: new Date(parsed.occurredAt),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async handleMessage(channel: string, message: string): Promise<void> {
|
|
244
|
+
let event: DomainEvent;
|
|
245
|
+
try {
|
|
246
|
+
event = this.deserialize(message);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
this.logger.warn(`Failed to deserialize event on channel "${channel}": ${err}`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await this.dispatch(event);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async dispatch(event: DomainEvent): Promise<void> {
|
|
256
|
+
const set = this.handlers.get(event.type);
|
|
257
|
+
if (!set) return;
|
|
258
|
+
for (const handler of set) {
|
|
259
|
+
try {
|
|
260
|
+
await handler(event);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.logger.error(
|
|
263
|
+
`Handler error for event type "${event.type}" (id: ${event.id}): ${err}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Subscribe to a per-type Redis channel.
|
|
271
|
+
* Called lazily when the first handler is registered for a type.
|
|
272
|
+
*/
|
|
273
|
+
private async subscribeToType(eventType: string): Promise<void> {
|
|
274
|
+
if (this.subscribedTypes.has(eventType)) {
|
|
275
|
+
return; // Already subscribed to this type.
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const channel = `${CHANNEL_PREFIX}${eventType}`;
|
|
279
|
+
try {
|
|
280
|
+
await this.subscriber!.subscribe(channel);
|
|
281
|
+
this.subscribedTypes.add(eventType);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.logger.error(`Failed to subscribe to channel "${channel}": ${err}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Unsubscribe from a per-type Redis channel.
|
|
289
|
+
* Called when the last handler for a type is removed.
|
|
290
|
+
*/
|
|
291
|
+
private async unsubscribeFromType(eventType: string): Promise<void> {
|
|
292
|
+
if (!this.subscribedTypes.has(eventType)) {
|
|
293
|
+
return; // Not subscribed to this type.
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const channel = `${CHANNEL_PREFIX}${eventType}`;
|
|
297
|
+
try {
|
|
298
|
+
await this.subscriber!.unsubscribe(channel);
|
|
299
|
+
this.subscribedTypes.delete(eventType);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
this.logger.error(`Failed to unsubscribe from channel "${channel}": ${err}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed errors for the events subsystem (ADR-024, EVT-6).
|
|
3
|
+
*
|
|
4
|
+
* All thrown from the publish path of `TypedEventBus`. They exist as
|
|
5
|
+
* classes so consumers can `instanceof` them in catch blocks and
|
|
6
|
+
* exception filters can map them to HTTP codes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by `TypedEventBus.publish()` when the EventsModule is configured
|
|
11
|
+
* with `multiTenant: true` and the caller did not supply
|
|
12
|
+
* `opts.metadata.tenantId`. Multi-tenant mode requires every outbox row to
|
|
13
|
+
* be attributable to a tenant — the `domain_events.tenant_id` column is
|
|
14
|
+
* populated from this value and the drain loop uses it for future
|
|
15
|
+
* tenant-scoped filtering (deferred — see ADR-024 §Multi-tenancy).
|
|
16
|
+
*
|
|
17
|
+
* Disable multi-tenancy at the module level (`multiTenant: false`, the
|
|
18
|
+
* default) to opt out of the requirement entirely.
|
|
19
|
+
*/
|
|
20
|
+
export class MissingTenantIdError extends Error {
|
|
21
|
+
override readonly name = 'MissingTenantIdError';
|
|
22
|
+
constructor(public readonly eventType: string) {
|
|
23
|
+
super(
|
|
24
|
+
`Missing tenantId for event '${eventType}'. EventsModule is configured ` +
|
|
25
|
+
`with multiTenant: true — every publish must include ` +
|
|
26
|
+
`opts.metadata.tenantId. Either pass the tenantId or disable ` +
|
|
27
|
+
`multi-tenancy on the module.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|