@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.
Files changed (140) hide show
  1. package/dist/auth/auth.controller.d.ts +4 -4
  2. package/dist/auth/auth.service.d.ts +4 -4
  3. package/dist/challenge/challenge.service.d.ts +2 -2
  4. package/dist/core.module.d.ts.map +1 -1
  5. package/dist/core.module.js +4 -1
  6. package/dist/core.module.js.map +1 -1
  7. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +1 -1
  8. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +2 -2
  9. package/dist/index.d.ts +13 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +14 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/integration/index.d.ts +4 -0
  14. package/dist/integration/index.d.ts.map +1 -0
  15. package/dist/integration/index.js +20 -0
  16. package/dist/integration/index.js.map +1 -0
  17. package/dist/integration/integration-api.validation.d.ts +2 -0
  18. package/dist/integration/integration-api.validation.d.ts.map +1 -0
  19. package/dist/integration/integration-api.validation.js +126 -0
  20. package/dist/integration/integration-api.validation.js.map +1 -0
  21. package/dist/integration/integration.module.d.ts +3 -0
  22. package/dist/integration/integration.module.d.ts.map +1 -0
  23. package/dist/integration/integration.module.js +54 -0
  24. package/dist/integration/integration.module.js.map +1 -0
  25. package/dist/integration/services/domain-event.publisher.d.ts +31 -0
  26. package/dist/integration/services/domain-event.publisher.d.ts.map +1 -0
  27. package/dist/integration/services/domain-event.publisher.js +79 -0
  28. package/dist/integration/services/domain-event.publisher.js.map +1 -0
  29. package/dist/integration/services/event-subscriber.registry.d.ts +37 -0
  30. package/dist/integration/services/event-subscriber.registry.d.ts.map +1 -0
  31. package/dist/integration/services/event-subscriber.registry.js +86 -0
  32. package/dist/integration/services/event-subscriber.registry.js.map +1 -0
  33. package/dist/integration/services/inbox.service.d.ts +55 -0
  34. package/dist/integration/services/inbox.service.d.ts.map +1 -0
  35. package/dist/integration/services/inbox.service.js +173 -0
  36. package/dist/integration/services/inbox.service.js.map +1 -0
  37. package/dist/integration/services/index.d.ts +12 -0
  38. package/dist/integration/services/index.d.ts.map +1 -0
  39. package/dist/integration/services/index.js +28 -0
  40. package/dist/integration/services/index.js.map +1 -0
  41. package/dist/integration/services/integration-developer-api.service.d.ts +30 -0
  42. package/dist/integration/services/integration-developer-api.service.d.ts.map +1 -0
  43. package/dist/integration/services/integration-developer-api.service.js +55 -0
  44. package/dist/integration/services/integration-developer-api.service.js.map +1 -0
  45. package/dist/integration/services/integration-link.service.d.ts +52 -0
  46. package/dist/integration/services/integration-link.service.d.ts.map +1 -0
  47. package/dist/integration/services/integration-link.service.js +128 -0
  48. package/dist/integration/services/integration-link.service.js.map +1 -0
  49. package/dist/integration/services/integration-settings.service.d.ts +23 -0
  50. package/dist/integration/services/integration-settings.service.d.ts.map +1 -0
  51. package/dist/integration/services/integration-settings.service.js +81 -0
  52. package/dist/integration/services/integration-settings.service.js.map +1 -0
  53. package/dist/integration/services/outbox-polling.coordinator.d.ts +45 -0
  54. package/dist/integration/services/outbox-polling.coordinator.d.ts.map +1 -0
  55. package/dist/integration/services/outbox-polling.coordinator.js +143 -0
  56. package/dist/integration/services/outbox-polling.coordinator.js.map +1 -0
  57. package/dist/integration/services/outbox.notifier.d.ts +30 -0
  58. package/dist/integration/services/outbox.notifier.d.ts.map +1 -0
  59. package/dist/integration/services/outbox.notifier.js +57 -0
  60. package/dist/integration/services/outbox.notifier.js.map +1 -0
  61. package/dist/integration/services/outbox.processor.d.ts +42 -0
  62. package/dist/integration/services/outbox.processor.d.ts.map +1 -0
  63. package/dist/integration/services/outbox.processor.job.d.ts +43 -0
  64. package/dist/integration/services/outbox.processor.job.d.ts.map +1 -0
  65. package/dist/integration/services/outbox.processor.job.js +100 -0
  66. package/dist/integration/services/outbox.processor.job.js.map +1 -0
  67. package/dist/integration/services/outbox.processor.js +208 -0
  68. package/dist/integration/services/outbox.processor.js.map +1 -0
  69. package/dist/integration/services/outbox.service.d.ts +53 -0
  70. package/dist/integration/services/outbox.service.d.ts.map +1 -0
  71. package/dist/integration/services/outbox.service.js +149 -0
  72. package/dist/integration/services/outbox.service.js.map +1 -0
  73. package/dist/integration/types/event.types.d.ts +88 -0
  74. package/dist/integration/types/event.types.d.ts.map +1 -0
  75. package/dist/integration/types/event.types.js +35 -0
  76. package/dist/integration/types/event.types.js.map +1 -0
  77. package/dist/integration/types/index.d.ts +3 -0
  78. package/dist/integration/types/index.d.ts.map +1 -0
  79. package/dist/integration/types/index.js +19 -0
  80. package/dist/integration/types/index.js.map +1 -0
  81. package/dist/integration/types/subscriber.types.d.ts +31 -0
  82. package/dist/integration/types/subscriber.types.d.ts.map +1 -0
  83. package/dist/integration/types/subscriber.types.js +3 -0
  84. package/dist/integration/types/subscriber.types.js.map +1 -0
  85. package/dist/mail/mail.controller.d.ts +2 -2
  86. package/dist/mail/mail.service.d.ts +2 -2
  87. package/dist/mail-sent/mail-sent.controller.d.ts +3 -3
  88. package/dist/mail-sent/mail-sent.service.d.ts +3 -3
  89. package/dist/oauth/oauth.controller.js.map +1 -1
  90. package/dist/oauth/oauth.service.d.ts +3 -3
  91. package/dist/oauth/oauth.service.d.ts.map +1 -1
  92. package/dist/oauth/oauth.service.js.map +1 -1
  93. package/dist/profile/profile.controller.d.ts +3 -3
  94. package/dist/profile/profile.service.d.ts +3 -3
  95. package/dist/setting/setting.controller.d.ts +12 -8
  96. package/dist/setting/setting.controller.d.ts.map +1 -1
  97. package/dist/setting/setting.service.d.ts +12 -8
  98. package/dist/setting/setting.service.d.ts.map +1 -1
  99. package/dist/setting/setting.service.js +21 -1
  100. package/dist/setting/setting.service.js.map +1 -1
  101. package/dist/user/user.controller.d.ts +4 -4
  102. package/dist/user/user.service.d.ts +9 -9
  103. package/hedhog/data/route.yaml +2 -0
  104. package/hedhog/data/setting_group.yaml +955 -470
  105. package/hedhog/data/setting_subgroup.yaml +303 -0
  106. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +44 -18
  107. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +134 -27
  108. package/hedhog/frontend/app/configurations/layout.tsx.ejs +84 -23
  109. package/hedhog/frontend/app/preferences/page.tsx.ejs +45 -48
  110. package/hedhog/table/inbox_event.yaml +40 -0
  111. package/hedhog/table/integration_link.yaml +33 -0
  112. package/hedhog/table/outbox_event.yaml +45 -0
  113. package/hedhog/table/setting.yaml +7 -0
  114. package/hedhog/table/setting_subgroup.yaml +19 -0
  115. package/package.json +8 -8
  116. package/src/core.module.ts +4 -1
  117. package/src/index.ts +15 -0
  118. package/src/integration/README.md +397 -0
  119. package/src/integration/USAGE_EXAMPLE.md +279 -0
  120. package/src/integration/index.ts +4 -0
  121. package/src/integration/integration-api.validation.ts +154 -0
  122. package/src/integration/integration.module.ts +53 -0
  123. package/src/integration/services/domain-event.publisher.ts +136 -0
  124. package/src/integration/services/event-subscriber.registry.ts +89 -0
  125. package/src/integration/services/inbox.service.ts +218 -0
  126. package/src/integration/services/index.ts +12 -0
  127. package/src/integration/services/integration-developer-api.service.ts +96 -0
  128. package/src/integration/services/integration-link.service.ts +154 -0
  129. package/src/integration/services/integration-settings.service.ts +128 -0
  130. package/src/integration/services/outbox-polling.coordinator.ts +146 -0
  131. package/src/integration/services/outbox.notifier.ts +48 -0
  132. package/src/integration/services/outbox.processor.job.ts +97 -0
  133. package/src/integration/services/outbox.processor.ts +266 -0
  134. package/src/integration/services/outbox.service.ts +209 -0
  135. package/src/integration/types/event.types.ts +93 -0
  136. package/src/integration/types/index.ts +3 -0
  137. package/src/integration/types/subscriber.types.ts +37 -0
  138. package/src/oauth/oauth.controller.ts +17 -17
  139. package/src/oauth/oauth.service.ts +20 -20
  140. package/src/setting/setting.service.ts +27 -2
@@ -0,0 +1,96 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import {
3
+ DomainEvent,
4
+ EventHandler,
5
+ IntegrationLink,
6
+ LinkType,
7
+ OutboxEvent,
8
+ SubscriberDefinition,
9
+ } from '../types';
10
+ import {
11
+ DomainEventPublisher,
12
+ PublishDomainEventInput,
13
+ PublishDomainEventOptions,
14
+ } from './domain-event.publisher';
15
+ import { EventSubscriberRegistry } from './event-subscriber.registry';
16
+ import {
17
+ CreateIntegrationLinkDto,
18
+ IntegrationLinkPersistenceClient,
19
+ IntegrationLinkService,
20
+ } from './integration-link.service';
21
+
22
+ export interface IntegrationSubscriptionInput {
23
+ eventName: string;
24
+ consumerName: string;
25
+ handler: EventHandler;
26
+ priority?: number;
27
+ }
28
+
29
+ export interface IntegrationLinkQuery {
30
+ module: string;
31
+ entityType: string;
32
+ entityId: string;
33
+ }
34
+
35
+ @Injectable()
36
+ export class IntegrationDeveloperApiService {
37
+ constructor(
38
+ private readonly publisher: DomainEventPublisher,
39
+ private readonly subscriberRegistry: EventSubscriberRegistry,
40
+ private readonly linkService: IntegrationLinkService,
41
+ ) {}
42
+
43
+ async publishEvent(
44
+ input: PublishDomainEventInput,
45
+ options?: PublishDomainEventOptions,
46
+ ): Promise<OutboxEvent> {
47
+ return this.publisher.publishEvent(input, options);
48
+ }
49
+
50
+ async publishEvents(
51
+ events: Array<DomainEvent | PublishDomainEventInput>,
52
+ options?: PublishDomainEventOptions,
53
+ ): Promise<OutboxEvent[]> {
54
+ return this.publisher.publishEvents(events, options);
55
+ }
56
+
57
+ subscribe(input: IntegrationSubscriptionInput): void {
58
+ this.subscriberRegistry.subscribe(
59
+ input.eventName,
60
+ input.consumerName,
61
+ input.handler,
62
+ input.priority || 0,
63
+ );
64
+ }
65
+
66
+ subscribeMany(definitions: SubscriberDefinition[]): void {
67
+ this.subscriberRegistry.subscribeMany(definitions);
68
+ }
69
+
70
+ async createLink(
71
+ dto: CreateIntegrationLinkDto,
72
+ persistenceClient?: IntegrationLinkPersistenceClient,
73
+ ): Promise<IntegrationLink> {
74
+ return this.linkService.createLink(dto, persistenceClient);
75
+ }
76
+
77
+ async findLinksBySource(query: IntegrationLinkQuery): Promise<IntegrationLink[]> {
78
+ return this.linkService.findOutbound(
79
+ query.module,
80
+ query.entityType,
81
+ query.entityId,
82
+ );
83
+ }
84
+
85
+ async findLinksByTarget(query: IntegrationLinkQuery): Promise<IntegrationLink[]> {
86
+ return this.linkService.findInbound(
87
+ query.module,
88
+ query.entityType,
89
+ query.entityId,
90
+ );
91
+ }
92
+
93
+ async findLinksByType(linkType: LinkType): Promise<IntegrationLink[]> {
94
+ return this.linkService.findByLinkType(linkType);
95
+ }
96
+ }
@@ -0,0 +1,154 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { Injectable } from '@nestjs/common';
3
+ import { IntegrationLink, LinkType } from '../types';
4
+
5
+ export interface CreateIntegrationLinkDto {
6
+ sourceModule: string;
7
+ sourceEntityType: string;
8
+ sourceEntityId: string;
9
+ targetModule: string;
10
+ targetEntityType: string;
11
+ targetEntityId: string;
12
+ linkType: LinkType;
13
+ metadata?: Record<string, any>;
14
+ }
15
+
16
+ export interface IntegrationLinkPersistenceClient {
17
+ integrationLink: PrismaService['integrationLink'];
18
+ }
19
+
20
+ @Injectable()
21
+ export class IntegrationLinkService {
22
+ constructor(private readonly prisma: PrismaService) {}
23
+
24
+ /**
25
+ * Create a link between entities from different modules
26
+ */
27
+ async createLink(
28
+ dto: CreateIntegrationLinkDto,
29
+ persistenceClient?: IntegrationLinkPersistenceClient,
30
+ ): Promise<IntegrationLink> {
31
+ const client = persistenceClient ?? this.prisma;
32
+
33
+ return client.integrationLink.create({
34
+ data: {
35
+ sourceModule: dto.sourceModule,
36
+ sourceEntityType: dto.sourceEntityType,
37
+ sourceEntityId: dto.sourceEntityId,
38
+ targetModule: dto.targetModule,
39
+ targetEntityType: dto.targetEntityType,
40
+ targetEntityId: dto.targetEntityId,
41
+ linkType: dto.linkType,
42
+ metadata: dto.metadata || null,
43
+ },
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Find all links originating from a source entity
49
+ */
50
+ async findOutbound(
51
+ sourceModule: string,
52
+ sourceEntityType: string,
53
+ sourceEntityId: string,
54
+ ): Promise<IntegrationLink[]> {
55
+ return this.prisma.integrationLink.findMany({
56
+ where: {
57
+ sourceModule,
58
+ sourceEntityType,
59
+ sourceEntityId,
60
+ },
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Find all links terminating at a target entity
66
+ */
67
+ async findInbound(
68
+ targetModule: string,
69
+ targetEntityType: string,
70
+ targetEntityId: string,
71
+ ): Promise<IntegrationLink[]> {
72
+ return this.prisma.integrationLink.findMany({
73
+ where: {
74
+ targetModule,
75
+ targetEntityType,
76
+ targetEntityId,
77
+ },
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Find links of a specific type
83
+ */
84
+ async findByLinkType(linkType: LinkType): Promise<IntegrationLink[]> {
85
+ return this.prisma.integrationLink.findMany({
86
+ where: { linkType },
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Delete a link
92
+ */
93
+ async deleteLink(linkId: string): Promise<IntegrationLink> {
94
+ return this.prisma.integrationLink.delete({
95
+ where: { id: linkId },
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Get link by ID
101
+ */
102
+ async getById(linkId: string): Promise<IntegrationLink | null> {
103
+ return this.prisma.integrationLink.findUnique({
104
+ where: { id: linkId },
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Find bidirectional link between two entities
110
+ */
111
+ async findBidirectional(
112
+ module1: string,
113
+ entity1Type: string,
114
+ entity1Id: string,
115
+ module2: string,
116
+ entity2Type: string,
117
+ entity2Id: string,
118
+ ): Promise<IntegrationLink | null> {
119
+ // Look for forward or reverse direction
120
+ const link = await this.prisma.integrationLink.findFirst({
121
+ where: {
122
+ OR: [
123
+ {
124
+ sourceModule: module1,
125
+ sourceEntityType: entity1Type,
126
+ sourceEntityId: entity1Id,
127
+ targetModule: module2,
128
+ targetEntityType: entity2Type,
129
+ targetEntityId: entity2Id,
130
+ },
131
+ {
132
+ sourceModule: module2,
133
+ sourceEntityType: entity2Type,
134
+ sourceEntityId: entity2Id,
135
+ targetModule: module1,
136
+ targetEntityType: entity1Type,
137
+ targetEntityId: entity1Id,
138
+ },
139
+ ],
140
+ },
141
+ });
142
+
143
+ return link;
144
+ }
145
+
146
+ /**
147
+ * Count links from a module
148
+ */
149
+ async countFromModule(sourceModule: string): Promise<number> {
150
+ return this.prisma.integrationLink.count({
151
+ where: { sourceModule },
152
+ });
153
+ }
154
+ }
@@ -0,0 +1,128 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { SettingService } from '../../setting/setting.service';
3
+
4
+ export interface IntegrationRuntimeSettings {
5
+ outboxEnabled: boolean;
6
+ outboxProcessorEnabled: boolean;
7
+ startupDrainEnabled: boolean;
8
+ pollingIntervalMs: number;
9
+ idlePollingIntervalMs: number;
10
+ batchSize: number;
11
+ startupDrainBatchSize: number;
12
+ maxAttempts: number;
13
+ processingLeaseMs: number;
14
+ retryBaseDelayMs: number;
15
+ deadLetterEnabled: boolean;
16
+ }
17
+
18
+ @Injectable()
19
+ export class IntegrationSettingsService {
20
+ private readonly logger = new Logger(IntegrationSettingsService.name);
21
+
22
+ constructor(private readonly settingService: SettingService) {}
23
+
24
+ async getRuntimeSettings(): Promise<IntegrationRuntimeSettings> {
25
+ const values = await this.settingService.getSettingValues([
26
+ 'outbox-enabled',
27
+ 'outbox-processor-enabled',
28
+ 'outbox-startup-drain-enabled',
29
+ 'outbox-polling-interval-ms',
30
+ 'outbox-idle-polling-interval-ms',
31
+ 'outbox-batch-size',
32
+ 'outbox-startup-drain-batch-size',
33
+ 'outbox-max-attempts',
34
+ 'outbox-processing-lease-ms',
35
+ 'outbox-retry-base-delay-ms',
36
+ 'outbox-dead-letter-enabled',
37
+ ]);
38
+
39
+ const pollingIntervalMs = this.getPositiveInt(
40
+ values['outbox-polling-interval-ms'],
41
+ 5000,
42
+ 100,
43
+ 'outbox-polling-interval-ms',
44
+ );
45
+
46
+ let idlePollingIntervalMs = this.getPositiveInt(
47
+ values['outbox-idle-polling-interval-ms'],
48
+ 30000,
49
+ 100,
50
+ 'outbox-idle-polling-interval-ms',
51
+ );
52
+
53
+ if (idlePollingIntervalMs < pollingIntervalMs) {
54
+ this.logger.warn(
55
+ `Setting outbox-idle-polling-interval-ms (${idlePollingIntervalMs}) is lower than outbox-polling-interval-ms (${pollingIntervalMs}). Using ${pollingIntervalMs}.`,
56
+ );
57
+ idlePollingIntervalMs = pollingIntervalMs;
58
+ }
59
+
60
+ return {
61
+ outboxEnabled: this.getBoolean(values['outbox-enabled'], true),
62
+ outboxProcessorEnabled: this.getBoolean(
63
+ values['outbox-processor-enabled'],
64
+ true,
65
+ ),
66
+ startupDrainEnabled: this.getBoolean(
67
+ values['outbox-startup-drain-enabled'],
68
+ true,
69
+ ),
70
+ pollingIntervalMs,
71
+ idlePollingIntervalMs,
72
+ batchSize: this.getPositiveInt(values['outbox-batch-size'], 10, 1, 'outbox-batch-size'),
73
+ startupDrainBatchSize: this.getPositiveInt(
74
+ values['outbox-startup-drain-batch-size'],
75
+ 50,
76
+ 1,
77
+ 'outbox-startup-drain-batch-size',
78
+ ),
79
+ maxAttempts: this.getPositiveInt(values['outbox-max-attempts'], 3, 1, 'outbox-max-attempts'),
80
+ processingLeaseMs: this.getPositiveInt(
81
+ values['outbox-processing-lease-ms'],
82
+ 30000,
83
+ 1000,
84
+ 'outbox-processing-lease-ms',
85
+ ),
86
+ retryBaseDelayMs: this.getPositiveInt(
87
+ values['outbox-retry-base-delay-ms'],
88
+ 1000,
89
+ 100,
90
+ 'outbox-retry-base-delay-ms',
91
+ ),
92
+ deadLetterEnabled: this.getBoolean(values['outbox-dead-letter-enabled'], true),
93
+ };
94
+ }
95
+
96
+ private getPositiveInt(
97
+ value: unknown,
98
+ fallback: number,
99
+ min: number,
100
+ slug: string,
101
+ ): number {
102
+ const parsed = Number(value);
103
+ if (!Number.isFinite(parsed) || parsed < min) {
104
+ this.logger.warn(
105
+ `Invalid ${slug} setting value (${String(value)}). Using fallback ${fallback}.`,
106
+ );
107
+ return fallback;
108
+ }
109
+
110
+ return Math.floor(parsed);
111
+ }
112
+
113
+ private getBoolean(value: unknown, fallback: boolean): boolean {
114
+ if (typeof value === 'boolean') {
115
+ return value;
116
+ }
117
+
118
+ if (value === 'true' || value === '1') {
119
+ return true;
120
+ }
121
+
122
+ if (value === 'false' || value === '0') {
123
+ return false;
124
+ }
125
+
126
+ return fallback;
127
+ }
128
+ }
@@ -0,0 +1,146 @@
1
+ import { Injectable, Logger } from '@nestjs/common';
2
+ import { IntegrationSettingsService } from './integration-settings.service';
3
+ import { OutboxNotifier } from './outbox.notifier';
4
+ import { OutboxProcessor } from './outbox.processor';
5
+
6
+ /**
7
+ * Hybrid polling coordinator
8
+ * Manages the adaptive polling strategy: immediate reactions + periodic checks
9
+ * Uses in-memory notifications for immediate wakeup + configurable polling intervals
10
+ */
11
+ @Injectable()
12
+ export class OutboxPollingCoordinator {
13
+ private readonly logger = new Logger(OutboxPollingCoordinator.name);
14
+ private processingLoopRunning = false;
15
+ private processingLoopPromise: Promise<void> | null = null;
16
+ private immediateWakeupTriggered = false;
17
+ private shouldStopLoop = false;
18
+
19
+ constructor(
20
+ private readonly processor: OutboxProcessor,
21
+ private readonly notifier: OutboxNotifier,
22
+ private readonly integrationSettingsService: IntegrationSettingsService,
23
+ ) {}
24
+
25
+ /**
26
+ * Start the hybrid processing loop
27
+ * Reacts to notifications immediately, polls periodically
28
+ */
29
+ async startProcessingLoop(): Promise<void> {
30
+ if (this.processingLoopRunning) {
31
+ this.logger.warn('Processing loop already running');
32
+ return;
33
+ }
34
+
35
+ this.processingLoopRunning = true;
36
+ this.shouldStopLoop = false;
37
+ this.logger.log('Starting outbox hybrid processing loop');
38
+
39
+ this.processingLoopPromise = this.runProcessingLoop();
40
+ }
41
+
42
+ /**
43
+ * Stop the processing loop gracefully
44
+ */
45
+ async stopProcessingLoop(): Promise<void> {
46
+ if (!this.processingLoopRunning) {
47
+ return;
48
+ }
49
+
50
+ this.logger.log('Stopping outbox processing loop');
51
+ this.shouldStopLoop = true;
52
+
53
+ if (this.processingLoopPromise) {
54
+ await this.processingLoopPromise;
55
+ }
56
+
57
+ this.processingLoopRunning = false;
58
+ }
59
+
60
+ /**
61
+ * Trigger immediate processing from notifier
62
+ */
63
+ triggerImmediateProcessing(): void {
64
+ this.immediateWakeupTriggered = true;
65
+ }
66
+
67
+ /**
68
+ * Check if loop is currently running
69
+ */
70
+ isRunning(): boolean {
71
+ return this.processingLoopRunning;
72
+ }
73
+
74
+ /**
75
+ * Main processing loop - runs continuously
76
+ */
77
+ private async runProcessingLoop(): Promise<void> {
78
+ // Subscribe to notifications
79
+ const immediateWakeupListener = () => {
80
+ this.triggerImmediateProcessing();
81
+ };
82
+ this.notifier.subscribe(immediateWakeupListener);
83
+
84
+ try {
85
+ while (!this.shouldStopLoop) {
86
+ const settings = await this.integrationSettingsService.getRuntimeSettings();
87
+ const isEnabled = settings.outboxEnabled;
88
+ const processorEnabled = settings.outboxProcessorEnabled;
89
+
90
+ if (!isEnabled || !processorEnabled) {
91
+ // If disabled, wait before checking again
92
+ await this.delay(settings.idlePollingIntervalMs);
93
+ continue;
94
+ }
95
+
96
+ // Check if we should process immediately (woken by notification)
97
+ const shouldProcessImmediately = this.immediateWakeupTriggered;
98
+ this.immediateWakeupTriggered = false;
99
+
100
+ // Process batch
101
+ const processedCount = await this.processor.processBatch();
102
+
103
+ // Determine next polling interval
104
+ let nextDelayMs: number;
105
+
106
+ if (shouldProcessImmediately && processedCount > 0) {
107
+ // We processed items from immediate wakeup - use active polling interval
108
+ nextDelayMs = settings.pollingIntervalMs;
109
+ this.logger.debug(
110
+ `Processed ${processedCount} events from immediate wakeup, next poll in ${nextDelayMs}ms`,
111
+ );
112
+ } else if (processedCount > 0) {
113
+ // Processed items from periodic polling - use active interval
114
+ nextDelayMs = settings.pollingIntervalMs;
115
+ this.logger.debug(
116
+ `Processed ${processedCount} events, next poll in ${nextDelayMs}ms`,
117
+ );
118
+ } else {
119
+ // No items processed - use idle interval
120
+ nextDelayMs = settings.idlePollingIntervalMs;
121
+ this.logger.debug(
122
+ `No events processed, idle polling for ${nextDelayMs}ms`,
123
+ );
124
+ }
125
+
126
+ // Wait for next poll, but be ready for immediate wakeup
127
+ await this.delay(nextDelayMs);
128
+ }
129
+ } finally {
130
+ // Cleanup: unsubscribe from notifications
131
+ this.notifier.unsubscribe(immediateWakeupListener);
132
+ this.logger.log('Processing loop stopped');
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Utility: delay with cancellation support
138
+ */
139
+ private delay(ms: number): Promise<void> {
140
+ return new Promise((resolve) => {
141
+ const timeout = setTimeout(resolve, ms);
142
+ // Store timeout for potential cancellation (not used yet, but available)
143
+ (this as any)._currentTimeout = timeout;
144
+ });
145
+ }
146
+ }
@@ -0,0 +1,48 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { EventEmitter } from 'events';
3
+
4
+ /**
5
+ * In-memory notifier for signaling new outbox events
6
+ * Provides immediate wakeup to the processor without database polling
7
+ * NOT the source of truth; database outbox is the authoritative queue
8
+ */
9
+ @Injectable()
10
+ export class OutboxNotifier {
11
+ private readonly emitter = new EventEmitter();
12
+ private readonly EVENT_WRITTEN = 'outbox:event:written';
13
+
14
+ /**
15
+ * Subscribe to outbox event notifications
16
+ */
17
+ subscribe(listener: () => void): void {
18
+ this.emitter.on(this.EVENT_WRITTEN, listener);
19
+ }
20
+
21
+ /**
22
+ * Unsubscribe from outbox event notifications
23
+ */
24
+ unsubscribe(listener: () => void): void {
25
+ this.emitter.removeListener(this.EVENT_WRITTEN, listener);
26
+ }
27
+
28
+ /**
29
+ * Emit notification that a new event was written to outbox
30
+ */
31
+ notifyEventWritten(): void {
32
+ this.emitter.emit(this.EVENT_WRITTEN);
33
+ }
34
+
35
+ /**
36
+ * Get current number of subscribers
37
+ */
38
+ getSubscriberCount(): number {
39
+ return this.emitter.listenerCount(this.EVENT_WRITTEN);
40
+ }
41
+
42
+ /**
43
+ * Remove all listeners
44
+ */
45
+ removeAllListeners(): void {
46
+ this.emitter.removeAllListeners(this.EVENT_WRITTEN);
47
+ }
48
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ OnModuleDestroy,
5
+ OnModuleInit,
6
+ } from '@nestjs/common';
7
+ import { IntegrationSettingsService } from './integration-settings.service';
8
+ import { OutboxPollingCoordinator } from './outbox-polling.coordinator';
9
+ import { OutboxNotifier } from './outbox.notifier';
10
+ import { OutboxProcessor } from './outbox.processor';
11
+
12
+ /**
13
+ * Hybrid background job for outbox processing
14
+ * Combines startup recovery, immediate notification reactions, and periodic polling
15
+ */
16
+ @Injectable()
17
+ export class OutboxProcessorJob implements OnModuleInit, OnModuleDestroy {
18
+ private readonly logger = new Logger(OutboxProcessorJob.name);
19
+
20
+ constructor(
21
+ private readonly processor: OutboxProcessor,
22
+ private readonly notifier: OutboxNotifier,
23
+ private readonly coordinator: OutboxPollingCoordinator,
24
+ private readonly integrationSettingsService: IntegrationSettingsService,
25
+ ) {}
26
+
27
+ /**
28
+ * Called when module initializes
29
+ * 1. Drain pending events from startup
30
+ * 2. Start hybrid processing loop
31
+ */
32
+ async onModuleInit(): Promise<void> {
33
+ const settings = await this.integrationSettingsService.getRuntimeSettings();
34
+ const isEnabled = settings.outboxEnabled;
35
+ if (!isEnabled) {
36
+ this.logger.debug('Outbox integration disabled in settings');
37
+ return;
38
+ }
39
+
40
+ // Step 1: Startup drain
41
+ const startupDrainEnabled = settings.startupDrainEnabled;
42
+
43
+ if (startupDrainEnabled) {
44
+ this.logger.log('Starting outbox startup drain...');
45
+ try {
46
+ const processed = await this.processor.startupDrain();
47
+ this.logger.log(
48
+ `Outbox startup drain completed: ${processed} events`,
49
+ );
50
+ } catch (error) {
51
+ this.logger.error('Outbox startup drain failed:', error);
52
+ }
53
+ }
54
+
55
+ // Step 2: Start hybrid processing loop
56
+ const processorEnabled = settings.outboxProcessorEnabled;
57
+
58
+ if (processorEnabled) {
59
+ this.logger.log('Starting outbox hybrid processing loop');
60
+ try {
61
+ await this.coordinator.startProcessingLoop();
62
+ } catch (error) {
63
+ this.logger.error('Failed to start outbox processing loop:', error);
64
+ }
65
+ } else {
66
+ this.logger.debug('Outbox processor disabled in settings');
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Called when module is destroyed
72
+ * Gracefully stop the processing loop
73
+ */
74
+ async onModuleDestroy(): Promise<void> {
75
+ this.logger.log('Shutting down outbox processing');
76
+ try {
77
+ await this.coordinator.stopProcessingLoop();
78
+ } catch (error) {
79
+ this.logger.error(
80
+ 'Error stopping outbox processing loop:',
81
+ error,
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Get current processing statistics
88
+ * Useful for health checks and monitoring
89
+ */
90
+ async getStats() {
91
+ const stats = await this.processor.getStats();
92
+ return {
93
+ ...stats,
94
+ pollingLoopRunning: this.coordinator.isRunning(),
95
+ };
96
+ }
97
+ }