@auto-engineer/pipeline 0.14.0 → 0.15.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 (249) hide show
  1. package/.turbo/turbo-build.log +5 -6
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +279 -0
  4. package/dist/src/builder/define.d.ts +6 -2
  5. package/dist/src/builder/define.d.ts.map +1 -1
  6. package/dist/src/builder/define.js +17 -7
  7. package/dist/src/builder/define.js.map +1 -1
  8. package/dist/src/builder/define.specs.js +3 -3
  9. package/dist/src/builder/define.specs.js.map +1 -1
  10. package/dist/src/core/descriptors.d.ts +6 -2
  11. package/dist/src/core/descriptors.d.ts.map +1 -1
  12. package/dist/src/graph/filter-graph.d.ts +3 -0
  13. package/dist/src/graph/filter-graph.d.ts.map +1 -0
  14. package/dist/src/graph/filter-graph.js +80 -0
  15. package/dist/src/graph/filter-graph.js.map +1 -0
  16. package/dist/src/graph/filter-graph.specs.d.ts +2 -0
  17. package/dist/src/graph/filter-graph.specs.d.ts.map +1 -0
  18. package/dist/src/graph/filter-graph.specs.js +204 -0
  19. package/dist/src/graph/filter-graph.specs.js.map +1 -0
  20. package/dist/src/graph/types.d.ts +8 -0
  21. package/dist/src/graph/types.d.ts.map +1 -1
  22. package/dist/src/index.d.ts +1 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/projections/await-tracker-projection.d.ts +31 -0
  26. package/dist/src/projections/await-tracker-projection.d.ts.map +1 -0
  27. package/dist/src/projections/await-tracker-projection.js +35 -0
  28. package/dist/src/projections/await-tracker-projection.js.map +1 -0
  29. package/dist/src/projections/index.d.ts +4 -0
  30. package/dist/src/projections/index.d.ts.map +1 -0
  31. package/dist/src/projections/index.js +4 -0
  32. package/dist/src/projections/index.js.map +1 -0
  33. package/dist/src/projections/item-status-projection.d.ts +22 -0
  34. package/dist/src/projections/item-status-projection.d.ts.map +1 -0
  35. package/dist/src/projections/item-status-projection.js +11 -0
  36. package/dist/src/projections/item-status-projection.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.d.ts +15 -0
  42. package/dist/src/projections/latest-run-projection.d.ts.map +1 -0
  43. package/dist/src/projections/latest-run-projection.js +7 -0
  44. package/dist/src/projections/latest-run-projection.js.map +1 -0
  45. package/dist/src/projections/latest-run-projection.specs.d.ts +2 -0
  46. package/dist/src/projections/latest-run-projection.specs.d.ts.map +1 -0
  47. package/dist/src/projections/latest-run-projection.specs.js +33 -0
  48. package/dist/src/projections/latest-run-projection.specs.js.map +1 -0
  49. package/dist/src/projections/message-log-projection.d.ts +51 -0
  50. package/dist/src/projections/message-log-projection.d.ts.map +1 -0
  51. package/dist/src/projections/message-log-projection.js +51 -0
  52. package/dist/src/projections/message-log-projection.js.map +1 -0
  53. package/dist/src/projections/message-log-projection.specs.d.ts +2 -0
  54. package/dist/src/projections/message-log-projection.specs.d.ts.map +1 -0
  55. package/dist/src/projections/message-log-projection.specs.js +101 -0
  56. package/dist/src/projections/message-log-projection.specs.js.map +1 -0
  57. package/dist/src/projections/node-status-projection.d.ts +23 -0
  58. package/dist/src/projections/node-status-projection.d.ts.map +1 -0
  59. package/dist/src/projections/node-status-projection.js +10 -0
  60. package/dist/src/projections/node-status-projection.js.map +1 -0
  61. package/dist/src/projections/node-status-projection.specs.d.ts +2 -0
  62. package/dist/src/projections/node-status-projection.specs.d.ts.map +1 -0
  63. package/dist/src/projections/node-status-projection.specs.js +116 -0
  64. package/dist/src/projections/node-status-projection.specs.js.map +1 -0
  65. package/dist/src/projections/phased-execution-projection.d.ts +77 -0
  66. package/dist/src/projections/phased-execution-projection.d.ts.map +1 -0
  67. package/dist/src/projections/phased-execution-projection.js +54 -0
  68. package/dist/src/projections/phased-execution-projection.js.map +1 -0
  69. package/dist/src/projections/phased-execution-projection.specs.d.ts +2 -0
  70. package/dist/src/projections/phased-execution-projection.specs.d.ts.map +1 -0
  71. package/dist/src/projections/phased-execution-projection.specs.js +171 -0
  72. package/dist/src/projections/phased-execution-projection.specs.js.map +1 -0
  73. package/dist/src/projections/settled-instance-projection.d.ts +67 -0
  74. package/dist/src/projections/settled-instance-projection.d.ts.map +1 -0
  75. package/dist/src/projections/settled-instance-projection.js +66 -0
  76. package/dist/src/projections/settled-instance-projection.js.map +1 -0
  77. package/dist/src/projections/settled-instance-projection.specs.d.ts +2 -0
  78. package/dist/src/projections/settled-instance-projection.specs.d.ts.map +1 -0
  79. package/dist/src/projections/settled-instance-projection.specs.js +217 -0
  80. package/dist/src/projections/settled-instance-projection.specs.js.map +1 -0
  81. package/dist/src/projections/stats-projection.d.ts +9 -0
  82. package/dist/src/projections/stats-projection.d.ts.map +1 -0
  83. package/dist/src/projections/stats-projection.js +16 -0
  84. package/dist/src/projections/stats-projection.js.map +1 -0
  85. package/dist/src/projections/stats-projection.specs.d.ts +2 -0
  86. package/dist/src/projections/stats-projection.specs.d.ts.map +1 -0
  87. package/dist/src/projections/stats-projection.specs.js +91 -0
  88. package/dist/src/projections/stats-projection.specs.js.map +1 -0
  89. package/dist/src/runtime/await-tracker.d.ts +17 -7
  90. package/dist/src/runtime/await-tracker.d.ts.map +1 -1
  91. package/dist/src/runtime/await-tracker.js +32 -29
  92. package/dist/src/runtime/await-tracker.js.map +1 -1
  93. package/dist/src/runtime/await-tracker.specs.js +56 -38
  94. package/dist/src/runtime/await-tracker.specs.js.map +1 -1
  95. package/dist/src/runtime/context.d.ts +1 -1
  96. package/dist/src/runtime/context.d.ts.map +1 -1
  97. package/dist/src/runtime/event-command-map.d.ts +3 -3
  98. package/dist/src/runtime/event-command-map.d.ts.map +1 -1
  99. package/dist/src/runtime/event-command-map.js +6 -2
  100. package/dist/src/runtime/event-command-map.js.map +1 -1
  101. package/dist/src/runtime/phased-executor.d.ts +15 -9
  102. package/dist/src/runtime/phased-executor.d.ts.map +1 -1
  103. package/dist/src/runtime/phased-executor.js +126 -104
  104. package/dist/src/runtime/phased-executor.js.map +1 -1
  105. package/dist/src/runtime/phased-executor.specs.js +243 -81
  106. package/dist/src/runtime/phased-executor.specs.js.map +1 -1
  107. package/dist/src/runtime/pipeline-runtime.d.ts.map +1 -1
  108. package/dist/src/runtime/pipeline-runtime.js +2 -2
  109. package/dist/src/runtime/pipeline-runtime.js.map +1 -1
  110. package/dist/src/runtime/pipeline-runtime.specs.js +35 -0
  111. package/dist/src/runtime/pipeline-runtime.specs.js.map +1 -1
  112. package/dist/src/runtime/settled-tracker.d.ts +12 -9
  113. package/dist/src/runtime/settled-tracker.d.ts.map +1 -1
  114. package/dist/src/runtime/settled-tracker.js +92 -77
  115. package/dist/src/runtime/settled-tracker.js.map +1 -1
  116. package/dist/src/runtime/settled-tracker.specs.js +568 -118
  117. package/dist/src/runtime/settled-tracker.specs.js.map +1 -1
  118. package/dist/src/server/pipeline-server.d.ts +31 -9
  119. package/dist/src/server/pipeline-server.d.ts.map +1 -1
  120. package/dist/src/server/pipeline-server.e2e.specs.js +2 -10
  121. package/dist/src/server/pipeline-server.e2e.specs.js.map +1 -1
  122. package/dist/src/server/pipeline-server.js +408 -123
  123. package/dist/src/server/pipeline-server.js.map +1 -1
  124. package/dist/src/server/pipeline-server.specs.js +777 -32
  125. package/dist/src/server/pipeline-server.specs.js.map +1 -1
  126. package/dist/src/server/sse-manager.specs.js +55 -35
  127. package/dist/src/server/sse-manager.specs.js.map +1 -1
  128. package/dist/src/store/index.d.ts +3 -0
  129. package/dist/src/store/index.d.ts.map +1 -0
  130. package/dist/src/store/index.js +3 -0
  131. package/dist/src/store/index.js.map +1 -0
  132. package/dist/src/store/pipeline-event-store.d.ts +10 -0
  133. package/dist/src/store/pipeline-event-store.d.ts.map +1 -0
  134. package/dist/src/store/pipeline-event-store.js +112 -0
  135. package/dist/src/store/pipeline-event-store.js.map +1 -0
  136. package/dist/src/store/pipeline-event-store.specs.d.ts +2 -0
  137. package/dist/src/store/pipeline-event-store.specs.d.ts.map +1 -0
  138. package/dist/src/store/pipeline-event-store.specs.js +287 -0
  139. package/dist/src/store/pipeline-event-store.specs.js.map +1 -0
  140. package/dist/src/store/pipeline-read-model.d.ts +49 -0
  141. package/dist/src/store/pipeline-read-model.d.ts.map +1 -0
  142. package/dist/src/store/pipeline-read-model.js +157 -0
  143. package/dist/src/store/pipeline-read-model.js.map +1 -0
  144. package/dist/src/store/pipeline-read-model.specs.d.ts +2 -0
  145. package/dist/src/store/pipeline-read-model.specs.d.ts.map +1 -0
  146. package/dist/src/store/pipeline-read-model.specs.js +830 -0
  147. package/dist/src/store/pipeline-read-model.specs.js.map +1 -0
  148. package/dist/src/testing/fixtures/kanban-full.pipeline.js +2 -2
  149. package/dist/src/testing/fixtures/kanban-full.pipeline.js.map +1 -1
  150. package/dist/src/testing/fixtures/kanban.pipeline.js +2 -2
  151. package/dist/src/testing/fixtures/kanban.pipeline.js.map +1 -1
  152. package/dist/tsconfig.tsbuildinfo +1 -1
  153. package/ketchup-plan.md +960 -0
  154. package/package.json +5 -4
  155. package/src/builder/define.specs.ts +3 -3
  156. package/src/builder/define.ts +24 -11
  157. package/src/core/descriptors.ts +7 -2
  158. package/src/graph/filter-graph.specs.ts +241 -0
  159. package/src/graph/filter-graph.ts +111 -0
  160. package/src/graph/types.ts +10 -0
  161. package/src/index.ts +1 -0
  162. package/src/projections/await-tracker-projection.ts +68 -0
  163. package/src/projections/index.ts +11 -0
  164. package/src/projections/item-status-projection.specs.ts +130 -0
  165. package/src/projections/item-status-projection.ts +32 -0
  166. package/src/projections/latest-run-projection.specs.ts +38 -0
  167. package/src/projections/latest-run-projection.ts +20 -0
  168. package/src/projections/message-log-projection.specs.ts +118 -0
  169. package/src/projections/message-log-projection.ts +113 -0
  170. package/src/projections/node-status-projection.specs.ts +127 -0
  171. package/src/projections/node-status-projection.ts +33 -0
  172. package/src/projections/phased-execution-projection.specs.ts +202 -0
  173. package/src/projections/phased-execution-projection.ts +146 -0
  174. package/src/projections/settled-instance-projection.specs.ts +249 -0
  175. package/src/projections/settled-instance-projection.ts +160 -0
  176. package/src/projections/stats-projection.specs.ts +105 -0
  177. package/src/projections/stats-projection.ts +26 -0
  178. package/src/runtime/await-tracker.specs.ts +57 -34
  179. package/src/runtime/await-tracker.ts +43 -31
  180. package/src/runtime/context.ts +1 -1
  181. package/src/runtime/event-command-map.ts +11 -4
  182. package/src/runtime/phased-executor.specs.ts +357 -81
  183. package/src/runtime/phased-executor.ts +142 -126
  184. package/src/runtime/pipeline-runtime.specs.ts +42 -0
  185. package/src/runtime/pipeline-runtime.ts +6 -4
  186. package/src/runtime/settled-tracker.specs.ts +716 -120
  187. package/src/runtime/settled-tracker.ts +104 -98
  188. package/src/server/pipeline-server.e2e.specs.ts +10 -16
  189. package/src/server/pipeline-server.specs.ts +964 -49
  190. package/src/server/pipeline-server.ts +512 -144
  191. package/src/server/sse-manager.specs.ts +67 -36
  192. package/src/store/index.ts +2 -0
  193. package/src/store/pipeline-event-store.specs.ts +309 -0
  194. package/src/store/pipeline-event-store.ts +156 -0
  195. package/src/store/pipeline-read-model.specs.ts +967 -0
  196. package/src/store/pipeline-read-model.ts +223 -0
  197. package/src/testing/fixtures/kanban-full.pipeline.ts +2 -2
  198. package/src/testing/fixtures/kanban.pipeline.ts +2 -2
  199. package/claude.md +0 -160
  200. package/dist/src/__tests__/e2e/helpers.d.ts +0 -48
  201. package/dist/src/__tests__/e2e/helpers.d.ts.map +0 -1
  202. package/dist/src/__tests__/e2e/helpers.js +0 -253
  203. package/dist/src/__tests__/e2e/helpers.js.map +0 -1
  204. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.d.ts +0 -2
  205. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.d.ts.map +0 -1
  206. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.js +0 -195
  207. package/dist/src/__tests__/e2e/kanban-migration.e2e.specs.js.map +0 -1
  208. package/dist/src/__tests__/e2e/types.d.ts +0 -107
  209. package/dist/src/__tests__/e2e/types.d.ts.map +0 -1
  210. package/dist/src/__tests__/e2e/types.js +0 -2
  211. package/dist/src/__tests__/e2e/types.js.map +0 -1
  212. package/dist/src/file-syncer/crypto/jwe-encryptor.d.ts +0 -15
  213. package/dist/src/file-syncer/crypto/jwe-encryptor.d.ts.map +0 -1
  214. package/dist/src/file-syncer/crypto/jwe-encryptor.js +0 -64
  215. package/dist/src/file-syncer/crypto/jwe-encryptor.js.map +0 -1
  216. package/dist/src/file-syncer/crypto/provider-resolver.d.ts +0 -24
  217. package/dist/src/file-syncer/crypto/provider-resolver.d.ts.map +0 -1
  218. package/dist/src/file-syncer/crypto/provider-resolver.js +0 -71
  219. package/dist/src/file-syncer/crypto/provider-resolver.js.map +0 -1
  220. package/dist/src/file-syncer/discovery/bareImports.d.ts +0 -3
  221. package/dist/src/file-syncer/discovery/bareImports.d.ts.map +0 -1
  222. package/dist/src/file-syncer/discovery/bareImports.js +0 -36
  223. package/dist/src/file-syncer/discovery/bareImports.js.map +0 -1
  224. package/dist/src/file-syncer/discovery/dts.d.ts +0 -8
  225. package/dist/src/file-syncer/discovery/dts.d.ts.map +0 -1
  226. package/dist/src/file-syncer/discovery/dts.js +0 -99
  227. package/dist/src/file-syncer/discovery/dts.js.map +0 -1
  228. package/dist/src/file-syncer/index.d.ts +0 -46
  229. package/dist/src/file-syncer/index.d.ts.map +0 -1
  230. package/dist/src/file-syncer/index.js +0 -392
  231. package/dist/src/file-syncer/index.js.map +0 -1
  232. package/dist/src/file-syncer/sync/resolveSyncFileSet.d.ts +0 -7
  233. package/dist/src/file-syncer/sync/resolveSyncFileSet.d.ts.map +0 -1
  234. package/dist/src/file-syncer/sync/resolveSyncFileSet.js +0 -86
  235. package/dist/src/file-syncer/sync/resolveSyncFileSet.js.map +0 -1
  236. package/dist/src/file-syncer/types/wire.d.ts +0 -14
  237. package/dist/src/file-syncer/types/wire.d.ts.map +0 -1
  238. package/dist/src/file-syncer/types/wire.js +0 -2
  239. package/dist/src/file-syncer/types/wire.js.map +0 -1
  240. package/dist/src/file-syncer/utils/hash.d.ts +0 -5
  241. package/dist/src/file-syncer/utils/hash.d.ts.map +0 -1
  242. package/dist/src/file-syncer/utils/hash.js +0 -19
  243. package/dist/src/file-syncer/utils/hash.js.map +0 -1
  244. package/dist/src/file-syncer/utils/path.d.ts +0 -13
  245. package/dist/src/file-syncer/utils/path.d.ts.map +0 -1
  246. package/dist/src/file-syncer/utils/path.js +0 -74
  247. package/dist/src/file-syncer/utils/path.js.map +0 -1
  248. package/docs/testing-analysis.md +0 -395
  249. package/pomodoro-plan.md +0 -651
@@ -1,40 +1,101 @@
1
1
  import type { Command, Event } from '@auto-engineer/message-bus';
2
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { SettledEvent } from '../projections/settled-instance-projection';
4
+ import type { PipelineEventStoreContext } from '../store/pipeline-event-store';
5
+ import { createPipelineEventStore } from '../store/pipeline-event-store';
3
6
  import { SettledTracker } from './settled-tracker';
4
7
 
8
+ interface ESTrackerOptions {
9
+ onError?: (error: unknown, context: { commandTypes: readonly string[]; correlationId: string }) => void;
10
+ onDispatch?: (commandType: string, data: unknown, correlationId: string) => void;
11
+ onEventEmit?: (event: SettledEvent) => void | Promise<void>;
12
+ }
13
+
14
+ function createESTracker(ctx: PipelineEventStoreContext, options: ESTrackerOptions = {}): SettledTracker {
15
+ return new SettledTracker({
16
+ readModel: ctx.readModel,
17
+ onError: options.onError,
18
+ onDispatch: options.onDispatch,
19
+ onEventEmit: async (event) => {
20
+ await ctx.eventStore.appendToStream(`settled-${event.data.correlationId}`, [
21
+ { type: event.type, data: event.data },
22
+ ]);
23
+ await options.onEventEmit?.(event);
24
+ },
25
+ });
26
+ }
27
+
5
28
  describe('SettledTracker', () => {
6
29
  let tracker: SettledTracker;
30
+ let ctx: PipelineEventStoreContext;
7
31
 
8
32
  beforeEach(() => {
9
- tracker = new SettledTracker();
33
+ ctx = createPipelineEventStore();
34
+ tracker = createESTracker(ctx);
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await ctx.close();
10
39
  });
11
40
 
12
41
  describe('handler registration', () => {
13
- it('should register a handler for multiple command types', () => {
42
+ it('should fire handler when registered command completes', async () => {
43
+ let fired = false;
44
+
14
45
  tracker.registerHandler({
15
46
  commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
16
- handler: () => {},
47
+ handler: () => {
48
+ fired = true;
49
+ },
17
50
  });
18
51
 
19
- expect(tracker.getRegisteredHandlerCount()).toBe(1);
52
+ await tracker.onCommandStarted({ type: 'CheckTests', correlationId: 'c1', requestId: 'r1', data: {} });
53
+ await tracker.onCommandStarted({ type: 'CheckTypes', correlationId: 'c1', requestId: 'r2', data: {} });
54
+ await tracker.onCommandStarted({ type: 'CheckLint', correlationId: 'c1', requestId: 'r3', data: {} });
55
+
56
+ await tracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: {} }, 'CheckTests');
57
+ await tracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: {} }, 'CheckTypes');
58
+ await tracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: {} }, 'CheckLint');
59
+
60
+ expect(fired).toBe(true);
20
61
  });
21
62
 
22
- it('should register multiple handlers', () => {
63
+ it('should fire multiple registered handlers independently', async () => {
64
+ let handler1Fired = false;
65
+ let handler2Fired = false;
66
+
23
67
  tracker.registerHandler({
24
68
  commandTypes: ['A', 'B'],
25
- handler: () => {},
69
+ handler: () => {
70
+ handler1Fired = true;
71
+ },
26
72
  });
27
73
  tracker.registerHandler({
28
74
  commandTypes: ['C', 'D'],
29
- handler: () => {},
75
+ handler: () => {
76
+ handler2Fired = true;
77
+ },
30
78
  });
31
79
 
32
- expect(tracker.getRegisteredHandlerCount()).toBe(2);
80
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
81
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
82
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
83
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
84
+
85
+ expect(handler1Fired).toBe(true);
86
+ expect(handler2Fired).toBe(false);
87
+
88
+ await tracker.onCommandStarted({ type: 'C', correlationId: 'c1', requestId: 'r3', data: {} });
89
+ await tracker.onCommandStarted({ type: 'D', correlationId: 'c1', requestId: 'r4', data: {} });
90
+ await tracker.onEventReceived({ type: 'CDone', correlationId: 'c1', data: {} }, 'C');
91
+ await tracker.onEventReceived({ type: 'DDone', correlationId: 'c1', data: {} }, 'D');
92
+
93
+ expect(handler2Fired).toBe(true);
33
94
  });
34
95
  });
35
96
 
36
97
  describe('command tracking', () => {
37
- it('should track multiple commands by correlationId', () => {
98
+ it('should track multiple commands by correlationId', async () => {
38
99
  tracker.registerHandler({
39
100
  commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
40
101
  handler: () => {},
@@ -47,89 +108,98 @@ describe('SettledTracker', () => {
47
108
  data: {},
48
109
  };
49
110
 
50
- tracker.onCommandStarted(command);
111
+ await tracker.onCommandStarted(command);
51
112
 
52
- expect(tracker.isWaitingFor('c1', 'CheckTests')).toBe(true);
53
- expect(tracker.isWaitingFor('c1', 'CheckTypes')).toBe(false);
113
+ expect(await tracker.isWaitingForAsync('c1', 'CheckTests')).toBe(true);
114
+ expect(await tracker.isWaitingForAsync('c1', 'CheckTypes')).toBe(false);
54
115
  });
55
116
 
56
- it('should instantiate handler instance when first tracked command arrives', () => {
117
+ it('should create handler instance when first tracked command arrives', async () => {
118
+ let fired = false;
119
+
57
120
  tracker.registerHandler({
58
121
  commandTypes: ['A', 'B'],
59
- handler: () => {},
122
+ handler: () => {
123
+ fired = true;
124
+ },
60
125
  });
61
126
 
62
- tracker.onCommandStarted({
63
- type: 'A',
64
- correlationId: 'c1',
65
- requestId: 'r1',
66
- data: {},
67
- });
127
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
68
128
 
69
- expect(tracker.getActiveInstanceCount()).toBe(1);
129
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
130
+ expect(fired).toBe(false);
70
131
  });
71
132
 
72
- it('should not create duplicate instances for same correlationId', () => {
133
+ it('should not fire handler multiple times for same correlationId when commands arrive separately', async () => {
134
+ let fireCount = 0;
135
+
73
136
  tracker.registerHandler({
74
137
  commandTypes: ['A', 'B'],
75
- handler: () => {},
138
+ handler: () => {
139
+ fireCount++;
140
+ },
76
141
  });
77
142
 
78
- tracker.onCommandStarted({
79
- type: 'A',
80
- correlationId: 'c1',
81
- requestId: 'r1',
82
- data: {},
83
- });
84
- tracker.onCommandStarted({
85
- type: 'B',
86
- correlationId: 'c1',
87
- requestId: 'r2',
88
- data: {},
89
- });
143
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
144
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
90
145
 
91
- expect(tracker.getActiveInstanceCount()).toBe(1);
146
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
147
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
148
+
149
+ expect(fireCount).toBe(1);
92
150
  });
93
151
 
94
- it('should ignore commands without correlationId', () => {
152
+ it('should ignore commands without correlationId', async () => {
153
+ let fired = false;
154
+
95
155
  tracker.registerHandler({
96
156
  commandTypes: ['A'],
97
- handler: () => {},
157
+ handler: () => {
158
+ fired = true;
159
+ },
98
160
  });
99
161
 
100
- tracker.onCommandStarted({
162
+ await tracker.onCommandStarted({
101
163
  type: 'A',
102
164
  requestId: 'r1',
103
165
  data: {},
104
166
  } as Command);
105
167
 
106
- expect(tracker.getActiveInstanceCount()).toBe(0);
168
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
169
+
170
+ expect(fired).toBe(false);
107
171
  });
108
172
 
109
- it('should ignore commands without requestId', () => {
173
+ it('should ignore commands without requestId', async () => {
174
+ let fired = false;
175
+
110
176
  tracker.registerHandler({
111
177
  commandTypes: ['A'],
112
- handler: () => {},
178
+ handler: () => {
179
+ fired = true;
180
+ },
113
181
  });
114
182
 
115
- tracker.onCommandStarted({
183
+ await tracker.onCommandStarted({
116
184
  type: 'A',
117
185
  correlationId: 'c1',
118
186
  data: {},
119
187
  } as Command);
120
188
 
121
- expect(tracker.getActiveInstanceCount()).toBe(0);
189
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
190
+
191
+ expect(fired).toBe(false);
122
192
  });
123
193
  });
124
194
 
125
195
  describe('event routing', () => {
126
- it('should mark command as complete when event received', () => {
196
+ it('should mark command as complete when event received', async () => {
127
197
  tracker.registerHandler({
128
198
  commandTypes: ['A'],
129
199
  handler: () => {},
130
200
  });
131
201
 
132
- tracker.onCommandStarted({
202
+ await tracker.onCommandStarted({
133
203
  type: 'A',
134
204
  correlationId: 'c1',
135
205
  requestId: 'r1',
@@ -142,12 +212,12 @@ describe('SettledTracker', () => {
142
212
  data: {},
143
213
  };
144
214
 
145
- tracker.onEventReceived(event, 'A');
215
+ await tracker.onEventReceived(event, 'A');
146
216
 
147
- expect(tracker.isWaitingFor('c1', 'A')).toBe(false);
217
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
148
218
  });
149
219
 
150
- it('should collect events for each command type', () => {
220
+ it('should collect events for each command type', async () => {
151
221
  let receivedEvents: Record<string, Event[]> = {};
152
222
 
153
223
  tracker.registerHandler({
@@ -157,21 +227,21 @@ describe('SettledTracker', () => {
157
227
  },
158
228
  });
159
229
 
160
- tracker.onCommandStarted({
230
+ await tracker.onCommandStarted({
161
231
  type: 'A',
162
232
  correlationId: 'c1',
163
233
  requestId: 'r1',
164
234
  data: {},
165
235
  });
166
- tracker.onCommandStarted({
236
+ await tracker.onCommandStarted({
167
237
  type: 'B',
168
238
  correlationId: 'c1',
169
239
  requestId: 'r2',
170
240
  data: {},
171
241
  });
172
242
 
173
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: { foo: 1 } }, 'A');
174
- tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: { bar: 2 } }, 'B');
243
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: { foo: 1 } }, 'A');
244
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: { bar: 2 } }, 'B');
175
245
 
176
246
  expect(receivedEvents.A).toHaveLength(1);
177
247
  expect(receivedEvents.A[0].type).toBe('ADone');
@@ -179,7 +249,7 @@ describe('SettledTracker', () => {
179
249
  expect(receivedEvents.B[0].type).toBe('BDone');
180
250
  });
181
251
 
182
- it('should ignore events without correlationId', () => {
252
+ it('should ignore events without correlationId', async () => {
183
253
  let handlerCalled = false;
184
254
 
185
255
  tracker.registerHandler({
@@ -189,21 +259,21 @@ describe('SettledTracker', () => {
189
259
  },
190
260
  });
191
261
 
192
- tracker.onCommandStarted({
262
+ await tracker.onCommandStarted({
193
263
  type: 'A',
194
264
  correlationId: 'c1',
195
265
  requestId: 'r1',
196
266
  data: {},
197
267
  });
198
268
 
199
- tracker.onEventReceived({ type: 'ADone', data: {} } as Event, 'A');
269
+ await tracker.onEventReceived({ type: 'ADone', data: {} } as Event, 'A');
200
270
 
201
271
  expect(handlerCalled).toBe(false);
202
272
  });
203
273
  });
204
274
 
205
275
  describe('handler execution', () => {
206
- it('should fire handler when all commands complete', () => {
276
+ it('should fire handler when all commands complete', async () => {
207
277
  let fired = false;
208
278
 
209
279
  tracker.registerHandler({
@@ -213,27 +283,27 @@ describe('SettledTracker', () => {
213
283
  },
214
284
  });
215
285
 
216
- tracker.onCommandStarted({
286
+ await tracker.onCommandStarted({
217
287
  type: 'A',
218
288
  correlationId: 'c1',
219
289
  requestId: 'r1',
220
290
  data: {},
221
291
  });
222
- tracker.onCommandStarted({
292
+ await tracker.onCommandStarted({
223
293
  type: 'B',
224
294
  correlationId: 'c1',
225
295
  requestId: 'r2',
226
296
  data: {},
227
297
  });
228
298
 
229
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
299
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
230
300
  expect(fired).toBe(false);
231
301
 
232
- tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
302
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
233
303
  expect(fired).toBe(true);
234
304
  });
235
305
 
236
- it('should not fire handler until all tracked commands have events', () => {
306
+ it('should not fire handler until all tracked commands have events', async () => {
237
307
  let fireCount = 0;
238
308
 
239
309
  tracker.registerHandler({
@@ -243,56 +313,53 @@ describe('SettledTracker', () => {
243
313
  },
244
314
  });
245
315
 
246
- tracker.onCommandStarted({
316
+ await tracker.onCommandStarted({
247
317
  type: 'CheckTests',
248
318
  correlationId: 'c1',
249
319
  requestId: 'r1',
250
320
  data: {},
251
321
  });
252
- tracker.onCommandStarted({
322
+ await tracker.onCommandStarted({
253
323
  type: 'CheckTypes',
254
324
  correlationId: 'c1',
255
325
  requestId: 'r2',
256
326
  data: {},
257
327
  });
258
- tracker.onCommandStarted({
328
+ await tracker.onCommandStarted({
259
329
  type: 'CheckLint',
260
330
  correlationId: 'c1',
261
331
  requestId: 'r3',
262
332
  data: {},
263
333
  });
264
334
 
265
- tracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: {} }, 'CheckTests');
335
+ await tracker.onEventReceived({ type: 'TestsCheckPassed', correlationId: 'c1', data: {} }, 'CheckTests');
266
336
  expect(fireCount).toBe(0);
267
337
 
268
- tracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: {} }, 'CheckTypes');
338
+ await tracker.onEventReceived({ type: 'TypeCheckPassed', correlationId: 'c1', data: {} }, 'CheckTypes');
269
339
  expect(fireCount).toBe(0);
270
340
 
271
- tracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: {} }, 'CheckLint');
341
+ await tracker.onEventReceived({ type: 'LintCheckPassed', correlationId: 'c1', data: {} }, 'CheckLint');
272
342
  expect(fireCount).toBe(1);
273
343
  });
274
344
 
275
- it('should cleanup instance after handler fires', () => {
345
+ it('should cleanup after handler fires by allowing new tracking for same correlationId', async () => {
346
+ let fireCount = 0;
347
+
276
348
  tracker.registerHandler({
277
349
  commandTypes: ['A'],
278
- handler: () => {},
279
- });
280
-
281
- tracker.onCommandStarted({
282
- type: 'A',
283
- correlationId: 'c1',
284
- requestId: 'r1',
285
- data: {},
350
+ handler: () => {
351
+ fireCount++;
352
+ },
286
353
  });
287
354
 
288
- expect(tracker.getActiveInstanceCount()).toBe(1);
289
-
290
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
355
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
356
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
357
+ expect(fireCount).toBe(1);
291
358
 
292
- expect(tracker.getActiveInstanceCount()).toBe(0);
359
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
293
360
  });
294
361
 
295
- it('should handle separate correlationIds independently', () => {
362
+ it('should handle separate correlationIds independently', async () => {
296
363
  const firedFor: string[] = [];
297
364
 
298
365
  tracker.registerHandler({
@@ -302,26 +369,26 @@ describe('SettledTracker', () => {
302
369
  },
303
370
  });
304
371
 
305
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
306
- tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
307
- tracker.onCommandStarted({ type: 'A', correlationId: 'c2', requestId: 'r3', data: {} });
308
- tracker.onCommandStarted({ type: 'B', correlationId: 'c2', requestId: 'r4', data: {} });
372
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
373
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
374
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c2', requestId: 'r3', data: {} });
375
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c2', requestId: 'r4', data: {} });
309
376
 
310
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
311
- tracker.onEventReceived({ type: 'BDone', correlationId: 'c2', data: {} }, 'B');
377
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
378
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c2', data: {} }, 'B');
312
379
 
313
380
  expect(firedFor).toHaveLength(0);
314
381
 
315
- tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
382
+ await tracker.onEventReceived({ type: 'BDone', correlationId: 'c1', data: {} }, 'B');
316
383
  expect(firedFor).toEqual(['c1']);
317
384
 
318
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c2', data: {} }, 'A');
385
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c2', data: {} }, 'A');
319
386
  expect(firedFor).toEqual(['c1', 'c2']);
320
387
  });
321
388
  });
322
389
 
323
390
  describe('persist for retry', () => {
324
- it('should reset trackers when handler returns persist: true', () => {
391
+ it('should reset trackers when handler returns persist: true', async () => {
325
392
  let callCount = 0;
326
393
 
327
394
  tracker.registerHandler({
@@ -332,37 +399,42 @@ describe('SettledTracker', () => {
332
399
  },
333
400
  });
334
401
 
335
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
336
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
402
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
403
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
337
404
  expect(callCount).toBe(1);
338
- expect(tracker.getActiveInstanceCount()).toBe(1);
339
405
 
340
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r2', data: {} });
341
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
406
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r2', data: {} });
407
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
408
+
409
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
342
410
  expect(callCount).toBe(2);
343
- expect(tracker.getActiveInstanceCount()).toBe(1);
344
411
 
345
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r3', data: {} });
346
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
412
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r3', data: {} });
413
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
347
414
  expect(callCount).toBe(3);
348
- expect(tracker.getActiveInstanceCount()).toBe(0);
415
+
416
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(true);
349
417
  });
350
418
 
351
- it('should cleanup on handler error', () => {
419
+ it('should cleanup on handler error and not throw', async () => {
420
+ let handlerCalls = 0;
421
+
352
422
  tracker.registerHandler({
353
423
  commandTypes: ['A'],
354
424
  handler: () => {
425
+ handlerCalls++;
355
426
  throw new Error('Handler error');
356
427
  },
357
428
  });
358
429
 
359
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
430
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
360
431
 
361
- expect(() => {
362
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
363
- }).not.toThrow();
432
+ await expect(
433
+ tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A'),
434
+ ).resolves.not.toThrow();
364
435
 
365
- expect(tracker.getActiveInstanceCount()).toBe(0);
436
+ expect(handlerCalls).toBe(1);
437
+ expect(await tracker.isWaitingForAsync('c1', 'A')).toBe(false);
366
438
  });
367
439
  });
368
440
 
@@ -370,16 +442,16 @@ describe('SettledTracker', () => {
370
442
  it('should accept onError callback in options', () => {
371
443
  const onError = vi.fn();
372
444
 
373
- tracker = new SettledTracker({ onError });
445
+ tracker = createESTracker(ctx, { onError });
374
446
 
375
447
  expect(tracker).toBeInstanceOf(SettledTracker);
376
448
  });
377
449
 
378
- it('should call onError when handler throws', () => {
450
+ it('should call onError when handler throws', async () => {
379
451
  const onError = vi.fn();
380
452
  const thrownError = new Error('Handler failed');
381
453
 
382
- tracker = new SettledTracker({ onError });
454
+ tracker = createESTracker(ctx, { onError });
383
455
 
384
456
  tracker.registerHandler({
385
457
  commandTypes: ['A'],
@@ -388,8 +460,8 @@ describe('SettledTracker', () => {
388
460
  },
389
461
  });
390
462
 
391
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
392
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
463
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
464
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
393
465
 
394
466
  expect(onError).toHaveBeenCalledTimes(1);
395
467
  expect(onError).toHaveBeenCalledWith(thrownError, {
@@ -400,10 +472,10 @@ describe('SettledTracker', () => {
400
472
  });
401
473
 
402
474
  describe('dispatch callback', () => {
403
- it('should call onDispatch when provided', () => {
475
+ it('should call onDispatch when provided', async () => {
404
476
  const dispatched: Array<{ type: string; data: unknown }> = [];
405
477
 
406
- tracker = new SettledTracker({
478
+ tracker = createESTracker(ctx, {
407
479
  onDispatch: (commandType, data, _correlationId) => {
408
480
  dispatched.push({ type: commandType, data });
409
481
  },
@@ -416,17 +488,17 @@ describe('SettledTracker', () => {
416
488
  },
417
489
  });
418
490
 
419
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
420
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
491
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
492
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
421
493
 
422
494
  expect(dispatched).toHaveLength(1);
423
495
  expect(dispatched[0]).toEqual({ type: 'FollowUp', data: { foo: 'bar' } });
424
496
  });
425
497
 
426
- it('should pass correlationId to onDispatch', () => {
498
+ it('should pass correlationId to onDispatch', async () => {
427
499
  let receivedCorrelationId: string | undefined;
428
500
 
429
- tracker = new SettledTracker({
501
+ tracker = createESTracker(ctx, {
430
502
  onDispatch: (_commandType, _data, correlationId) => {
431
503
  receivedCorrelationId = correlationId;
432
504
  },
@@ -439,10 +511,534 @@ describe('SettledTracker', () => {
439
511
  },
440
512
  });
441
513
 
442
- tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
443
- tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
514
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
515
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
444
516
 
445
517
  expect(receivedCorrelationId).toBe('c1');
446
518
  });
447
519
  });
520
+
521
+ describe('event emission', () => {
522
+ it('should emit SettledInstanceCreated when first command starts for a template', async () => {
523
+ const emittedEvents: SettledEvent[] = [];
524
+
525
+ tracker = createESTracker(ctx, {
526
+ onEventEmit: (event) => {
527
+ emittedEvents.push(event);
528
+ },
529
+ });
530
+
531
+ tracker.registerHandler({
532
+ commandTypes: ['A', 'B'],
533
+ handler: () => {},
534
+ });
535
+
536
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
537
+
538
+ expect(emittedEvents).toHaveLength(2);
539
+ expect(emittedEvents[0].type).toBe('SettledInstanceCreated');
540
+ expect(emittedEvents[0].data).toEqual({
541
+ templateId: 'template-A,B',
542
+ correlationId: 'c1',
543
+ commandTypes: ['A', 'B'],
544
+ });
545
+ });
546
+
547
+ it('should emit SettledCommandStarted when command starts', async () => {
548
+ const emittedEvents: SettledEvent[] = [];
549
+
550
+ tracker = createESTracker(ctx, {
551
+ onEventEmit: (event) => {
552
+ emittedEvents.push(event);
553
+ },
554
+ });
555
+
556
+ tracker.registerHandler({
557
+ commandTypes: ['A', 'B'],
558
+ handler: () => {},
559
+ });
560
+
561
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
562
+
563
+ expect(emittedEvents[1].type).toBe('SettledCommandStarted');
564
+ expect(emittedEvents[1].data).toEqual({
565
+ templateId: 'template-A,B',
566
+ correlationId: 'c1',
567
+ commandType: 'A',
568
+ });
569
+ });
570
+
571
+ it('should not emit SettledInstanceCreated for subsequent commands in same instance', async () => {
572
+ const emittedEvents: SettledEvent[] = [];
573
+
574
+ tracker = createESTracker(ctx, {
575
+ onEventEmit: (event) => {
576
+ emittedEvents.push(event);
577
+ },
578
+ });
579
+
580
+ tracker.registerHandler({
581
+ commandTypes: ['A', 'B'],
582
+ handler: () => {},
583
+ });
584
+
585
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
586
+ await tracker.onCommandStarted({ type: 'B', correlationId: 'c1', requestId: 'r2', data: {} });
587
+
588
+ const createdEvents = emittedEvents.filter((e) => e.type === 'SettledInstanceCreated');
589
+ expect(createdEvents).toHaveLength(1);
590
+ });
591
+
592
+ it('should emit SettledEventReceived when event is received', async () => {
593
+ const emittedEvents: SettledEvent[] = [];
594
+
595
+ tracker = createESTracker(ctx, {
596
+ onEventEmit: (event) => {
597
+ emittedEvents.push(event);
598
+ },
599
+ });
600
+
601
+ tracker.registerHandler({
602
+ commandTypes: ['A'],
603
+ handler: () => {},
604
+ });
605
+
606
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
607
+ emittedEvents.length = 0;
608
+
609
+ const domainEvent: Event = { type: 'ADone', correlationId: 'c1', data: { result: 'ok' } };
610
+ await tracker.onEventReceived(domainEvent, 'A');
611
+
612
+ expect(emittedEvents[0].type).toBe('SettledEventReceived');
613
+ expect(emittedEvents[0].data).toEqual({
614
+ templateId: 'template-A',
615
+ correlationId: 'c1',
616
+ commandType: 'A',
617
+ event: domainEvent,
618
+ });
619
+ });
620
+
621
+ it('should emit SettledHandlerFired when handler fires', async () => {
622
+ const emittedEvents: SettledEvent[] = [];
623
+
624
+ tracker = createESTracker(ctx, {
625
+ onEventEmit: (event) => {
626
+ emittedEvents.push(event);
627
+ },
628
+ });
629
+
630
+ tracker.registerHandler({
631
+ commandTypes: ['A'],
632
+ handler: () => {},
633
+ });
634
+
635
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
636
+ emittedEvents.length = 0;
637
+
638
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
639
+
640
+ const firedEvent = emittedEvents.find((e) => e.type === 'SettledHandlerFired');
641
+ expect(firedEvent).toBeDefined();
642
+ expect(firedEvent?.data).toEqual({
643
+ templateId: 'template-A',
644
+ correlationId: 'c1',
645
+ persist: false,
646
+ });
647
+ });
648
+
649
+ it('should emit SettledInstanceReset when handler fires without persist', async () => {
650
+ const emittedEvents: SettledEvent[] = [];
651
+
652
+ tracker = createESTracker(ctx, {
653
+ onEventEmit: (event) => {
654
+ emittedEvents.push(event);
655
+ },
656
+ });
657
+
658
+ tracker.registerHandler({
659
+ commandTypes: ['A'],
660
+ handler: () => {},
661
+ });
662
+
663
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
664
+ emittedEvents.length = 0;
665
+
666
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
667
+
668
+ const resetEvent = emittedEvents.find((e) => e.type === 'SettledInstanceReset');
669
+ expect(resetEvent).toBeDefined();
670
+ expect(resetEvent?.data).toEqual({
671
+ templateId: 'template-A',
672
+ correlationId: 'c1',
673
+ });
674
+ });
675
+
676
+ it('should emit SettledInstanceReset when handler returns persist: true', async () => {
677
+ const emittedEvents: SettledEvent[] = [];
678
+
679
+ tracker = createESTracker(ctx, {
680
+ onEventEmit: (event) => {
681
+ emittedEvents.push(event);
682
+ },
683
+ });
684
+
685
+ tracker.registerHandler({
686
+ commandTypes: ['A'],
687
+ handler: () => ({ persist: true }),
688
+ });
689
+
690
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
691
+ emittedEvents.length = 0;
692
+
693
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
694
+
695
+ const firedEvent = emittedEvents.find((e) => e.type === 'SettledHandlerFired');
696
+ expect(firedEvent?.data).toEqual({
697
+ templateId: 'template-A',
698
+ correlationId: 'c1',
699
+ persist: true,
700
+ });
701
+
702
+ const resetEvent = emittedEvents.find((e) => e.type === 'SettledInstanceReset');
703
+ expect(resetEvent).toBeDefined();
704
+ expect(resetEvent?.data).toEqual({
705
+ templateId: 'template-A',
706
+ correlationId: 'c1',
707
+ });
708
+ });
709
+
710
+ it('should emit SettledInstanceCleaned on handler error', async () => {
711
+ const emittedEvents: SettledEvent[] = [];
712
+
713
+ tracker = createESTracker(ctx, {
714
+ onEventEmit: (event) => {
715
+ emittedEvents.push(event);
716
+ },
717
+ onError: () => {},
718
+ });
719
+
720
+ tracker.registerHandler({
721
+ commandTypes: ['A'],
722
+ handler: () => {
723
+ throw new Error('Handler error');
724
+ },
725
+ });
726
+
727
+ await tracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
728
+ emittedEvents.length = 0;
729
+
730
+ await tracker.onEventReceived({ type: 'ADone', correlationId: 'c1', data: {} }, 'A');
731
+
732
+ const cleanedEvent = emittedEvents.find((e) => e.type === 'SettledInstanceCleaned');
733
+ expect(cleanedEvent).toBeDefined();
734
+ });
735
+ });
736
+
737
+ describe('projection-based state (full ES)', () => {
738
+ it('should query instance state from readModel after emitting events', async () => {
739
+ const { eventStore, readModel, close } = createPipelineEventStore();
740
+
741
+ try {
742
+ const emittedEvents: SettledEvent[] = [];
743
+
744
+ const esTracker = new SettledTracker({
745
+ readModel,
746
+ onEventEmit: async (event) => {
747
+ emittedEvents.push(event);
748
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
749
+ { type: event.type, data: event.data },
750
+ ]);
751
+ },
752
+ });
753
+
754
+ esTracker.registerHandler({
755
+ commandTypes: ['A', 'B'],
756
+ handler: () => {},
757
+ });
758
+
759
+ await esTracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
760
+
761
+ const instance = await readModel.getSettledInstance('template-A,B', 'c1');
762
+
763
+ expect(instance).not.toBeNull();
764
+ expect(instance?.status).toBe('active');
765
+ expect(instance?.commandTrackers).toHaveLength(2);
766
+
767
+ const trackerA = instance?.commandTrackers.find((t) => t.commandType === 'A');
768
+ expect(trackerA?.hasStarted).toBe(true);
769
+ expect(trackerA?.hasCompleted).toBe(false);
770
+ } finally {
771
+ await close();
772
+ }
773
+ });
774
+
775
+ it('should derive isWaitingFor from projection query', async () => {
776
+ const { eventStore, readModel, close } = createPipelineEventStore();
777
+
778
+ try {
779
+ const esTracker = new SettledTracker({
780
+ readModel,
781
+ onEventEmit: async (event) => {
782
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
783
+ { type: event.type, data: event.data },
784
+ ]);
785
+ },
786
+ });
787
+
788
+ esTracker.registerHandler({
789
+ commandTypes: ['A'],
790
+ handler: () => {},
791
+ });
792
+
793
+ await esTracker.onCommandStarted({ type: 'A', correlationId: 'c1', requestId: 'r1', data: {} });
794
+
795
+ const isWaiting = await esTracker.isWaitingForAsync('c1', 'A');
796
+ expect(isWaiting).toBe(true);
797
+
798
+ const isWaitingB = await esTracker.isWaitingForAsync('c1', 'B');
799
+ expect(isWaitingB).toBe(false);
800
+ } finally {
801
+ await close();
802
+ }
803
+ });
804
+
805
+ it('should create new instance after previous instance was cleaned (retry scenario)', async () => {
806
+ const { eventStore, readModel, close } = createPipelineEventStore();
807
+
808
+ try {
809
+ let handlerCallCount = 0;
810
+ const dispatched: Array<{ commandType: string; data: unknown }> = [];
811
+
812
+ const esTracker = new SettledTracker({
813
+ readModel,
814
+ onDispatch: (commandType, data) => {
815
+ dispatched.push({ commandType, data });
816
+ },
817
+ onEventEmit: async (event) => {
818
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
819
+ { type: event.type, data: event.data },
820
+ ]);
821
+ },
822
+ });
823
+
824
+ esTracker.registerHandler({
825
+ commandTypes: ['CheckTypes'],
826
+ handler: (events, send) => {
827
+ handlerCallCount++;
828
+ const checkTypesEvents = events.CheckTypes ?? [];
829
+ const failedEvent = checkTypesEvents.find((e) => e.type === 'TypeCheckFailed');
830
+ if (failedEvent) {
831
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
832
+ }
833
+ },
834
+ });
835
+
836
+ await esTracker.onCommandStarted({
837
+ type: 'CheckTypes',
838
+ correlationId: 'c1',
839
+ requestId: 'r1',
840
+ data: { target: './slice1' },
841
+ });
842
+
843
+ await esTracker.onEventReceived(
844
+ { type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322' } },
845
+ 'CheckTypes',
846
+ );
847
+
848
+ expect(handlerCallCount).toBe(1);
849
+ expect(dispatched).toHaveLength(1);
850
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
851
+
852
+ dispatched.length = 0;
853
+
854
+ await esTracker.onCommandStarted({
855
+ type: 'CheckTypes',
856
+ correlationId: 'c1',
857
+ requestId: 'r2',
858
+ data: { target: './slice1' },
859
+ });
860
+
861
+ await esTracker.onEventReceived(
862
+ { type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice1' } },
863
+ 'CheckTypes',
864
+ );
865
+
866
+ expect(handlerCallCount).toBe(2);
867
+ } finally {
868
+ await close();
869
+ }
870
+ });
871
+
872
+ it('should fire handler for each completion when concurrent commands run (concurrent slices scenario)', async () => {
873
+ const { eventStore, readModel, close } = createPipelineEventStore();
874
+
875
+ try {
876
+ let handlerCallCount = 0;
877
+ const dispatched: Array<{ commandType: string; data: unknown }> = [];
878
+
879
+ const esTracker = new SettledTracker({
880
+ readModel,
881
+ onDispatch: (commandType, data) => {
882
+ dispatched.push({ commandType, data });
883
+ },
884
+ onEventEmit: async (event) => {
885
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
886
+ { type: event.type, data: event.data },
887
+ ]);
888
+ },
889
+ });
890
+
891
+ esTracker.registerHandler({
892
+ commandTypes: ['CheckTypes'],
893
+ handler: (events, send) => {
894
+ handlerCallCount++;
895
+ const checkTypesEvents = events.CheckTypes ?? [];
896
+ const failedEvent = checkTypesEvents.find((e) => e.type === 'TypeCheckFailed');
897
+ if (failedEvent) {
898
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
899
+ }
900
+ },
901
+ });
902
+
903
+ await esTracker.onCommandStarted({
904
+ type: 'CheckTypes',
905
+ correlationId: 'c1',
906
+ requestId: 'r1',
907
+ data: { target: './slice1' },
908
+ });
909
+
910
+ await esTracker.onCommandStarted({
911
+ type: 'CheckTypes',
912
+ correlationId: 'c1',
913
+ requestId: 'r2',
914
+ data: { target: './slice2' },
915
+ });
916
+
917
+ await esTracker.onEventReceived(
918
+ { type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice1' } },
919
+ 'CheckTypes',
920
+ );
921
+
922
+ expect(handlerCallCount).toBe(1);
923
+
924
+ await esTracker.onEventReceived(
925
+ { type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322', target: './slice2' } },
926
+ 'CheckTypes',
927
+ );
928
+
929
+ expect(handlerCallCount).toBe(2);
930
+ expect(dispatched).toHaveLength(1);
931
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
932
+ } finally {
933
+ await close();
934
+ }
935
+ });
936
+
937
+ it('should fire handler for interleaved slices when one fails (production scenario)', async () => {
938
+ const { eventStore, readModel, close } = createPipelineEventStore();
939
+
940
+ try {
941
+ let handlerCallCount = 0;
942
+ const dispatched: Array<{ commandType: string; data: unknown }> = [];
943
+
944
+ const esTracker = new SettledTracker({
945
+ readModel,
946
+ onDispatch: (commandType, data) => {
947
+ dispatched.push({ commandType, data });
948
+ },
949
+ onEventEmit: async (event) => {
950
+ await eventStore.appendToStream(`settled-${event.data.correlationId}`, [
951
+ { type: event.type, data: event.data },
952
+ ]);
953
+ },
954
+ });
955
+
956
+ esTracker.registerHandler({
957
+ commandTypes: ['CheckTests', 'CheckTypes', 'CheckLint'],
958
+ handler: (events, send) => {
959
+ handlerCallCount++;
960
+ const allEvents = [...(events.CheckTests ?? []), ...(events.CheckTypes ?? []), ...(events.CheckLint ?? [])];
961
+ const failedEvent = allEvents.find((e) => e.type.includes('Failed'));
962
+ if (failedEvent) {
963
+ send('ImplementSlice', { retry: true, errors: failedEvent.data });
964
+ }
965
+ },
966
+ });
967
+
968
+ await esTracker.onCommandStarted({
969
+ type: 'CheckTests',
970
+ correlationId: 'c1',
971
+ requestId: 'slice2-tests',
972
+ data: { target: './slice2' },
973
+ });
974
+ await esTracker.onCommandStarted({
975
+ type: 'CheckTypes',
976
+ correlationId: 'c1',
977
+ requestId: 'slice2-types',
978
+ data: { target: './slice2' },
979
+ });
980
+ await esTracker.onCommandStarted({
981
+ type: 'CheckLint',
982
+ correlationId: 'c1',
983
+ requestId: 'slice2-lint',
984
+ data: { target: './slice2' },
985
+ });
986
+
987
+ await esTracker.onEventReceived(
988
+ { type: 'LintCheckPassed', correlationId: 'c1', data: { target: './slice2' } },
989
+ 'CheckLint',
990
+ );
991
+
992
+ await esTracker.onCommandStarted({
993
+ type: 'CheckTests',
994
+ correlationId: 'c1',
995
+ requestId: 'slice3-tests',
996
+ data: { target: './slice3' },
997
+ });
998
+ await esTracker.onCommandStarted({
999
+ type: 'CheckTypes',
1000
+ correlationId: 'c1',
1001
+ requestId: 'slice3-types',
1002
+ data: { target: './slice3' },
1003
+ });
1004
+ await esTracker.onCommandStarted({
1005
+ type: 'CheckLint',
1006
+ correlationId: 'c1',
1007
+ requestId: 'slice3-lint',
1008
+ data: { target: './slice3' },
1009
+ });
1010
+
1011
+ await esTracker.onEventReceived(
1012
+ { type: 'TypeCheckPassed', correlationId: 'c1', data: { target: './slice2' } },
1013
+ 'CheckTypes',
1014
+ );
1015
+
1016
+ await esTracker.onEventReceived(
1017
+ { type: 'TestsCheckPassed', correlationId: 'c1', data: { target: './slice2' } },
1018
+ 'CheckTests',
1019
+ );
1020
+
1021
+ expect(handlerCallCount).toBe(1);
1022
+
1023
+ await esTracker.onEventReceived(
1024
+ { type: 'LintCheckPassed', correlationId: 'c1', data: { target: './slice3' } },
1025
+ 'CheckLint',
1026
+ );
1027
+ await esTracker.onEventReceived(
1028
+ { type: 'TypeCheckFailed', correlationId: 'c1', data: { errors: 'TS2322', target: './slice3' } },
1029
+ 'CheckTypes',
1030
+ );
1031
+ await esTracker.onEventReceived(
1032
+ { type: 'TestsCheckPassed', correlationId: 'c1', data: { target: './slice3' } },
1033
+ 'CheckTests',
1034
+ );
1035
+
1036
+ expect(handlerCallCount).toBe(2);
1037
+ expect(dispatched).toHaveLength(1);
1038
+ expect(dispatched[0].commandType).toBe('ImplementSlice');
1039
+ } finally {
1040
+ await close();
1041
+ }
1042
+ });
1043
+ });
448
1044
  });