@hed-hog/core 0.0.294 → 0.0.296
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/dist/auth/auth.controller.d.ts +4 -4
- package/dist/auth/auth.service.d.ts +4 -4
- package/dist/challenge/challenge.service.d.ts +2 -2
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +4 -1
- package/dist/core.module.js.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/index.d.ts +4 -0
- package/dist/integration/index.d.ts.map +1 -0
- package/dist/integration/index.js +20 -0
- package/dist/integration/index.js.map +1 -0
- package/dist/integration/integration-api.validation.d.ts +2 -0
- package/dist/integration/integration-api.validation.d.ts.map +1 -0
- package/dist/integration/integration-api.validation.js +126 -0
- package/dist/integration/integration-api.validation.js.map +1 -0
- package/dist/integration/integration.module.d.ts +3 -0
- package/dist/integration/integration.module.d.ts.map +1 -0
- package/dist/integration/integration.module.js +54 -0
- package/dist/integration/integration.module.js.map +1 -0
- package/dist/integration/services/domain-event.publisher.d.ts +31 -0
- package/dist/integration/services/domain-event.publisher.d.ts.map +1 -0
- package/dist/integration/services/domain-event.publisher.js +79 -0
- package/dist/integration/services/domain-event.publisher.js.map +1 -0
- package/dist/integration/services/event-subscriber.registry.d.ts +37 -0
- package/dist/integration/services/event-subscriber.registry.d.ts.map +1 -0
- package/dist/integration/services/event-subscriber.registry.js +86 -0
- package/dist/integration/services/event-subscriber.registry.js.map +1 -0
- package/dist/integration/services/inbox.service.d.ts +55 -0
- package/dist/integration/services/inbox.service.d.ts.map +1 -0
- package/dist/integration/services/inbox.service.js +173 -0
- package/dist/integration/services/inbox.service.js.map +1 -0
- package/dist/integration/services/index.d.ts +12 -0
- package/dist/integration/services/index.d.ts.map +1 -0
- package/dist/integration/services/index.js +28 -0
- package/dist/integration/services/index.js.map +1 -0
- package/dist/integration/services/integration-developer-api.service.d.ts +30 -0
- package/dist/integration/services/integration-developer-api.service.d.ts.map +1 -0
- package/dist/integration/services/integration-developer-api.service.js +55 -0
- package/dist/integration/services/integration-developer-api.service.js.map +1 -0
- package/dist/integration/services/integration-link.service.d.ts +52 -0
- package/dist/integration/services/integration-link.service.d.ts.map +1 -0
- package/dist/integration/services/integration-link.service.js +128 -0
- package/dist/integration/services/integration-link.service.js.map +1 -0
- package/dist/integration/services/integration-settings.service.d.ts +23 -0
- package/dist/integration/services/integration-settings.service.d.ts.map +1 -0
- package/dist/integration/services/integration-settings.service.js +81 -0
- package/dist/integration/services/integration-settings.service.js.map +1 -0
- package/dist/integration/services/outbox-polling.coordinator.d.ts +45 -0
- package/dist/integration/services/outbox-polling.coordinator.d.ts.map +1 -0
- package/dist/integration/services/outbox-polling.coordinator.js +143 -0
- package/dist/integration/services/outbox-polling.coordinator.js.map +1 -0
- package/dist/integration/services/outbox.notifier.d.ts +30 -0
- package/dist/integration/services/outbox.notifier.d.ts.map +1 -0
- package/dist/integration/services/outbox.notifier.js +57 -0
- package/dist/integration/services/outbox.notifier.js.map +1 -0
- package/dist/integration/services/outbox.processor.d.ts +42 -0
- package/dist/integration/services/outbox.processor.d.ts.map +1 -0
- package/dist/integration/services/outbox.processor.job.d.ts +43 -0
- package/dist/integration/services/outbox.processor.job.d.ts.map +1 -0
- package/dist/integration/services/outbox.processor.job.js +100 -0
- package/dist/integration/services/outbox.processor.job.js.map +1 -0
- package/dist/integration/services/outbox.processor.js +208 -0
- package/dist/integration/services/outbox.processor.js.map +1 -0
- package/dist/integration/services/outbox.service.d.ts +53 -0
- package/dist/integration/services/outbox.service.d.ts.map +1 -0
- package/dist/integration/services/outbox.service.js +149 -0
- package/dist/integration/services/outbox.service.js.map +1 -0
- package/dist/integration/types/event.types.d.ts +88 -0
- package/dist/integration/types/event.types.d.ts.map +1 -0
- package/dist/integration/types/event.types.js +35 -0
- package/dist/integration/types/event.types.js.map +1 -0
- package/dist/integration/types/index.d.ts +3 -0
- package/dist/integration/types/index.d.ts.map +1 -0
- package/dist/integration/types/index.js +19 -0
- package/dist/integration/types/index.js.map +1 -0
- package/dist/integration/types/subscriber.types.d.ts +31 -0
- package/dist/integration/types/subscriber.types.d.ts.map +1 -0
- package/dist/integration/types/subscriber.types.js +3 -0
- package/dist/integration/types/subscriber.types.js.map +1 -0
- package/dist/mail/mail.controller.d.ts +2 -2
- package/dist/mail/mail.service.d.ts +2 -2
- package/dist/mail-sent/mail-sent.controller.d.ts +3 -3
- package/dist/mail-sent/mail-sent.service.d.ts +3 -3
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +3 -3
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +3 -3
- package/dist/profile/profile.service.d.ts +3 -3
- package/dist/setting/setting.controller.d.ts +12 -8
- package/dist/setting/setting.controller.d.ts.map +1 -1
- package/dist/setting/setting.service.d.ts +12 -8
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +21 -1
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +4 -4
- package/dist/user/user.service.d.ts +9 -9
- package/hedhog/data/route.yaml +2 -0
- package/hedhog/data/setting_group.yaml +955 -470
- package/hedhog/data/setting_subgroup.yaml +303 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +44 -18
- package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +134 -27
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +84 -23
- package/hedhog/frontend/app/preferences/page.tsx.ejs +45 -48
- package/hedhog/table/inbox_event.yaml +40 -0
- package/hedhog/table/integration_link.yaml +33 -0
- package/hedhog/table/outbox_event.yaml +45 -0
- package/hedhog/table/setting.yaml +7 -0
- package/hedhog/table/setting_subgroup.yaml +19 -0
- package/package.json +8 -8
- package/src/core.module.ts +4 -1
- package/src/index.ts +15 -0
- package/src/integration/README.md +397 -0
- package/src/integration/USAGE_EXAMPLE.md +279 -0
- package/src/integration/index.ts +4 -0
- package/src/integration/integration-api.validation.ts +154 -0
- package/src/integration/integration.module.ts +53 -0
- package/src/integration/services/domain-event.publisher.ts +136 -0
- package/src/integration/services/event-subscriber.registry.ts +89 -0
- package/src/integration/services/inbox.service.ts +218 -0
- package/src/integration/services/index.ts +12 -0
- package/src/integration/services/integration-developer-api.service.ts +96 -0
- package/src/integration/services/integration-link.service.ts +154 -0
- package/src/integration/services/integration-settings.service.ts +128 -0
- package/src/integration/services/outbox-polling.coordinator.ts +146 -0
- package/src/integration/services/outbox.notifier.ts +48 -0
- package/src/integration/services/outbox.processor.job.ts +97 -0
- package/src/integration/services/outbox.processor.ts +266 -0
- package/src/integration/services/outbox.service.ts +209 -0
- package/src/integration/types/event.types.ts +93 -0
- package/src/integration/types/index.ts +3 -0
- package/src/integration/types/subscriber.types.ts +37 -0
- package/src/oauth/oauth.controller.ts +17 -17
- package/src/oauth/oauth.service.ts +20 -20
- package/src/setting/setting.service.ts +27 -2
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# Core Integration Module
|
|
2
|
+
|
|
3
|
+
A reusable, event-driven cross-module integration foundation for the HedHog platform.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The **integration** module provides:
|
|
8
|
+
|
|
9
|
+
- **Durable Outbox**: Database-backed event queue with automatic retry and exponential backoff
|
|
10
|
+
- **Idempotent Inbox**: Per-consumer tracking ensures handlers never process the same event twice
|
|
11
|
+
- **Entity Integration Links**: Generic mappings between entities across different modules
|
|
12
|
+
- **Event Subscriber Registry**: Global registry for modules to register event handlers
|
|
13
|
+
- **Settings-Driven Configuration**: All timeouts, retry logic, and batch sizes configurable via SettingService
|
|
14
|
+
- **Hybrid Processing Strategy**: Database as source of truth + in-memory notifier for immediate wakeup
|
|
15
|
+
|
|
16
|
+
## Architecture
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Module A (Finance) → publishEvent() → DomainEventPublisher
|
|
20
|
+
↓
|
|
21
|
+
OutboxService (write to DB)
|
|
22
|
+
↓
|
|
23
|
+
OutboxNotifier (wake up processor)
|
|
24
|
+
↓
|
|
25
|
+
OutboxProcessorJob (hybrid loop)
|
|
26
|
+
↓
|
|
27
|
+
┌───────────────────────────────────────────┐
|
|
28
|
+
↓ ↓
|
|
29
|
+
Fetch pending events ExecuteHandlers
|
|
30
|
+
↓ ↓
|
|
31
|
+
Lock with lease time Get handlers from registry
|
|
32
|
+
↓ ↓
|
|
33
|
+
Mark as processing For each registered handler:
|
|
34
|
+
↓ ↓
|
|
35
|
+
Track inbox per consumer 1. Get/create InboxEvent
|
|
36
|
+
↓ 2. Mark as processing
|
|
37
|
+
Execute handler code 3. Call handler function
|
|
38
|
+
↓ 4. Mark as processed (or failed)
|
|
39
|
+
Handle failures: 5. Create IntegrationLinks
|
|
40
|
+
- Retry with backoff (as side effects)
|
|
41
|
+
- Dead letter after max
|
|
42
|
+
- Update outbox status
|
|
43
|
+
|
|
44
|
+
Module B (Operations) ← Subscribes via registry, receives events, creates links
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Database Schema
|
|
48
|
+
|
|
49
|
+
### `outbox_event` Table
|
|
50
|
+
|
|
51
|
+
Stores domain events for cross-module integration.
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
CREATE TABLE outbox_event (
|
|
55
|
+
id UUID PRIMARY KEY,
|
|
56
|
+
event_name VARCHAR(255) NOT NULL,
|
|
57
|
+
source_module VARCHAR(127) NOT NULL,
|
|
58
|
+
aggregate_type VARCHAR(127) NOT NULL,
|
|
59
|
+
aggregate_id VARCHAR(36) NOT NULL,
|
|
60
|
+
payload JSONB NOT NULL,
|
|
61
|
+
status VARCHAR(63) NOT NULL, -- pending | processing | processed | failed | dead_letter
|
|
62
|
+
attempt_count INT DEFAULT 0,
|
|
63
|
+
last_error VARCHAR(1023),
|
|
64
|
+
available_at TIMESTAMP WITH TIME ZONE,
|
|
65
|
+
processed_at TIMESTAMP WITH TIME ZONE,
|
|
66
|
+
created_at TIMESTAMP WITH TIME ZONE,
|
|
67
|
+
updated_at TIMESTAMP WITH TIME ZONE
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
-- Indexes for efficient polling
|
|
71
|
+
CREATE INDEX idx_outbox_event_status_available_at
|
|
72
|
+
ON outbox_event(status, available_at);
|
|
73
|
+
CREATE INDEX idx_outbox_event_source
|
|
74
|
+
ON outbox_event(source_module, aggregate_type, aggregate_id);
|
|
75
|
+
CREATE INDEX idx_outbox_event_created_at
|
|
76
|
+
ON outbox_event(created_at);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `inbox_event` Table
|
|
80
|
+
|
|
81
|
+
Per-consumer idempotency tracking to ensure exactly-once processing semantics.
|
|
82
|
+
|
|
83
|
+
```sql
|
|
84
|
+
CREATE TABLE inbox_event (
|
|
85
|
+
id UUID PRIMARY KEY,
|
|
86
|
+
outbox_event_id UUID NOT NULL REFERENCES outbox_event(id) ON DELETE CASCADE,
|
|
87
|
+
consumer_name VARCHAR(255) NOT NULL,
|
|
88
|
+
status VARCHAR(63) NOT NULL, -- received | processing | processed | failed | skipped
|
|
89
|
+
attempt_count INT DEFAULT 0,
|
|
90
|
+
last_error VARCHAR(1023),
|
|
91
|
+
processed_at TIMESTAMP WITH TIME ZONE,
|
|
92
|
+
created_at TIMESTAMP WITH TIME ZONE,
|
|
93
|
+
updated_at TIMESTAMP WITH TIME ZONE
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
-- Unique constraint ensures idempotency
|
|
97
|
+
CREATE UNIQUE INDEX idx_inbox_event_idempotency
|
|
98
|
+
ON inbox_event(outbox_event_id, consumer_name);
|
|
99
|
+
CREATE INDEX idx_inbox_event_status
|
|
100
|
+
ON inbox_event(status);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `integration_link` Table
|
|
104
|
+
|
|
105
|
+
Generic mappings between entities across modules (no direct foreign keys).
|
|
106
|
+
|
|
107
|
+
```sql
|
|
108
|
+
CREATE TABLE integration_link (
|
|
109
|
+
id UUID PRIMARY KEY,
|
|
110
|
+
source_module VARCHAR(127) NOT NULL,
|
|
111
|
+
source_entity_type VARCHAR(127) NOT NULL,
|
|
112
|
+
source_entity_id VARCHAR(36) NOT NULL,
|
|
113
|
+
target_module VARCHAR(127) NOT NULL,
|
|
114
|
+
target_entity_type VARCHAR(127) NOT NULL,
|
|
115
|
+
target_entity_id VARCHAR(36) NOT NULL,
|
|
116
|
+
link_type VARCHAR(63) NOT NULL, -- reference | cascade | aggregate_root
|
|
117
|
+
metadata JSONB,
|
|
118
|
+
created_at TIMESTAMP WITH TIME ZONE,
|
|
119
|
+
updated_at TIMESTAMP WITH TIME ZONE
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
-- Efficient lookups
|
|
123
|
+
CREATE INDEX idx_integration_link_source
|
|
124
|
+
ON integration_link(source_module, source_entity_type, source_entity_id);
|
|
125
|
+
CREATE INDEX idx_integration_link_target
|
|
126
|
+
ON integration_link(target_module, target_entity_type, target_entity_id);
|
|
127
|
+
CREATE INDEX idx_integration_link_type
|
|
128
|
+
ON integration_link(link_type);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Core Services
|
|
132
|
+
|
|
133
|
+
### `DomainEventPublisher`
|
|
134
|
+
|
|
135
|
+
Entry point for modules to publish events.
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
@Injectable()
|
|
139
|
+
export class DomainEventPublisher {
|
|
140
|
+
async publishEvent(
|
|
141
|
+
eventName: string,
|
|
142
|
+
sourceModule: string,
|
|
143
|
+
aggregateType: string,
|
|
144
|
+
aggregateId: string,
|
|
145
|
+
payload?: Record<string, any>,
|
|
146
|
+
): Promise<void>
|
|
147
|
+
|
|
148
|
+
async publishEvents(events: DomainEvent[]): Promise<void>
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Usage:**
|
|
153
|
+
```typescript
|
|
154
|
+
@Injectable()
|
|
155
|
+
export class InvoiceService {
|
|
156
|
+
constructor(
|
|
157
|
+
private readonly eventPublisher: DomainEventPublisher,
|
|
158
|
+
) {}
|
|
159
|
+
|
|
160
|
+
async createInvoice(data: any): Promise<Invoice> {
|
|
161
|
+
const invoice = await this.prisma.invoice.create({ data });
|
|
162
|
+
|
|
163
|
+
await this.eventPublisher.publishEvent(
|
|
164
|
+
'invoice.created',
|
|
165
|
+
'finance',
|
|
166
|
+
'Invoice',
|
|
167
|
+
invoice.id,
|
|
168
|
+
{ amount: invoice.amount },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return invoice;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### `IntegrationDeveloperApiService`
|
|
177
|
+
|
|
178
|
+
Single developer-facing facade for publishing events, subscribing handlers, and managing links.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
@Injectable()
|
|
182
|
+
export class OperationsBillingIntegration {
|
|
183
|
+
constructor(private readonly integrationApi: IntegrationDeveloperApiService) {}
|
|
184
|
+
|
|
185
|
+
async publishProjectReady(projectId: string): Promise<void> {
|
|
186
|
+
await this.integrationApi.publishEvent({
|
|
187
|
+
eventName: 'operations.project.billing_ready',
|
|
188
|
+
sourceModule: 'operations',
|
|
189
|
+
aggregateType: 'project',
|
|
190
|
+
aggregateId: projectId,
|
|
191
|
+
payload: { projectId },
|
|
192
|
+
metadata: { producer: 'operations' },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
onModuleInit(): void {
|
|
197
|
+
this.integrationApi.subscribe({
|
|
198
|
+
eventName: 'operations.contract.payable_generated',
|
|
199
|
+
consumerName: 'operations-finance-handler',
|
|
200
|
+
priority: 10,
|
|
201
|
+
handler: async (event, context) => {
|
|
202
|
+
context.logger.log(`Received ${event.eventName} for ${event.aggregateId}`);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### `EventSubscriberRegistry`
|
|
210
|
+
|
|
211
|
+
Global registry for handler registration.
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
@Injectable()
|
|
215
|
+
export class EventSubscriberRegistry {
|
|
216
|
+
registerHandler(definition: SubscriberDefinition): void
|
|
217
|
+
getHandlers(eventName: string): SubscriberDefinition[]
|
|
218
|
+
hasHandlers(eventName: string): boolean
|
|
219
|
+
getEventNames(): string[]
|
|
220
|
+
getHandlerCount(): number
|
|
221
|
+
clear(): void
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Usage:**
|
|
226
|
+
```typescript
|
|
227
|
+
@Injectable()
|
|
228
|
+
export class OperationsEventHandlers implements OnModuleInit {
|
|
229
|
+
constructor(private readonly registry: EventSubscriberRegistry) {}
|
|
230
|
+
|
|
231
|
+
onModuleInit(): void {
|
|
232
|
+
this.registry.registerHandler({
|
|
233
|
+
eventName: 'invoice.created',
|
|
234
|
+
consumerName: 'operations-module',
|
|
235
|
+
priority: 10,
|
|
236
|
+
handler: async (event, context) => {
|
|
237
|
+
// Handle event
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### `InboxService`
|
|
245
|
+
|
|
246
|
+
Tracks per-consumer processing for idempotency.
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
@Injectable()
|
|
250
|
+
export class InboxService {
|
|
251
|
+
async getOrCreate(
|
|
252
|
+
outboxEventId: string,
|
|
253
|
+
consumerName: string,
|
|
254
|
+
): Promise<InboxEvent>
|
|
255
|
+
|
|
256
|
+
async markProcessing(inboxEventId: string): Promise<InboxEvent>
|
|
257
|
+
async markProcessed(inboxEventId: string): Promise<InboxEvent>
|
|
258
|
+
async markFailed(inboxEventId: string, error: string): Promise<InboxEvent>
|
|
259
|
+
async markSkipped(inboxEventId: string): Promise<InboxEvent>
|
|
260
|
+
async isProcessed(
|
|
261
|
+
outboxEventId: string,
|
|
262
|
+
consumerName: string,
|
|
263
|
+
): Promise<boolean>
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `IntegrationLinkService`
|
|
268
|
+
|
|
269
|
+
Create and query cross-module entity relationships.
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
@Injectable()
|
|
273
|
+
export class IntegrationLinkService {
|
|
274
|
+
async createLink(dto: CreateIntegrationLinkDto): Promise<IntegrationLink>
|
|
275
|
+
async findOutbound(
|
|
276
|
+
sourceModule: string,
|
|
277
|
+
sourceEntityType: string,
|
|
278
|
+
sourceEntityId: string,
|
|
279
|
+
): Promise<IntegrationLink[]>
|
|
280
|
+
async findInbound(
|
|
281
|
+
targetModule: string,
|
|
282
|
+
targetEntityType: string,
|
|
283
|
+
targetEntityId: string,
|
|
284
|
+
): Promise<IntegrationLink[]>
|
|
285
|
+
async findByLinkType(linkType: LinkType): Promise<IntegrationLink[]>
|
|
286
|
+
async findBidirectional(...): Promise<IntegrationLink | null>
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The facade `IntegrationDeveloperApiService` also provides link methods:
|
|
291
|
+
|
|
292
|
+
- `createLink(dto, persistenceClient?)`
|
|
293
|
+
- `findLinksBySource({ module, entityType, entityId })`
|
|
294
|
+
- `findLinksByTarget({ module, entityType, entityId })`
|
|
295
|
+
- `findLinksByType(linkType)`
|
|
296
|
+
|
|
297
|
+
### `OutboxProcessor`
|
|
298
|
+
|
|
299
|
+
Processes pending events, executes handlers, and manages retries.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
@Injectable()
|
|
303
|
+
export class OutboxProcessor {
|
|
304
|
+
async processBatch(): Promise<number> // Process one batch
|
|
305
|
+
async startupDrain(): Promise<number> // Drain on startup
|
|
306
|
+
async getStats(): Promise<ProcessorStats>
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Settings Configuration
|
|
311
|
+
|
|
312
|
+
All integration settings are registered in `setting_group.yaml` under the `integration` group:
|
|
313
|
+
|
|
314
|
+
| Setting Key | Type | Default | Purpose |
|
|
315
|
+
|---|---|---|---|
|
|
316
|
+
| `core.integration.outbox.enabled` | boolean | `true` | Enable/disable outbox system |
|
|
317
|
+
| `core.integration.outbox.processor.enabled` | boolean | `true` | Enable/disable processor job |
|
|
318
|
+
| `core.integration.outbox.startupDrainEnabled` | boolean | `true` | Run drain on startup |
|
|
319
|
+
| `core.integration.outbox.pollingIntervalMs` | number | `5000` | Poll interval when processing |
|
|
320
|
+
| `core.integration.outbox.idlePollingIntervalMs` | number | `30000` | Poll interval when idle |
|
|
321
|
+
| `core.integration.outbox.batchSize` | number | `10` | Events per batch |
|
|
322
|
+
| `core.integration.outbox.maxAttempts` | number | `3` | Retry attempts |
|
|
323
|
+
| `core.integration.outbox.processingLeaseMs` | number | `30000` | Lock duration |
|
|
324
|
+
| `core.integration.outbox.retryBaseDelayMs` | number | `1000` | Base delay for backoff |
|
|
325
|
+
| `core.integration.outbox.deadLetterEnabled` | boolean | `true` | Move failed events to dead letter |
|
|
326
|
+
| `core.integration.outbox.startupDrainBatchSize` | number | `50` | Batch size for startup drain |
|
|
327
|
+
|
|
328
|
+
## Event Handler Contract
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
export type EventHandler = (
|
|
332
|
+
event: DomainEvent,
|
|
333
|
+
context: SubscriberContext,
|
|
334
|
+
) => Promise<void>
|
|
335
|
+
|
|
336
|
+
export interface DomainEvent {
|
|
337
|
+
eventName: string
|
|
338
|
+
sourceModule: string
|
|
339
|
+
aggregateType: string
|
|
340
|
+
aggregateId: string
|
|
341
|
+
payload: Record<string, any>
|
|
342
|
+
timestamp: Date
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface SubscriberContext {
|
|
346
|
+
logger: Logger // NestJS Logger
|
|
347
|
+
inboxService: InboxService // Mark as processed/failed
|
|
348
|
+
linkService: IntegrationLinkService // Create links
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Failure Handling & Retry Logic
|
|
353
|
+
|
|
354
|
+
1. Handler executes; if exception thrown → marked as failed
|
|
355
|
+
2. Exponential backoff: delay = `retryBaseDelayMs` × 2^(attemptCount)
|
|
356
|
+
3. After `maxAttempts`, event moves to `dead_letter` status
|
|
357
|
+
4. Processing lease prevents stuck events: if not updated within `processingLeaseMs`, available for reprocessing
|
|
358
|
+
5. Startup drain recovers all pending events on app restart
|
|
359
|
+
|
|
360
|
+
## Idempotency
|
|
361
|
+
|
|
362
|
+
The inbox table with unique constraint on `(outboxEventId, consumerName)` ensures:
|
|
363
|
+
|
|
364
|
+
- Same handler never processes same event twice
|
|
365
|
+
- Crashes during processing don't cause duplicates
|
|
366
|
+
- Replay is safe (read-only handlers preferred)
|
|
367
|
+
|
|
368
|
+
**Important**: Applications must still make handlers idempotent in logic:
|
|
369
|
+
- Operations should be repeatable without side effects
|
|
370
|
+
- Or: check for existence before creating
|
|
371
|
+
- Or: use database-level constraints (unique, upsert)
|
|
372
|
+
|
|
373
|
+
## Performance Considerations
|
|
374
|
+
|
|
375
|
+
- **Polling Interval**: Tune based on event volume and latency requirements
|
|
376
|
+
- **Batch Size**: Larger batches reduce DB queries but block longer
|
|
377
|
+
- **Processing Lease**: Must exceed typical handler execution time
|
|
378
|
+
- **Dead Letter Threshold**: Balance between retries and operational alerts
|
|
379
|
+
- **Indexes**: Already defined for common query patterns
|
|
380
|
+
|
|
381
|
+
## Testing
|
|
382
|
+
|
|
383
|
+
See [USAGE_EXAMPLE.md](./USAGE_EXAMPLE.md) for examples of how to:
|
|
384
|
+
- Publish events from one module
|
|
385
|
+
- Register handlers in another module
|
|
386
|
+
- Query integration links
|
|
387
|
+
- Test idempotency
|
|
388
|
+
|
|
389
|
+
## Future Enhancements
|
|
390
|
+
|
|
391
|
+
- Event schema registry and versioning
|
|
392
|
+
- Distributed tracing / correlation IDs
|
|
393
|
+
- Event replay / time-travel features
|
|
394
|
+
- Saga orchestration (choreography → orchestration)
|
|
395
|
+
- Webhook / external event ingestion
|
|
396
|
+
- Dead letter queue UI dashboard
|
|
397
|
+
- Metrics and observability
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXAMPLE: How a Module Uses the Integration Foundation
|
|
3
|
+
*
|
|
4
|
+
* This example shows how the finance or operations module would use
|
|
5
|
+
* the core integration infrastructure to publish events and handle
|
|
6
|
+
* events from other modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// STEP 1: Publish a Domain Event from Finance Module
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import { Injectable } from '@nestjs/common';
|
|
14
|
+
import { IntegrationDeveloperApiService } from '@hed-hog/core';
|
|
15
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class InvoiceService {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly prisma: PrismaService,
|
|
21
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async createInvoice(data: any): Promise<any> {
|
|
25
|
+
// Create the invoice
|
|
26
|
+
const invoice = await this.prisma.invoice.create({ data });
|
|
27
|
+
|
|
28
|
+
// Publish event that other modules can subscribe to
|
|
29
|
+
await this.integrationApi.publishEvent({
|
|
30
|
+
eventName: 'invoice.created',
|
|
31
|
+
sourceModule: 'finance',
|
|
32
|
+
aggregateType: 'Invoice',
|
|
33
|
+
aggregateId: invoice.id,
|
|
34
|
+
payload: {
|
|
35
|
+
invoiceId: invoice.id,
|
|
36
|
+
amount: invoice.amount,
|
|
37
|
+
customerId: invoice.customerId,
|
|
38
|
+
},
|
|
39
|
+
metadata: {
|
|
40
|
+
producer: 'finance',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return invoice;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async cancelInvoice(invoiceId: string): Promise<void> {
|
|
48
|
+
await this.prisma.invoice.update({
|
|
49
|
+
where: { id: invoiceId },
|
|
50
|
+
data: { status: 'CANCELLED' },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Notify subscribers
|
|
54
|
+
await this.integrationApi.publishEvent({
|
|
55
|
+
eventName: 'invoice.cancelled',
|
|
56
|
+
sourceModule: 'finance',
|
|
57
|
+
aggregateType: 'Invoice',
|
|
58
|
+
aggregateId: invoiceId,
|
|
59
|
+
payload: { invoiceId },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// STEP 2: Register Event Handlers in Operations Module
|
|
66
|
+
// ============================================================================
|
|
67
|
+
|
|
68
|
+
import { Injectable, OnModuleInit} from '@nestjs/common';
|
|
69
|
+
import {
|
|
70
|
+
IntegrationDeveloperApiService,
|
|
71
|
+
DomainEvent,
|
|
72
|
+
SubscriberContext,
|
|
73
|
+
LinkType,
|
|
74
|
+
} from '@hed-hog/core';
|
|
75
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
76
|
+
|
|
77
|
+
@Injectable()
|
|
78
|
+
export class OperationsEventHandlers implements OnModuleInit {
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly prisma: PrismaService,
|
|
81
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
82
|
+
) {}
|
|
83
|
+
|
|
84
|
+
onModuleInit(): void {
|
|
85
|
+
// Register handler for invoice.created event
|
|
86
|
+
this.integrationApi.subscribe({
|
|
87
|
+
eventName: 'invoice.created',
|
|
88
|
+
consumerName: 'operations-module',
|
|
89
|
+
priority: 10,
|
|
90
|
+
handler: async (event: DomainEvent, context: SubscriberContext) => {
|
|
91
|
+
await this.onInvoiceCreated(event, context);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Register handler for invoice.cancelled event
|
|
96
|
+
this.integrationApi.subscribe({
|
|
97
|
+
eventName: 'invoice.cancelled',
|
|
98
|
+
consumerName: 'operations-module',
|
|
99
|
+
priority: 10,
|
|
100
|
+
handler: async (event: DomainEvent, context: SubscriberContext) => {
|
|
101
|
+
await this.onInvoiceCancelled(event, context);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle invoice.created event
|
|
108
|
+
* This might trigger operational workflows, create tasks, etc.
|
|
109
|
+
*/
|
|
110
|
+
private async onInvoiceCreated(
|
|
111
|
+
event: DomainEvent,
|
|
112
|
+
context: SubscriberContext,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
context.logger.log(`Processing invoice creation: ${event.aggregateId}`);
|
|
115
|
+
|
|
116
|
+
const { invoiceId, customerId, amount } = event.payload;
|
|
117
|
+
|
|
118
|
+
// Create an operational task
|
|
119
|
+
const task = await this.prisma.task.create({
|
|
120
|
+
data: {
|
|
121
|
+
title: `Process invoice ${invoiceId}`,
|
|
122
|
+
description: `Invoice for amount ${amount} from customer ${customerId}`,
|
|
123
|
+
status: 'PENDING',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Create an integration link between the finance invoice and operations task
|
|
128
|
+
await context.linkService.createLink({
|
|
129
|
+
sourceModule: 'finance',
|
|
130
|
+
sourceEntityType: 'Invoice',
|
|
131
|
+
sourceEntityId: invoiceId,
|
|
132
|
+
targetModule: 'operations',
|
|
133
|
+
targetEntityType: 'Task',
|
|
134
|
+
targetEntityId: task.id,
|
|
135
|
+
linkType: LinkType.CASCADE,
|
|
136
|
+
metadata: {
|
|
137
|
+
reason: 'workflow_trigger',
|
|
138
|
+
invoiceAmount: amount,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
context.logger.log(
|
|
143
|
+
`Created task ${task.id} for invoice ${invoiceId}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle invoice.cancelled event
|
|
149
|
+
*/
|
|
150
|
+
private async onInvoiceCancelled(
|
|
151
|
+
event: DomainEvent,
|
|
152
|
+
context: SubscriberContext,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
context.logger.log(`Processing invoice cancellation: ${event.aggregateId}`);
|
|
155
|
+
|
|
156
|
+
const { invoiceId } = event.payload;
|
|
157
|
+
|
|
158
|
+
// Find linked tasks
|
|
159
|
+
const links = await context.linkService.findOutbound(
|
|
160
|
+
'finance',
|
|
161
|
+
'Invoice',
|
|
162
|
+
invoiceId,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
for (const link of links) {
|
|
166
|
+
// Cancel related tasks
|
|
167
|
+
if (link.targetModule === 'operations' && link.targetEntityType === 'Task') {
|
|
168
|
+
await this.prisma.task.update({
|
|
169
|
+
where: { id: link.targetEntityId },
|
|
170
|
+
data: { status: 'CANCELLED' },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
context.logger.log(
|
|
174
|
+
`Cancelled task ${link.targetEntityId} due to invoice cancellation`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// STEP 3: Register Handlers in Operations Module DI
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
import { Module } from '@nestjs/common';
|
|
186
|
+
|
|
187
|
+
@Module({
|
|
188
|
+
providers: [OperationsEventHandlers],
|
|
189
|
+
})
|
|
190
|
+
export class OperationsModule {
|
|
191
|
+
// OperationsEventHandlers will be instantiated and its onModuleInit()
|
|
192
|
+
// will be called automatically, registering event handlers
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// STEP 4: Query Integration Links to Find Related Entities
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
import { Injectable } from '@nestjs/common';
|
|
200
|
+
import { IntegrationDeveloperApiService, LinkType } from '@hed-hog/core';
|
|
201
|
+
|
|
202
|
+
@Injectable()
|
|
203
|
+
export class InvoiceQueryService {
|
|
204
|
+
constructor(
|
|
205
|
+
private readonly integrationApi: IntegrationDeveloperApiService,
|
|
206
|
+
) {}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find all operational tasks related to an invoice
|
|
210
|
+
*/
|
|
211
|
+
async getRelatedTasks(invoiceId: string): Promise<any[]> {
|
|
212
|
+
const links = await this.integrationApi.findLinksBySource({
|
|
213
|
+
module: 'finance',
|
|
214
|
+
entityType: 'Invoice',
|
|
215
|
+
entityId: invoiceId,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const taskLinks = links.filter(
|
|
219
|
+
(link) => link.targetModule === 'operations' && link.targetEntityType === 'Task',
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return taskLinks.map((link) => link.targetEntityId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Find the invoice that triggered a task
|
|
227
|
+
*/
|
|
228
|
+
async getSourceInvoice(taskId: string): Promise<string | null> {
|
|
229
|
+
const links = await this.integrationApi.findLinksByTarget({
|
|
230
|
+
module: 'operations',
|
|
231
|
+
entityType: 'Task',
|
|
232
|
+
entityId: taskId,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const invoiceLink = links.find(
|
|
236
|
+
(link) => link.sourceModule === 'finance' && link.sourceEntityType === 'Invoice',
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return invoiceLink?.sourceEntityId || null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// ARCHITECTURE NOTES
|
|
245
|
+
// ============================================================================
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* KEY PRINCIPLES:
|
|
249
|
+
*
|
|
250
|
+
* 1. DURABLE OUTBOX
|
|
251
|
+
* - Events are written to the database outbox table first
|
|
252
|
+
* - In-memory notification wakes up processor (optional acceleration)
|
|
253
|
+
* - On crashes/restarts, processor catches up on pending events
|
|
254
|
+
* - No events are lost
|
|
255
|
+
*
|
|
256
|
+
* 2. IDEMPOTENT PROCESSING
|
|
257
|
+
* - Each consumer has an inbox entry per outbox event
|
|
258
|
+
* - Unique constraint prevents duplicate processing
|
|
259
|
+
* - If a handler crashes mid-execution, retry will recognize it was attempted
|
|
260
|
+
* - Handlers must be idempotent at the application logic level
|
|
261
|
+
*
|
|
262
|
+
* 3. CROSS-MODULE DECOUPLING
|
|
263
|
+
* - Modules publish events but don't depend on other modules' internals
|
|
264
|
+
* - Integration links are explicit, queryable, and metadata-rich
|
|
265
|
+
* - No direct FK relationships across module boundaries
|
|
266
|
+
* - Modules self-register handlers, core doesn't know about them
|
|
267
|
+
*
|
|
268
|
+
* 4. CONFIGURABILITY
|
|
269
|
+
* - All timing/retry/batch/scope settings are in the core SettingService
|
|
270
|
+
* - Operations/infrastructure teams can tune without code changes
|
|
271
|
+
* - Settings can be changed at runtime
|
|
272
|
+
*
|
|
273
|
+
* 5. FAILURE RECOVERY
|
|
274
|
+
* - Exponential backoff on handler failures
|
|
275
|
+
* - Configurable max attempts
|
|
276
|
+
* - Dead letter queue for poisoned events
|
|
277
|
+
* - Processing lease time prevents stuck events
|
|
278
|
+
* - Startup drain recovers pending events after restart
|
|
279
|
+
*/
|