@auto-engineer/pipeline 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +7 -7
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/dist/src/builder/define.specs.d.ts +2 -0
  6. package/dist/src/builder/define.specs.d.ts.map +1 -0
  7. package/dist/src/builder/define.specs.js +435 -0
  8. package/dist/src/builder/define.specs.js.map +1 -0
  9. package/dist/src/core/descriptors.specs.d.ts +2 -0
  10. package/dist/src/core/descriptors.specs.d.ts.map +1 -0
  11. package/dist/src/core/descriptors.specs.js +24 -0
  12. package/dist/src/core/descriptors.specs.js.map +1 -0
  13. package/dist/src/core/types.specs.d.ts +2 -0
  14. package/dist/src/core/types.specs.d.ts.map +1 -0
  15. package/dist/src/core/types.specs.js +40 -0
  16. package/dist/src/core/types.specs.js.map +1 -0
  17. package/dist/src/graph/filter-graph.specs.d.ts +2 -0
  18. package/dist/src/graph/filter-graph.specs.d.ts.map +1 -0
  19. package/dist/src/graph/filter-graph.specs.js +204 -0
  20. package/dist/src/graph/filter-graph.specs.js.map +1 -0
  21. package/dist/src/graph/types.specs.d.ts +2 -0
  22. package/dist/src/graph/types.specs.d.ts.map +1 -0
  23. package/dist/src/graph/types.specs.js +148 -0
  24. package/dist/src/graph/types.specs.js.map +1 -0
  25. package/dist/src/logging/event-logger.specs.d.ts +2 -0
  26. package/dist/src/logging/event-logger.specs.d.ts.map +1 -0
  27. package/dist/src/logging/event-logger.specs.js +81 -0
  28. package/dist/src/logging/event-logger.specs.js.map +1 -0
  29. package/dist/src/plugins/handler-adapter.specs.d.ts +2 -0
  30. package/dist/src/plugins/handler-adapter.specs.d.ts.map +1 -0
  31. package/dist/src/plugins/handler-adapter.specs.js +129 -0
  32. package/dist/src/plugins/handler-adapter.specs.js.map +1 -0
  33. package/dist/src/plugins/plugin-loader.specs.d.ts +2 -0
  34. package/dist/src/plugins/plugin-loader.specs.d.ts.map +1 -0
  35. package/dist/src/plugins/plugin-loader.specs.js +246 -0
  36. package/dist/src/plugins/plugin-loader.specs.js.map +1 -0
  37. package/dist/src/projections/item-status-projection.specs.d.ts +2 -0
  38. package/dist/src/projections/item-status-projection.specs.d.ts.map +1 -0
  39. package/dist/src/projections/item-status-projection.specs.js +119 -0
  40. package/dist/src/projections/item-status-projection.specs.js.map +1 -0
  41. package/dist/src/projections/latest-run-projection.specs.d.ts +2 -0
  42. package/dist/src/projections/latest-run-projection.specs.d.ts.map +1 -0
  43. package/dist/src/projections/latest-run-projection.specs.js +33 -0
  44. package/dist/src/projections/latest-run-projection.specs.js.map +1 -0
  45. package/dist/src/projections/message-log-projection.specs.d.ts +2 -0
  46. package/dist/src/projections/message-log-projection.specs.d.ts.map +1 -0
  47. package/dist/src/projections/message-log-projection.specs.js +101 -0
  48. package/dist/src/projections/message-log-projection.specs.js.map +1 -0
  49. package/dist/src/projections/node-status-projection.specs.d.ts +2 -0
  50. package/dist/src/projections/node-status-projection.specs.d.ts.map +1 -0
  51. package/dist/src/projections/node-status-projection.specs.js +116 -0
  52. package/dist/src/projections/node-status-projection.specs.js.map +1 -0
  53. package/dist/src/projections/phased-execution-projection.specs.d.ts +2 -0
  54. package/dist/src/projections/phased-execution-projection.specs.d.ts.map +1 -0
  55. package/dist/src/projections/phased-execution-projection.specs.js +171 -0
  56. package/dist/src/projections/phased-execution-projection.specs.js.map +1 -0
  57. package/dist/src/projections/settled-instance-projection.specs.d.ts +2 -0
  58. package/dist/src/projections/settled-instance-projection.specs.d.ts.map +1 -0
  59. package/dist/src/projections/settled-instance-projection.specs.js +217 -0
  60. package/dist/src/projections/settled-instance-projection.specs.js.map +1 -0
  61. package/dist/src/projections/stats-projection.specs.d.ts +2 -0
  62. package/dist/src/projections/stats-projection.specs.d.ts.map +1 -0
  63. package/dist/src/projections/stats-projection.specs.js +91 -0
  64. package/dist/src/projections/stats-projection.specs.js.map +1 -0
  65. package/dist/src/runtime/await-tracker.specs.d.ts +2 -0
  66. package/dist/src/runtime/await-tracker.specs.d.ts.map +1 -0
  67. package/dist/src/runtime/await-tracker.specs.js +64 -0
  68. package/dist/src/runtime/await-tracker.specs.js.map +1 -0
  69. package/dist/src/runtime/context.specs.d.ts +2 -0
  70. package/dist/src/runtime/context.specs.d.ts.map +1 -0
  71. package/dist/src/runtime/context.specs.js +26 -0
  72. package/dist/src/runtime/context.specs.js.map +1 -0
  73. package/dist/src/runtime/event-command-map.specs.d.ts +2 -0
  74. package/dist/src/runtime/event-command-map.specs.d.ts.map +1 -0
  75. package/dist/src/runtime/event-command-map.specs.js +108 -0
  76. package/dist/src/runtime/event-command-map.specs.js.map +1 -0
  77. package/dist/src/runtime/phased-executor.specs.d.ts +2 -0
  78. package/dist/src/runtime/phased-executor.specs.d.ts.map +1 -0
  79. package/dist/src/runtime/phased-executor.specs.js +418 -0
  80. package/dist/src/runtime/phased-executor.specs.js.map +1 -0
  81. package/dist/src/runtime/pipeline-runtime.specs.d.ts +2 -0
  82. package/dist/src/runtime/pipeline-runtime.specs.d.ts.map +1 -0
  83. package/dist/src/runtime/pipeline-runtime.specs.js +227 -0
  84. package/dist/src/runtime/pipeline-runtime.specs.js.map +1 -0
  85. package/dist/src/runtime/settled-tracker.specs.d.ts +2 -0
  86. package/dist/src/runtime/settled-tracker.specs.d.ts.map +1 -0
  87. package/dist/src/runtime/settled-tracker.specs.js +811 -0
  88. package/dist/src/runtime/settled-tracker.specs.js.map +1 -0
  89. package/dist/src/server/full-orchestration.e2e.specs.d.ts +2 -0
  90. package/dist/src/server/full-orchestration.e2e.specs.d.ts.map +1 -0
  91. package/dist/src/server/full-orchestration.e2e.specs.js +561 -0
  92. package/dist/src/server/full-orchestration.e2e.specs.js.map +1 -0
  93. package/dist/src/server/pipeline-server.e2e.specs.d.ts +2 -0
  94. package/dist/src/server/pipeline-server.e2e.specs.d.ts.map +1 -0
  95. package/dist/src/server/pipeline-server.e2e.specs.js +373 -0
  96. package/dist/src/server/pipeline-server.e2e.specs.js.map +1 -0
  97. package/dist/src/server/pipeline-server.specs.d.ts +2 -0
  98. package/dist/src/server/pipeline-server.specs.d.ts.map +1 -0
  99. package/dist/src/server/pipeline-server.specs.js +1407 -0
  100. package/dist/src/server/pipeline-server.specs.js.map +1 -0
  101. package/dist/src/server/sse-manager.specs.d.ts +2 -0
  102. package/dist/src/server/sse-manager.specs.d.ts.map +1 -0
  103. package/dist/src/server/sse-manager.specs.js +178 -0
  104. package/dist/src/server/sse-manager.specs.js.map +1 -0
  105. package/dist/src/store/pipeline-event-store.specs.d.ts +2 -0
  106. package/dist/src/store/pipeline-event-store.specs.d.ts.map +1 -0
  107. package/dist/src/store/pipeline-event-store.specs.js +287 -0
  108. package/dist/src/store/pipeline-event-store.specs.js.map +1 -0
  109. package/dist/src/store/pipeline-read-model.specs.d.ts +2 -0
  110. package/dist/src/store/pipeline-read-model.specs.d.ts.map +1 -0
  111. package/dist/src/store/pipeline-read-model.specs.js +830 -0
  112. package/dist/src/store/pipeline-read-model.specs.js.map +1 -0
  113. package/dist/src/testing/event-capture.specs.d.ts +2 -0
  114. package/dist/src/testing/event-capture.specs.d.ts.map +1 -0
  115. package/dist/src/testing/event-capture.specs.js +114 -0
  116. package/dist/src/testing/event-capture.specs.js.map +1 -0
  117. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.d.ts +2 -0
  118. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.d.ts.map +1 -0
  119. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.js +263 -0
  120. package/dist/src/testing/fixtures/kanban-full.pipeline.specs.js.map +1 -0
  121. package/dist/src/testing/fixtures/kanban.pipeline.specs.d.ts +2 -0
  122. package/dist/src/testing/fixtures/kanban.pipeline.specs.d.ts.map +1 -0
  123. package/dist/src/testing/fixtures/kanban.pipeline.specs.js +29 -0
  124. package/dist/src/testing/fixtures/kanban.pipeline.specs.js.map +1 -0
  125. package/dist/src/testing/kanban-todo.e2e.specs.d.ts +2 -0
  126. package/dist/src/testing/kanban-todo.e2e.specs.d.ts.map +1 -0
  127. package/dist/src/testing/kanban-todo.e2e.specs.js +160 -0
  128. package/dist/src/testing/kanban-todo.e2e.specs.js.map +1 -0
  129. package/dist/src/testing/mock-handlers.specs.d.ts +2 -0
  130. package/dist/src/testing/mock-handlers.specs.d.ts.map +1 -0
  131. package/dist/src/testing/mock-handlers.specs.js +193 -0
  132. package/dist/src/testing/mock-handlers.specs.js.map +1 -0
  133. package/dist/src/testing/real-execution.e2e.specs.d.ts +2 -0
  134. package/dist/src/testing/real-execution.e2e.specs.d.ts.map +1 -0
  135. package/dist/src/testing/real-execution.e2e.specs.js +140 -0
  136. package/dist/src/testing/real-execution.e2e.specs.js.map +1 -0
  137. package/dist/src/testing/real-plugin.e2e.specs.d.ts +2 -0
  138. package/dist/src/testing/real-plugin.e2e.specs.d.ts.map +1 -0
  139. package/dist/src/testing/real-plugin.e2e.specs.js +65 -0
  140. package/dist/src/testing/real-plugin.e2e.specs.js.map +1 -0
  141. package/dist/src/testing/server-startup.e2e.specs.d.ts +2 -0
  142. package/dist/src/testing/server-startup.e2e.specs.d.ts.map +1 -0
  143. package/dist/src/testing/server-startup.e2e.specs.js +104 -0
  144. package/dist/src/testing/server-startup.e2e.specs.js.map +1 -0
  145. package/dist/src/testing/snapshot-compare.specs.d.ts +2 -0
  146. package/dist/src/testing/snapshot-compare.specs.d.ts.map +1 -0
  147. package/dist/src/testing/snapshot-compare.specs.js +112 -0
  148. package/dist/src/testing/snapshot-compare.specs.js.map +1 -0
  149. package/dist/src/testing/snapshot-sanitize.specs.d.ts +2 -0
  150. package/dist/src/testing/snapshot-sanitize.specs.d.ts.map +1 -0
  151. package/dist/src/testing/snapshot-sanitize.specs.js +104 -0
  152. package/dist/src/testing/snapshot-sanitize.specs.js.map +1 -0
  153. package/dist/tsconfig.tsbuildinfo +1 -1
  154. package/package.json +15 -14
  155. package/LICENSE +0 -10
@@ -0,0 +1,811 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createPipelineEventStore } from '../store/pipeline-event-store.js';
3
+ import { SettledTracker } from './settled-tracker.js';
4
+ function createESTracker(ctx, options = {}) {
5
+ return new SettledTracker({
6
+ readModel: ctx.readModel,
7
+ onError: options.onError,
8
+ onDispatch: options.onDispatch,
9
+ onEventEmit: async (event) => {
10
+ await ctx.eventStore.appendToStream(`settled-${event.data.correlationId}`, [
11
+ { type: event.type, data: event.data },
12
+ ]);
13
+ await options.onEventEmit?.(event);
14
+ },
15
+ });
16
+ }
17
+ describe('SettledTracker', () => {
18
+ let tracker;
19
+ let ctx;
20
+ beforeEach(() => {
21
+ ctx = createPipelineEventStore();
22
+ tracker = createESTracker(ctx);
23
+ });
24
+ afterEach(async () => {
25
+ await ctx.close();
26
+ });
27
+ describe('handler registration', () => {
28
+ it('should fire handler when registered command completes', async () => {
29
+ let fired = false;
30
+ tracker.registerHandler({
31
+ commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
32
+ handler: () => {
33
+ fired = true;
34
+ },
35
+ });
36
+ await tracker.onCommandStarted({ type: 'CheckTests', correlationId: 'c1', requestId: 'r1', data: {} });
37
+ await tracker.onCommandStarted({ type: 'CheckTypes', correlationId: 'c1', requestId: 'r2', data: {} });
38
+ await tracker.onCommandStarted({ type: 'CheckLint', correlationId: 'c1', requestId: 'r3', data: {} });
39
+ await tracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: {} }, 'CheckTests');
40
+ await tracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: {} }, 'CheckTypes');
41
+ await tracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: {} }, 'CheckLint');
42
+ expect(fired).toBe(true);
43
+ });
44
+ it('should fire multiple registered handlers independently', async () => {
45
+ let handler1Fired = false;
46
+ let handler2Fired = false;
47
+ tracker.registerHandler({
48
+ commandTypes: ['A', 'B'],
49
+ handler: () => {
50
+ handler1Fired = true;
51
+ },
52
+ });
53
+ tracker.registerHandler({
54
+ commandTypes: ['C', 'D'],
55
+ handler: () => {
56
+ handler2Fired = true;
57
+ },
58
+ });
59
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
60
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
61
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
62
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
63
+ expect(handler1Fired).toBe(true);
64
+ expect(handler2Fired).toBe(false);
65
+ await tracker.onCommandStarted({ type: 'C', correlationId: 'c1', requestId: 'r3', data: {} });
66
+ await tracker.onCommandStarted({ type: 'D', correlationId: 'c1', requestId: 'r4', data: {} });
67
+ await tracker.onEventReceived({ type: 'CDone', correlationId: 'c1', data: {} }, 'C');
68
+ await tracker.onEventReceived({ type: 'DDone', correlationId: 'c1', data: {} }, 'D');
69
+ expect(handler2Fired).toBe(true);
70
+ });
71
+ });
72
+ describe('command tracking', () => {
73
+ it('should track multiple commands by correlationId', async () => {
74
+ tracker.registerHandler({
75
+ commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
76
+ handler: () => { },
77
+ });
78
+ const command = {
79
+ type: 'CheckTests',
80
+ correlationId: 'c1',
81
+ requestId: 'r1',
82
+ data: {},
83
+ };
84
+ await tracker.onCommandStarted(command);
85
+ expect(await tracker.isWaitingForAsync('c1', 'CheckTests')).toBe(true);
86
+ expect(await tracker.isWaitingForAsync('c1', 'CheckTypes')).toBe(false);
87
+ });
88
+ it('should create handler instance when first tracked command arrives', async () => {
89
+ let fired = false;
90
+ tracker.registerHandler({
91
+ commandTypes: ['A', 'B'],
92
+ handler: () => {
93
+ fired = true;
94
+ },
95
+ });
96
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
97
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
98
+ expect(fired).toBe(false);
99
+ });
100
+ it('should not fire handler multiple times for same correlationId when commands arrive separately', async () => {
101
+ let fireCount = 0;
102
+ tracker.registerHandler({
103
+ commandTypes: ['A', 'B'],
104
+ handler: () => {
105
+ fireCount++;
106
+ },
107
+ });
108
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
109
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
110
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
111
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
112
+ expect(fireCount).toBe(1);
113
+ });
114
+ it('should ignore commands without correlationId', async () => {
115
+ let fired = false;
116
+ tracker.registerHandler({
117
+ commandTypes: ['A'],
118
+ handler: () => {
119
+ fired = true;
120
+ },
121
+ });
122
+ await tracker.onCommandStarted({
123
+ type: 'A',
124
+ requestId: 'r1',
125
+ data: {},
126
+ });
127
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
128
+ expect(fired).toBe(false);
129
+ });
130
+ it('should ignore commands without requestId', async () => {
131
+ let fired = false;
132
+ tracker.registerHandler({
133
+ commandTypes: ['A'],
134
+ handler: () => {
135
+ fired = true;
136
+ },
137
+ });
138
+ await tracker.onCommandStarted({
139
+ type: 'A',
140
+ correlationId: 'c1',
141
+ data: {},
142
+ });
143
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
144
+ expect(fired).toBe(false);
145
+ });
146
+ });
147
+ describe('event routing', () => {
148
+ it('should mark command as complete when event received', async () => {
149
+ tracker.registerHandler({
150
+ commandTypes: ['A'],
151
+ handler: () => { },
152
+ });
153
+ await tracker.onCommandStarted({
154
+ type: 'A',
155
+ correlationId: 'c1',
156
+ requestId: 'r1',
157
+ data: {},
158
+ });
159
+ const event = {
160
+ type: 'ADone',
161
+ correlationId: 'c1',
162
+ data: {},
163
+ };
164
+ await tracker.onEventReceived(event, 'A');
165
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
166
+ });
167
+ it('should collect events for each command type', async () => {
168
+ let receivedEvents = {};
169
+ tracker.registerHandler({
170
+ commandTypes: ['A', 'B'],
171
+ handler: (events) => {
172
+ receivedEvents = events;
173
+ },
174
+ });
175
+ await tracker.onCommandStarted({
176
+ type: 'A',
177
+ correlationId: 'c1',
178
+ requestId: 'r1',
179
+ data: {},
180
+ });
181
+ await tracker.onCommandStarted({
182
+ type: 'B',
183
+ correlationId: 'c1',
184
+ requestId: 'r2',
185
+ data: {},
186
+ });
187
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: { foo: 1 } }, 'A');
188
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: { bar: 2 } }, 'B');
189
+ expect(receivedEvents.A).toHaveLength(1);
190
+ expect(receivedEvents.A[0].type).toBe('ADone');
191
+ expect(receivedEvents.B).toHaveLength(1);
192
+ expect(receivedEvents.B[0].type).toBe('BDone');
193
+ });
194
+ it('should ignore events without correlationId', async () => {
195
+ let handlerCalled = false;
196
+ tracker.registerHandler({
197
+ commandTypes: ['A'],
198
+ handler: () => {
199
+ handlerCalled = true;
200
+ },
201
+ });
202
+ await tracker.onCommandStarted({
203
+ type: 'A',
204
+ correlationId: 'c1',
205
+ requestId: 'r1',
206
+ data: {},
207
+ });
208
+ await tracker.onEventReceived({ type: 'ADone', data: {} }, 'A');
209
+ expect(handlerCalled).toBe(false);
210
+ });
211
+ });
212
+ describe('handler execution', () => {
213
+ it('should fire handler when all commands complete', async () => {
214
+ let fired = false;
215
+ tracker.registerHandler({
216
+ commandTypes: ['A', 'B'],
217
+ handler: () => {
218
+ fired = true;
219
+ },
220
+ });
221
+ await tracker.onCommandStarted({
222
+ type: 'A',
223
+ correlationId: 'c1',
224
+ requestId: 'r1',
225
+ data: {},
226
+ });
227
+ await tracker.onCommandStarted({
228
+ type: 'B',
229
+ correlationId: 'c1',
230
+ requestId: 'r2',
231
+ data: {},
232
+ });
233
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
234
+ expect(fired).toBe(false);
235
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
236
+ expect(fired).toBe(true);
237
+ });
238
+ it('should not fire handler until all tracked commands have events', async () => {
239
+ let fireCount = 0;
240
+ tracker.registerHandler({
241
+ commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
242
+ handler: () => {
243
+ fireCount++;
244
+ },
245
+ });
246
+ await tracker.onCommandStarted({
247
+ type: 'CheckTests',
248
+ correlationId: 'c1',
249
+ requestId: 'r1',
250
+ data: {},
251
+ });
252
+ await tracker.onCommandStarted({
253
+ type: 'CheckTypes',
254
+ correlationId: 'c1',
255
+ requestId: 'r2',
256
+ data: {},
257
+ });
258
+ await tracker.onCommandStarted({
259
+ type: 'CheckLint',
260
+ correlationId: 'c1',
261
+ requestId: 'r3',
262
+ data: {},
263
+ });
264
+ await tracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: {} }, 'CheckTests');
265
+ expect(fireCount).toBe(0);
266
+ await tracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: {} }, 'CheckTypes');
267
+ expect(fireCount).toBe(0);
268
+ await tracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: {} }, 'CheckLint');
269
+ expect(fireCount).toBe(1);
270
+ });
271
+ it('should cleanup after handler fires by allowing new tracking for same correlationId', async () => {
272
+ let fireCount = 0;
273
+ tracker.registerHandler({
274
+ commandTypes: ['A'],
275
+ handler: () => {
276
+ fireCount++;
277
+ },
278
+ });
279
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
280
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
281
+ expect(fireCount).toBe(1);
282
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
283
+ });
284
+ it('should handle separate correlationIds independently', async () => {
285
+ const firedFor = [];
286
+ tracker.registerHandler({
287
+ commandTypes: ['A', 'B'],
288
+ handler: (events) => {
289
+ firedFor.push(events.A[0].correlationId ?? 'unknown');
290
+ },
291
+ });
292
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
293
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
294
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c2', requestId: 'r3', data: {} });
295
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c2', requestId: 'r4', data: {} });
296
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
297
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c2', data: {} }, 'B');
298
+ expect(firedFor).toHaveLength(0);
299
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
300
+ expect(firedFor).toEqual(['c1']);
301
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c2', data: {} }, 'A');
302
+ expect(firedFor).toEqual(['c1', 'c2']);
303
+ });
304
+ });
305
+ describe('persist for retry', () => {
306
+ it('should reset trackers when handler returns persist: true', async () => {
307
+ let callCount = 0;
308
+ tracker.registerHandler({
309
+ commandTypes: ['A'],
310
+ handler: () => {
311
+ callCount++;
312
+ return callCount < 3 ? { persist: true } : undefined;
313
+ },
314
+ });
315
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
316
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
317
+ expect(callCount).toBe(1);
318
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r2', data: {} });
319
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
320
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
321
+ expect(callCount).toBe(2);
322
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r3', data: {} });
323
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
324
+ expect(callCount).toBe(3);
325
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
326
+ });
327
+ it('should cleanup on handler error and not throw', async () => {
328
+ let handlerCalls = 0;
329
+ tracker.registerHandler({
330
+ commandTypes: ['A'],
331
+ handler: () => {
332
+ handlerCalls++;
333
+ throw new Error('Handler error');
334
+ },
335
+ });
336
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
337
+ await expect(tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A')).resolves.not.toThrow();
338
+ expect(handlerCalls).toBe(1);
339
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(false);
340
+ });
341
+ });
342
+ describe('error callback', () => {
343
+ it('should accept onError callback in options', () => {
344
+ const onError = vi.fn();
345
+ tracker = createESTracker(ctx, { onError });
346
+ expect(tracker).toBeInstanceOf(SettledTracker);
347
+ });
348
+ it('should call onError when handler throws', async () => {
349
+ const onError = vi.fn();
350
+ const thrownError = new Error('Handler failed');
351
+ tracker = createESTracker(ctx, { onError });
352
+ tracker.registerHandler({
353
+ commandTypes: ['A'],
354
+ handler: () => {
355
+ throw thrownError;
356
+ },
357
+ });
358
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
359
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
360
+ expect(onError).toHaveBeenCalledTimes(1);
361
+ expect(onError).toHaveBeenCalledWith(thrownError, {
362
+ commandTypes: ['A'],
363
+ correlationId: 'c1',
364
+ });
365
+ });
366
+ });
367
+ describe('dispatch callback', () => {
368
+ it('should call onDispatch when provided', async () => {
369
+ const dispatched = [];
370
+ tracker = createESTracker(ctx, {
371
+ onDispatch: (commandType, data, _correlationId) => {
372
+ dispatched.push({ type: commandType, data });
373
+ },
374
+ });
375
+ tracker.registerHandler({
376
+ commandTypes: ['A'],
377
+ handler: (_events, send) => {
378
+ send('FollowUp', { foo: 'bar' });
379
+ },
380
+ });
381
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
382
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
383
+ expect(dispatched).toHaveLength(1);
384
+ expect(dispatched[0]).toEqual({ type: 'FollowUp', data: { foo: 'bar' } });
385
+ });
386
+ it('should pass correlationId to onDispatch', async () => {
387
+ let receivedCorrelationId;
388
+ tracker = createESTracker(ctx, {
389
+ onDispatch: (_commandType, _data, correlationId) => {
390
+ receivedCorrelationId = correlationId;
391
+ },
392
+ });
393
+ tracker.registerHandler({
394
+ commandTypes: ['A'],
395
+ handler: (_events, send) => {
396
+ send('FollowUp', {});
397
+ },
398
+ });
399
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
400
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
401
+ expect(receivedCorrelationId).toBe('c1');
402
+ });
403
+ });
404
+ describe('event emission', () => {
405
+ it('should emit SettledInstanceCreated when first command starts for a template', async () => {
406
+ const emittedEvents = [];
407
+ tracker = createESTracker(ctx, {
408
+ onEventEmit: (event) => {
409
+ emittedEvents.push(event);
410
+ },
411
+ });
412
+ tracker.registerHandler({
413
+ commandTypes: ['A', 'B'],
414
+ handler: () => { },
415
+ });
416
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
417
+ expect(emittedEvents).toHaveLength(2);
418
+ expect(emittedEvents[0].type).toBe('SettledInstanceCreated');
419
+ expect(emittedEvents[0].data).toEqual({
420
+ templateId: 'template-A,B',
421
+ correlationId: 'c1',
422
+ commandTypes: ['A', 'B'],
423
+ });
424
+ });
425
+ it('should emit SettledCommandStarted when command starts', async () => {
426
+ const emittedEvents = [];
427
+ tracker = createESTracker(ctx, {
428
+ onEventEmit: (event) => {
429
+ emittedEvents.push(event);
430
+ },
431
+ });
432
+ tracker.registerHandler({
433
+ commandTypes: ['A', 'B'],
434
+ handler: () => { },
435
+ });
436
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
437
+ expect(emittedEvents[1].type).toBe('SettledCommandStarted');
438
+ expect(emittedEvents[1].data).toEqual({
439
+ templateId: 'template-A,B',
440
+ correlationId: 'c1',
441
+ commandType: 'A',
442
+ });
443
+ });
444
+ it('should not emit SettledInstanceCreated for subsequent commands in same instance', async () => {
445
+ const emittedEvents = [];
446
+ tracker = createESTracker(ctx, {
447
+ onEventEmit: (event) => {
448
+ emittedEvents.push(event);
449
+ },
450
+ });
451
+ tracker.registerHandler({
452
+ commandTypes: ['A', 'B'],
453
+ handler: () => { },
454
+ });
455
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
456
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
457
+ const createdEvents = emittedEvents.filter((e) => e.type === 'SettledInstanceCreated');
458
+ expect(createdEvents).toHaveLength(1);
459
+ });
460
+ it('should emit SettledEventReceived when event is received', async () => {
461
+ const emittedEvents = [];
462
+ tracker = createESTracker(ctx, {
463
+ onEventEmit: (event) => {
464
+ emittedEvents.push(event);
465
+ },
466
+ });
467
+ tracker.registerHandler({
468
+ commandTypes: ['A'],
469
+ handler: () => { },
470
+ });
471
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
472
+ emittedEvents.length = 0;
473
+ const domainEvent = { type: 'ADone', correlationId: 'c1', data: { result: 'ok' } };
474
+ await tracker.onEventReceived(domainEvent, 'A');
475
+ expect(emittedEvents[0].type).toBe('SettledEventReceived');
476
+ expect(emittedEvents[0].data).toEqual({
477
+ templateId: 'template-A',
478
+ correlationId: 'c1',
479
+ commandType: 'A',
480
+ event: domainEvent,
481
+ });
482
+ });
483
+ it('should emit SettledHandlerFired when handler fires', async () => {
484
+ const emittedEvents = [];
485
+ tracker = createESTracker(ctx, {
486
+ onEventEmit: (event) => {
487
+ emittedEvents.push(event);
488
+ },
489
+ });
490
+ tracker.registerHandler({
491
+ commandTypes: ['A'],
492
+ handler: () => { },
493
+ });
494
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
495
+ emittedEvents.length = 0;
496
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
497
+ const firedEvent = emittedEvents.find((e) => e.type === 'SettledHandlerFired');
498
+ expect(firedEvent).toBeDefined();
499
+ expect(firedEvent?.data).toEqual({
500
+ templateId: 'template-A',
501
+ correlationId: 'c1',
502
+ persist: false,
503
+ });
504
+ });
505
+ it('should emit SettledInstanceReset when handler fires without persist', async () => {
506
+ const emittedEvents = [];
507
+ tracker = createESTracker(ctx, {
508
+ onEventEmit: (event) => {
509
+ emittedEvents.push(event);
510
+ },
511
+ });
512
+ tracker.registerHandler({
513
+ commandTypes: ['A'],
514
+ handler: () => { },
515
+ });
516
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
517
+ emittedEvents.length = 0;
518
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
519
+ const resetEvent = emittedEvents.find((e) => e.type === 'SettledInstanceReset');
520
+ expect(resetEvent).toBeDefined();
521
+ expect(resetEvent?.data).toEqual({
522
+ templateId: 'template-A',
523
+ correlationId: 'c1',
524
+ });
525
+ });
526
+ it('should emit SettledInstanceReset when handler returns persist: true', async () => {
527
+ const emittedEvents = [];
528
+ tracker = createESTracker(ctx, {
529
+ onEventEmit: (event) => {
530
+ emittedEvents.push(event);
531
+ },
532
+ });
533
+ tracker.registerHandler({
534
+ commandTypes: ['A'],
535
+ handler: () => ({ persist: true }),
536
+ });
537
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
538
+ emittedEvents.length = 0;
539
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
540
+ const firedEvent = emittedEvents.find((e) => e.type === 'SettledHandlerFired');
541
+ expect(firedEvent?.data).toEqual({
542
+ templateId: 'template-A',
543
+ correlationId: 'c1',
544
+ persist: true,
545
+ });
546
+ const resetEvent = emittedEvents.find((e) => e.type === 'SettledInstanceReset');
547
+ expect(resetEvent).toBeDefined();
548
+ expect(resetEvent?.data).toEqual({
549
+ templateId: 'template-A',
550
+ correlationId: 'c1',
551
+ });
552
+ });
553
+ it('should emit SettledInstanceCleaned on handler error', async () => {
554
+ const emittedEvents = [];
555
+ tracker = createESTracker(ctx, {
556
+ onEventEmit: (event) => {
557
+ emittedEvents.push(event);
558
+ },
559
+ onError: () => { },
560
+ });
561
+ tracker.registerHandler({
562
+ commandTypes: ['A'],
563
+ handler: () => {
564
+ throw new Error('Handler error');
565
+ },
566
+ });
567
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
568
+ emittedEvents.length = 0;
569
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
570
+ const cleanedEvent = emittedEvents.find((e) => e.type === 'SettledInstanceCleaned');
571
+ expect(cleanedEvent).toBeDefined();
572
+ });
573
+ });
574
+ describe('projection-based state (full ES)', () => {
575
+ it('should query instance state from readModel after emitting events', async () => {
576
+ const { eventStore, readModel, close } = createPipelineEventStore();
577
+ try {
578
+ const emittedEvents = [];
579
+ const esTracker = new SettledTracker({
580
+ readModel,
581
+ onEventEmit: async (event) => {
582
+ emittedEvents.push(event);
583
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
584
+ { type: event.type, data: event.data },
585
+ ]);
586
+ },
587
+ });
588
+ esTracker.registerHandler({
589
+ commandTypes: ['A', 'B'],
590
+ handler: () => { },
591
+ });
592
+ await esTracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
593
+ const instance = await readModel.getSettledInstance('template-A,B', 'c1');
594
+ expect(instance).not.toBeNull();
595
+ expect(instance?.status).toBe('active');
596
+ expect(instance?.commandTrackers).toHaveLength(2);
597
+ const trackerA = instance?.commandTrackers.find((t) => t.commandType === 'A');
598
+ expect(trackerA?.hasStarted).toBe(true);
599
+ expect(trackerA?.hasCompleted).toBe(false);
600
+ }
601
+ finally {
602
+ await close();
603
+ }
604
+ });
605
+ it('should derive isWaitingFor from projection query', async () => {
606
+ const { eventStore, readModel, close } = createPipelineEventStore();
607
+ try {
608
+ const esTracker = new SettledTracker({
609
+ readModel,
610
+ onEventEmit: async (event) => {
611
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
612
+ { type: event.type, data: event.data },
613
+ ]);
614
+ },
615
+ });
616
+ esTracker.registerHandler({
617
+ commandTypes: ['A'],
618
+ handler: () => { },
619
+ });
620
+ await esTracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
621
+ const isWaiting = await esTracker.isWaitingForAsync('c1', 'A');
622
+ expect(isWaiting).toBe(true);
623
+ const isWaitingB = await esTracker.isWaitingForAsync('c1', 'B');
624
+ expect(isWaitingB).toBe(false);
625
+ }
626
+ finally {
627
+ await close();
628
+ }
629
+ });
630
+ it('should create new instance after previous instance was cleaned (retry scenario)', async () => {
631
+ const { eventStore, readModel, close } = createPipelineEventStore();
632
+ try {
633
+ let handlerCallCount = 0;
634
+ const dispatched = [];
635
+ const esTracker = new SettledTracker({
636
+ readModel,
637
+ onDispatch: (commandType, data) => {
638
+ dispatched.push({ commandType, data });
639
+ },
640
+ onEventEmit: async (event) => {
641
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
642
+ { type: event.type, data: event.data },
643
+ ]);
644
+ },
645
+ });
646
+ esTracker.registerHandler({
647
+ commandTypes: ['CheckTypes'],
648
+ handler: (events, send) => {
649
+ handlerCallCount++;
650
+ const checkTypesEvents = events.CheckTypes ?? [];
651
+ const failedEvent = checkTypesEvents.find((e) => e.type === 'TypeCheckFailed');
652
+ if (failedEvent) {
653
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
654
+ }
655
+ },
656
+ });
657
+ await esTracker.onCommandStarted({
658
+ type: 'CheckTypes',
659
+ correlationId: 'c1',
660
+ requestId: 'r1',
661
+ data: { target: './slice1' },
662
+ });
663
+ await esTracker.onEventReceived({ type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322' } }, 'CheckTypes');
664
+ expect(handlerCallCount).toBe(1);
665
+ expect(dispatched).toHaveLength(1);
666
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
667
+ dispatched.length = 0;
668
+ await esTracker.onCommandStarted({
669
+ type: 'CheckTypes',
670
+ correlationId: 'c1',
671
+ requestId: 'r2',
672
+ data: { target: './slice1' },
673
+ });
674
+ await esTracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice1' } }, 'CheckTypes');
675
+ expect(handlerCallCount).toBe(2);
676
+ }
677
+ finally {
678
+ await close();
679
+ }
680
+ });
681
+ it('should fire handler for each completion when concurrent commands run (concurrent slices scenario)', async () => {
682
+ const { eventStore, readModel, close } = createPipelineEventStore();
683
+ try {
684
+ let handlerCallCount = 0;
685
+ const dispatched = [];
686
+ const esTracker = new SettledTracker({
687
+ readModel,
688
+ onDispatch: (commandType, data) => {
689
+ dispatched.push({ commandType, data });
690
+ },
691
+ onEventEmit: async (event) => {
692
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
693
+ { type: event.type, data: event.data },
694
+ ]);
695
+ },
696
+ });
697
+ esTracker.registerHandler({
698
+ commandTypes: ['CheckTypes'],
699
+ handler: (events, send) => {
700
+ handlerCallCount++;
701
+ const checkTypesEvents = events.CheckTypes ?? [];
702
+ const failedEvent = checkTypesEvents.find((e) => e.type === 'TypeCheckFailed');
703
+ if (failedEvent) {
704
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
705
+ }
706
+ },
707
+ });
708
+ await esTracker.onCommandStarted({
709
+ type: 'CheckTypes',
710
+ correlationId: 'c1',
711
+ requestId: 'r1',
712
+ data: { target: './slice1' },
713
+ });
714
+ await esTracker.onCommandStarted({
715
+ type: 'CheckTypes',
716
+ correlationId: 'c1',
717
+ requestId: 'r2',
718
+ data: { target: './slice2' },
719
+ });
720
+ await esTracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice1' } }, 'CheckTypes');
721
+ expect(handlerCallCount).toBe(1);
722
+ await esTracker.onEventReceived({ type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322', target: './slice2' } }, 'CheckTypes');
723
+ expect(handlerCallCount).toBe(2);
724
+ expect(dispatched).toHaveLength(1);
725
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
726
+ }
727
+ finally {
728
+ await close();
729
+ }
730
+ });
731
+ it('should fire handler for interleaved slices when one fails (production scenario)', async () => {
732
+ const { eventStore, readModel, close } = createPipelineEventStore();
733
+ try {
734
+ let handlerCallCount = 0;
735
+ const dispatched = [];
736
+ const esTracker = new SettledTracker({
737
+ readModel,
738
+ onDispatch: (commandType, data) => {
739
+ dispatched.push({ commandType, data });
740
+ },
741
+ onEventEmit: async (event) => {
742
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
743
+ { type: event.type, data: event.data },
744
+ ]);
745
+ },
746
+ });
747
+ esTracker.registerHandler({
748
+ commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
749
+ handler: (events, send) => {
750
+ handlerCallCount++;
751
+ const allEvents = [...(events.CheckTests ?? []), ...(events.CheckTypes ?? []), ...(events.CheckLint ?? [])];
752
+ const failedEvent = allEvents.find((e) => e.type.includes('Failed'));
753
+ if (failedEvent) {
754
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
755
+ }
756
+ },
757
+ });
758
+ await esTracker.onCommandStarted({
759
+ type: 'CheckTests',
760
+ correlationId: 'c1',
761
+ requestId: 'slice2-tests',
762
+ data: { target: './slice2' },
763
+ });
764
+ await esTracker.onCommandStarted({
765
+ type: 'CheckTypes',
766
+ correlationId: 'c1',
767
+ requestId: 'slice2-types',
768
+ data: { target: './slice2' },
769
+ });
770
+ await esTracker.onCommandStarted({
771
+ type: 'CheckLint',
772
+ correlationId: 'c1',
773
+ requestId: 'slice2-lint',
774
+ data: { target: './slice2' },
775
+ });
776
+ await esTracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: { target: './slice2' } }, 'CheckLint');
777
+ await esTracker.onCommandStarted({
778
+ type: 'CheckTests',
779
+ correlationId: 'c1',
780
+ requestId: 'slice3-tests',
781
+ data: { target: './slice3' },
782
+ });
783
+ await esTracker.onCommandStarted({
784
+ type: 'CheckTypes',
785
+ correlationId: 'c1',
786
+ requestId: 'slice3-types',
787
+ data: { target: './slice3' },
788
+ });
789
+ await esTracker.onCommandStarted({
790
+ type: 'CheckLint',
791
+ correlationId: 'c1',
792
+ requestId: 'slice3-lint',
793
+ data: { target: './slice3' },
794
+ });
795
+ await esTracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice2' } }, 'CheckTypes');
796
+ await esTracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: { target: './slice2' } }, 'CheckTests');
797
+ expect(handlerCallCount).toBe(1);
798
+ await esTracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: { target: './slice3' } }, 'CheckLint');
799
+ await esTracker.onEventReceived({ type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322', target: './slice3' } }, 'CheckTypes');
800
+ await esTracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: { target: './slice3' } }, 'CheckTests');
801
+ expect(handlerCallCount).toBe(2);
802
+ expect(dispatched).toHaveLength(1);
803
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
804
+ }
805
+ finally {
806
+ await close();
807
+ }
808
+ });
809
+ });
810
+ });
811
+ //# sourceMappingURL=settled-tracker.specs.js.map