@auto-engineer/pipeline 0.0.1 → 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 (201) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -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 +418 -134
  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 +7 -3
  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 -2
  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 +522 -156
  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/docs/testing-analysis.md +0 -395
  201. package/pomodoro-plan.md +0 -651
@@ -49,14 +49,8 @@ describe('SSEManager', () => {
49
49
  manager = new SSEManager();
50
50
  });
51
51
 
52
- describe('client management', () => {
53
- it('should add client', () => {
54
- const res = createMockResponse();
55
- manager.addClient('c1', res);
56
- expect(manager.clientCount).toBe(1);
57
- });
58
-
59
- it('should set correct SSE headers', () => {
52
+ describe('client connection', () => {
53
+ it('should set correct SSE headers when client connects', () => {
60
54
  const res = createMockResponse();
61
55
  manager.addClient('c1', res);
62
56
  expect(res.writeHeadMock).toHaveBeenCalledWith(200, {
@@ -73,23 +67,43 @@ describe('SSEManager', () => {
73
67
  expect(res.written[0]).toBe(':\n\n');
74
68
  });
75
69
 
76
- it('should remove client', () => {
70
+ it('should receive broadcasts after connecting', () => {
71
+ const res = createMockResponse();
72
+ manager.addClient('c1', res);
73
+
74
+ const event: Event = { type: 'TestEvent', data: { foo: 'bar' } };
75
+ manager.broadcast(event);
76
+
77
+ expect(res.written).toContainEqual(`data: ${JSON.stringify(event)}\n\n`);
78
+ });
79
+ });
80
+
81
+ describe('client removal', () => {
82
+ it('should not receive broadcasts after explicit removal', () => {
77
83
  const res = createMockResponse();
78
84
  manager.addClient('c1', res);
79
85
  manager.removeClient('c1');
80
- expect(manager.clientCount).toBe(0);
86
+
87
+ const event: Event = { type: 'TestEvent', data: { foo: 'bar' } };
88
+ manager.broadcast(event);
89
+
90
+ expect(res.written).toHaveLength(1);
81
91
  });
82
92
 
83
- it('should cleanup client on response close', () => {
93
+ it('should not receive broadcasts after connection close', () => {
84
94
  const res = createMockResponse();
85
95
  manager.addClient('c1', res);
86
96
  res.triggerClose();
87
- expect(manager.clientCount).toBe(0);
97
+
98
+ const event: Event = { type: 'TestEvent', data: { foo: 'bar' } };
99
+ manager.broadcast(event);
100
+
101
+ expect(res.written).toHaveLength(1);
88
102
  });
89
103
  });
90
104
 
91
105
  describe('broadcasting', () => {
92
- it('should broadcast event to all clients', () => {
106
+ it('should broadcast event to all connected clients', () => {
93
107
  const res1 = createMockResponse();
94
108
  const res2 = createMockResponse();
95
109
 
@@ -103,21 +117,21 @@ describe('SSEManager', () => {
103
117
  expect(res2.written).toContainEqual(`data: ${JSON.stringify(event)}\n\n`);
104
118
  });
105
119
 
106
- it('should filter by correlationId when set', () => {
107
- const res1 = createMockResponse();
108
- const res2 = createMockResponse();
120
+ it('should filter events by correlationId when client subscribes to specific correlation', () => {
121
+ const filteredRes = createMockResponse();
122
+ const unfilteredRes = createMockResponse();
109
123
 
110
- manager.addClient('c1', res1, 'workflow-123');
111
- manager.addClient('c2', res2);
124
+ manager.addClient('c1', filteredRes, 'workflow-123');
125
+ manager.addClient('c2', unfilteredRes);
112
126
 
113
127
  const event: Event = { type: 'TestEvent', correlationId: 'workflow-456', data: {} };
114
128
  manager.broadcast(event);
115
129
 
116
- expect(res1.written.length).toBe(1);
117
- expect(res2.written.length).toBe(2);
130
+ expect(filteredRes.written).toHaveLength(1);
131
+ expect(unfilteredRes.written).toHaveLength(2);
118
132
  });
119
133
 
120
- it('should send to filtered client when correlationId matches', () => {
134
+ it('should send event to filtered client when correlationId matches', () => {
121
135
  const res = createMockResponse();
122
136
  manager.addClient('c1', res, 'workflow-123');
123
137
 
@@ -127,19 +141,19 @@ describe('SSEManager', () => {
127
141
  expect(res.written).toContainEqual(`data: ${JSON.stringify(event)}\n\n`);
128
142
  });
129
143
 
130
- it('should not send to filtered client when correlationId does not match', () => {
144
+ it('should not send event to filtered client when correlationId does not match', () => {
131
145
  const res = createMockResponse();
132
146
  manager.addClient('c1', res, 'workflow-123');
133
147
 
134
148
  const event: Event = { type: 'TestEvent', correlationId: 'workflow-456', data: {} };
135
149
  manager.broadcast(event);
136
150
 
137
- expect(res.written.length).toBe(1);
151
+ expect(res.written).toHaveLength(1);
138
152
  });
139
153
  });
140
154
 
141
155
  describe('broadcast error handling', () => {
142
- it('should not throw when client.response.write fails', () => {
156
+ it('should not throw when client write fails', () => {
143
157
  const res = createMockResponse();
144
158
  manager.addClient('c1', res);
145
159
 
@@ -152,21 +166,28 @@ describe('SSEManager', () => {
152
166
  expect(() => manager.broadcast(event)).not.toThrow();
153
167
  });
154
168
 
155
- it('should remove failed client from clients map', () => {
169
+ it('should stop sending to failed client on subsequent broadcasts', () => {
156
170
  const res = createMockResponse();
157
- vi.mocked(res.write)
158
- .mockImplementationOnce(() => true)
159
- .mockImplementation(() => {
171
+ let writeCallCount = 0;
172
+ vi.mocked(res.write).mockImplementation((data: string) => {
173
+ writeCallCount++;
174
+ if (writeCallCount > 2) {
160
175
  throw new Error('Connection reset');
161
- });
176
+ }
177
+ res.written.push(data);
178
+ return true;
179
+ });
162
180
 
163
181
  manager.addClient('c1', res);
164
- expect(manager.clientCount).toBe(1);
165
182
 
166
- const event: Event = { type: 'TestEvent', data: {} };
167
- manager.broadcast(event);
183
+ const event1: Event = { type: 'TestEvent1', data: {} };
184
+ manager.broadcast(event1);
185
+
186
+ const event2: Event = { type: 'TestEvent2', data: {} };
187
+ manager.broadcast(event2);
168
188
 
169
- expect(manager.clientCount).toBe(0);
189
+ const eventMessages = res.written.filter((w) => w.startsWith('data:'));
190
+ expect(eventMessages).toHaveLength(1);
170
191
  });
171
192
 
172
193
  it('should continue broadcasting to other clients after one fails', () => {
@@ -186,12 +207,11 @@ describe('SSEManager', () => {
186
207
  manager.broadcast(event);
187
208
 
188
209
  expect(workingRes.written).toContainEqual(`data: ${JSON.stringify(event)}\n\n`);
189
- expect(manager.clientCount).toBe(1);
190
210
  });
191
211
  });
192
212
 
193
213
  describe('closeAll', () => {
194
- it('should close all clients', () => {
214
+ it('should end all client connections', () => {
195
215
  const res1 = createMockResponse();
196
216
  const res2 = createMockResponse();
197
217
 
@@ -200,9 +220,20 @@ describe('SSEManager', () => {
200
220
 
201
221
  manager.closeAll();
202
222
 
203
- expect(manager.clientCount).toBe(0);
204
223
  expect(res1.endMock).toHaveBeenCalled();
205
224
  expect(res2.endMock).toHaveBeenCalled();
206
225
  });
226
+
227
+ it('should not send broadcasts after closeAll', () => {
228
+ const res = createMockResponse();
229
+ manager.addClient('c1', res);
230
+
231
+ manager.closeAll();
232
+
233
+ const event: Event = { type: 'TestEvent', data: {} };
234
+ manager.broadcast(event);
235
+
236
+ expect(res.written).toHaveLength(1);
237
+ });
207
238
  });
208
239
  });
@@ -0,0 +1,2 @@
1
+ export { createPipelineEventStore, type PipelineEventStoreContext } from './pipeline-event-store';
2
+ export { type CommandStats, PipelineReadModel } from './pipeline-read-model';
@@ -0,0 +1,309 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ItemStatusDocument } from '../projections/item-status-projection';
3
+ import type { NodeStatusDocument } from '../projections/node-status-projection';
4
+ import type { PhasedExecutionDocument } from '../projections/phased-execution-projection';
5
+ import type { SettledInstanceDocument } from '../projections/settled-instance-projection';
6
+ import { createPipelineEventStore } from './pipeline-event-store';
7
+
8
+ describe('PipelineEventStore', () => {
9
+ describe('appendToStream', () => {
10
+ it('should append events and update projections', async () => {
11
+ const { eventStore, database, close } = createPipelineEventStore();
12
+ try {
13
+ await eventStore.appendToStream('pipeline-c1', [
14
+ {
15
+ type: 'ItemStatusChanged',
16
+ data: {
17
+ correlationId: 'c1',
18
+ commandType: 'Cmd',
19
+ itemKey: 'a',
20
+ requestId: 'r1',
21
+ status: 'running',
22
+ attemptCount: 1,
23
+ },
24
+ },
25
+ ]);
26
+
27
+ const collection = database.collection<ItemStatusDocument & { _id: string }>('ItemStatus');
28
+ const items = await collection.find();
29
+
30
+ expect(items.length).toBe(1);
31
+ expect(items[0]?.status).toBe('running');
32
+ } finally {
33
+ await close();
34
+ }
35
+ });
36
+
37
+ it('should project NodeStatusChanged events', async () => {
38
+ const { eventStore, database, close } = createPipelineEventStore();
39
+ try {
40
+ await eventStore.appendToStream('pipeline-c1', [
41
+ {
42
+ type: 'NodeStatusChanged',
43
+ data: {
44
+ correlationId: 'c1',
45
+ commandName: 'Cmd',
46
+ nodeId: 'node-1',
47
+ status: 'running',
48
+ previousStatus: 'idle',
49
+ pendingCount: 1,
50
+ endedCount: 0,
51
+ },
52
+ },
53
+ ]);
54
+
55
+ const collection = database.collection<NodeStatusDocument & { _id: string }>('NodeStatus');
56
+ const nodes = await collection.find();
57
+
58
+ expect(nodes.length).toBe(1);
59
+ expect(nodes[0]?.status).toBe('running');
60
+ } finally {
61
+ await close();
62
+ }
63
+ });
64
+
65
+ it('should update existing projection documents', async () => {
66
+ const { eventStore, database, close } = createPipelineEventStore();
67
+ try {
68
+ await eventStore.appendToStream('pipeline-c1', [
69
+ {
70
+ type: 'ItemStatusChanged',
71
+ data: {
72
+ correlationId: 'c1',
73
+ commandType: 'Cmd',
74
+ itemKey: 'a',
75
+ requestId: 'r1',
76
+ status: 'running',
77
+ attemptCount: 1,
78
+ },
79
+ },
80
+ ]);
81
+
82
+ await eventStore.appendToStream('pipeline-c1', [
83
+ {
84
+ type: 'ItemStatusChanged',
85
+ data: {
86
+ correlationId: 'c1',
87
+ commandType: 'Cmd',
88
+ itemKey: 'a',
89
+ requestId: 'r1',
90
+ status: 'success',
91
+ attemptCount: 1,
92
+ },
93
+ },
94
+ ]);
95
+
96
+ const collection = database.collection<ItemStatusDocument & { _id: string }>('ItemStatus');
97
+ const items = await collection.find();
98
+
99
+ expect(items.length).toBe(1);
100
+ expect(items[0]?.status).toBe('success');
101
+ } finally {
102
+ await close();
103
+ }
104
+ });
105
+ });
106
+
107
+ describe('readModel integration', () => {
108
+ it('should provide working read model queries', async () => {
109
+ const { eventStore, readModel, close } = createPipelineEventStore();
110
+ try {
111
+ await eventStore.appendToStream('pipeline-c1', [
112
+ {
113
+ type: 'ItemStatusChanged',
114
+ data: {
115
+ correlationId: 'c1',
116
+ commandType: 'Cmd',
117
+ itemKey: 'a',
118
+ requestId: 'r1',
119
+ status: 'running',
120
+ attemptCount: 1,
121
+ },
122
+ },
123
+ {
124
+ type: 'ItemStatusChanged',
125
+ data: {
126
+ correlationId: 'c1',
127
+ commandType: 'Cmd',
128
+ itemKey: 'b',
129
+ requestId: 'r2',
130
+ status: 'success',
131
+ attemptCount: 1,
132
+ },
133
+ },
134
+ ]);
135
+
136
+ const stats = await readModel.computeCommandStats('c1', 'Cmd');
137
+
138
+ expect(stats).toEqual({
139
+ pendingCount: 1,
140
+ endedCount: 1,
141
+ aggregateStatus: 'running',
142
+ });
143
+ } finally {
144
+ await close();
145
+ }
146
+ });
147
+
148
+ it('should detect correlation via read model', async () => {
149
+ const { eventStore, readModel, close } = createPipelineEventStore();
150
+ try {
151
+ expect(await readModel.hasCorrelation('c1')).toBe(false);
152
+
153
+ await eventStore.appendToStream('pipeline-c1', [
154
+ {
155
+ type: 'NodeStatusChanged',
156
+ data: {
157
+ correlationId: 'c1',
158
+ commandName: 'Cmd',
159
+ nodeId: 'node-1',
160
+ status: 'running',
161
+ previousStatus: 'idle',
162
+ pendingCount: 1,
163
+ endedCount: 0,
164
+ },
165
+ },
166
+ ]);
167
+
168
+ expect(await readModel.hasCorrelation('c1')).toBe(true);
169
+ } finally {
170
+ await close();
171
+ }
172
+ });
173
+ });
174
+
175
+ describe('settled instance projection', () => {
176
+ it('should project SettledInstanceCreated events', async () => {
177
+ const { eventStore, database, close } = createPipelineEventStore();
178
+ try {
179
+ await eventStore.appendToStream('settled-c1', [
180
+ {
181
+ type: 'SettledInstanceCreated',
182
+ data: {
183
+ templateId: 'template-CmdA,CmdB',
184
+ correlationId: 'c1',
185
+ commandTypes: ['CmdA', 'CmdB'],
186
+ },
187
+ },
188
+ ]);
189
+
190
+ const collection = database.collection<SettledInstanceDocument & { _id: string }>('SettledInstance');
191
+ const instances = await collection.find();
192
+
193
+ expect(instances.length).toBe(1);
194
+ expect(instances[0]?.status).toBe('active');
195
+ expect(instances[0]?.commandTrackers).toHaveLength(2);
196
+ } finally {
197
+ await close();
198
+ }
199
+ });
200
+
201
+ it('should update settled instance through lifecycle events', async () => {
202
+ const { eventStore, database, close } = createPipelineEventStore();
203
+ try {
204
+ await eventStore.appendToStream('settled-c1', [
205
+ {
206
+ type: 'SettledInstanceCreated',
207
+ data: {
208
+ templateId: 'template-CmdA',
209
+ correlationId: 'c1',
210
+ commandTypes: ['CmdA'],
211
+ },
212
+ },
213
+ {
214
+ type: 'SettledCommandStarted',
215
+ data: {
216
+ templateId: 'template-CmdA',
217
+ correlationId: 'c1',
218
+ commandType: 'CmdA',
219
+ },
220
+ },
221
+ ]);
222
+
223
+ const collection = database.collection<SettledInstanceDocument & { _id: string }>('SettledInstance');
224
+ const instances = await collection.find();
225
+
226
+ expect(instances.length).toBe(1);
227
+ expect(instances[0]?.commandTrackers[0]?.hasStarted).toBe(true);
228
+ expect(instances[0]?.commandTrackers[0]?.hasCompleted).toBe(false);
229
+ } finally {
230
+ await close();
231
+ }
232
+ });
233
+ });
234
+
235
+ describe('phased execution projection', () => {
236
+ it('should project PhasedExecutionStarted events', async () => {
237
+ const { eventStore, database, close } = createPipelineEventStore();
238
+ try {
239
+ await eventStore.appendToStream('phased-c1', [
240
+ {
241
+ type: 'PhasedExecutionStarted',
242
+ data: {
243
+ executionId: 'exec-1',
244
+ correlationId: 'c1',
245
+ handlerId: 'handler-1',
246
+ triggerEvent: { type: 'TestEvent', correlationId: 'c1', data: {} },
247
+ items: [{ key: 'a', phase: 'prepare', dispatched: false, completed: false }],
248
+ phases: ['prepare', 'execute'],
249
+ },
250
+ },
251
+ ]);
252
+
253
+ const collection = database.collection<PhasedExecutionDocument & { _id: string }>('PhasedExecution');
254
+ const executions = await collection.find();
255
+
256
+ expect(executions.length).toBe(1);
257
+ expect(executions[0]?.status).toBe('active');
258
+ expect(executions[0]?.items).toHaveLength(1);
259
+ } finally {
260
+ await close();
261
+ }
262
+ });
263
+
264
+ it('should update phased execution through lifecycle events', async () => {
265
+ const { eventStore, database, close } = createPipelineEventStore();
266
+ try {
267
+ await eventStore.appendToStream('phased-c1', [
268
+ {
269
+ type: 'PhasedExecutionStarted',
270
+ data: {
271
+ executionId: 'exec-1',
272
+ correlationId: 'c1',
273
+ handlerId: 'handler-1',
274
+ triggerEvent: { type: 'TestEvent', correlationId: 'c1', data: {} },
275
+ items: [{ key: 'a', phase: 'prepare', dispatched: false, completed: false }],
276
+ phases: ['prepare'],
277
+ },
278
+ },
279
+ {
280
+ type: 'PhasedItemDispatched',
281
+ data: { executionId: 'exec-1', itemKey: 'a', phase: 'prepare' },
282
+ },
283
+ {
284
+ type: 'PhasedItemCompleted',
285
+ data: {
286
+ executionId: 'exec-1',
287
+ itemKey: 'a',
288
+ resultEvent: { type: 'ItemDone', correlationId: 'c1', data: {} },
289
+ },
290
+ },
291
+ {
292
+ type: 'PhasedExecutionCompleted',
293
+ data: { executionId: 'exec-1', success: true, results: ['a'] },
294
+ },
295
+ ]);
296
+
297
+ const collection = database.collection<PhasedExecutionDocument & { _id: string }>('PhasedExecution');
298
+ const executions = await collection.find();
299
+
300
+ expect(executions.length).toBe(1);
301
+ expect(executions[0]?.status).toBe('completed');
302
+ expect(executions[0]?.items[0]?.dispatched).toBe(true);
303
+ expect(executions[0]?.items[0]?.completed).toBe(true);
304
+ } finally {
305
+ await close();
306
+ }
307
+ });
308
+ });
309
+ });
@@ -0,0 +1,156 @@
1
+ import {
2
+ getInMemoryDatabase,
3
+ getInMemoryEventStore,
4
+ type InMemoryDatabase,
5
+ type InMemoryEventStore,
6
+ type InMemoryReadEventMetadata,
7
+ inlineProjections,
8
+ inMemorySingleStreamProjection,
9
+ } from '@event-driven-io/emmett';
10
+ import type { AwaitEvent, AwaitTrackerDocument } from '../projections/await-tracker-projection';
11
+ import { evolve as evolveAwaitTracker } from '../projections/await-tracker-projection';
12
+ import type { ItemStatusChangedEvent, ItemStatusDocument } from '../projections/item-status-projection';
13
+ import { evolve as evolveItemStatus } from '../projections/item-status-projection';
14
+ import type { LatestRunDocument } from '../projections/latest-run-projection';
15
+ import { evolve as evolveLatestRun } from '../projections/latest-run-projection';
16
+ import type { MessageLogDocument, MessageLogEvent } from '../projections/message-log-projection';
17
+ import { evolve as evolveMessageLog } from '../projections/message-log-projection';
18
+ import type { NodeStatusChangedEvent, NodeStatusDocument } from '../projections/node-status-projection';
19
+ import { evolve as evolveNodeStatus } from '../projections/node-status-projection';
20
+ import type { PhasedExecutionDocument, PhasedExecutionEvent } from '../projections/phased-execution-projection';
21
+ import { evolve as evolvePhasedExecution } from '../projections/phased-execution-projection';
22
+ import type { SettledEvent, SettledInstanceDocument } from '../projections/settled-instance-projection';
23
+ import { evolve as evolveSettledInstance } from '../projections/settled-instance-projection';
24
+ import type { StatsDocument } from '../projections/stats-projection';
25
+ import { evolve as evolveStats } from '../projections/stats-projection';
26
+ import { PipelineReadModel } from './pipeline-read-model';
27
+
28
+ interface PipelineRunStartedEvent {
29
+ type: 'PipelineRunStarted';
30
+ data: {
31
+ correlationId: string;
32
+ triggerCommand: string;
33
+ };
34
+ }
35
+
36
+ function createProjections() {
37
+ const itemStatusProjection = inMemorySingleStreamProjection<ItemStatusDocument, ItemStatusChangedEvent>({
38
+ collectionName: 'ItemStatus',
39
+ canHandle: ['ItemStatusChanged'],
40
+ getDocumentId: (event) => `${event.data.correlationId}-${event.data.commandType}-${event.data.itemKey}`,
41
+ evolve: (document: ItemStatusDocument | null, event: ItemStatusChangedEvent) => evolveItemStatus(document, event),
42
+ });
43
+
44
+ const nodeStatusProjection = inMemorySingleStreamProjection<NodeStatusDocument, NodeStatusChangedEvent>({
45
+ collectionName: 'NodeStatus',
46
+ canHandle: ['NodeStatusChanged'],
47
+ getDocumentId: (event) => `${event.data.correlationId}-${event.data.commandName}`,
48
+ evolve: (document: NodeStatusDocument | null, event: NodeStatusChangedEvent) => evolveNodeStatus(document, event),
49
+ });
50
+
51
+ const latestRunProjection = inMemorySingleStreamProjection<LatestRunDocument, PipelineRunStartedEvent>({
52
+ collectionName: 'LatestRun',
53
+ canHandle: ['PipelineRunStarted'],
54
+ getDocumentId: () => 'singleton',
55
+ evolve: (document: LatestRunDocument | null, event: PipelineRunStartedEvent) => evolveLatestRun(document, event),
56
+ });
57
+
58
+ const messageLogProjection = inMemorySingleStreamProjection<MessageLogDocument, MessageLogEvent>({
59
+ collectionName: 'MessageLog',
60
+ canHandle: ['CommandDispatched', 'DomainEventEmitted', 'PipelineRunStarted', 'NodeStatusChanged'],
61
+ getDocumentId: (event) => {
62
+ if (event.type === 'PipelineRunStarted') {
63
+ return `prs-${event.data.correlationId}`;
64
+ }
65
+ if (event.type === 'NodeStatusChanged') {
66
+ return `nsc-${event.data.correlationId}-${event.data.commandName}-${event.data.status}`;
67
+ }
68
+ if (event.type === 'DomainEventEmitted') {
69
+ return `dee-${event.data.requestId}-${event.data.eventType}`;
70
+ }
71
+ return `cmd-${event.data.requestId}`;
72
+ },
73
+ evolve: (document: MessageLogDocument | null, event: MessageLogEvent) => evolveMessageLog(document, event),
74
+ });
75
+
76
+ const statsProjection = inMemorySingleStreamProjection<StatsDocument, MessageLogEvent>({
77
+ collectionName: 'Stats',
78
+ canHandle: ['CommandDispatched', 'DomainEventEmitted'],
79
+ getDocumentId: () => 'global',
80
+ evolve: (document: StatsDocument | null, event: MessageLogEvent) => evolveStats(document, event),
81
+ });
82
+
83
+ const settledInstanceProjection = inMemorySingleStreamProjection<SettledInstanceDocument, SettledEvent>({
84
+ collectionName: 'SettledInstance',
85
+ canHandle: [
86
+ 'SettledInstanceCreated',
87
+ 'SettledCommandStarted',
88
+ 'SettledEventReceived',
89
+ 'SettledHandlerFired',
90
+ 'SettledInstanceReset',
91
+ 'SettledInstanceCleaned',
92
+ ],
93
+ getDocumentId: (event) => `${event.data.templateId}-${event.data.correlationId}`,
94
+ evolve: (document: SettledInstanceDocument | null, event: SettledEvent) => evolveSettledInstance(document, event),
95
+ });
96
+
97
+ const phasedExecutionProjection = inMemorySingleStreamProjection<PhasedExecutionDocument, PhasedExecutionEvent>({
98
+ collectionName: 'PhasedExecution',
99
+ canHandle: [
100
+ 'PhasedExecutionStarted',
101
+ 'PhasedItemDispatched',
102
+ 'PhasedItemCompleted',
103
+ 'PhasedItemFailed',
104
+ 'PhasedPhaseAdvanced',
105
+ 'PhasedExecutionCompleted',
106
+ ],
107
+ getDocumentId: (event) => event.data.executionId,
108
+ evolve: (document: PhasedExecutionDocument | null, event: PhasedExecutionEvent) =>
109
+ evolvePhasedExecution(document, event),
110
+ });
111
+
112
+ const awaitTrackerProjection = inMemorySingleStreamProjection<AwaitTrackerDocument, AwaitEvent>({
113
+ collectionName: 'AwaitTracker',
114
+ canHandle: ['AwaitStarted', 'AwaitItemCompleted', 'AwaitCompleted'],
115
+ getDocumentId: (event) => event.data.correlationId,
116
+ evolve: (document: AwaitTrackerDocument | null, event: AwaitEvent) => evolveAwaitTracker(document, event),
117
+ });
118
+
119
+ return inlineProjections<InMemoryReadEventMetadata>([
120
+ itemStatusProjection,
121
+ nodeStatusProjection,
122
+ latestRunProjection,
123
+ messageLogProjection,
124
+ statsProjection,
125
+ settledInstanceProjection,
126
+ phasedExecutionProjection,
127
+ awaitTrackerProjection,
128
+ ] as Parameters<typeof inlineProjections<InMemoryReadEventMetadata>>[0]);
129
+ }
130
+
131
+ export interface PipelineEventStoreContext {
132
+ eventStore: InMemoryEventStore;
133
+ database: InMemoryDatabase;
134
+ readModel: PipelineReadModel;
135
+ close: () => Promise<void>;
136
+ }
137
+
138
+ export function createPipelineEventStore(): PipelineEventStoreContext {
139
+ const database = getInMemoryDatabase();
140
+ const eventStore = getInMemoryEventStore({
141
+ database,
142
+ projections: createProjections() as Parameters<typeof getInMemoryEventStore>[0] extends { projections?: infer P }
143
+ ? P
144
+ : never,
145
+ });
146
+ const readModel = new PipelineReadModel(database);
147
+
148
+ return {
149
+ eventStore,
150
+ database,
151
+ readModel,
152
+ close: async () => {
153
+ // No-op for in-memory store
154
+ },
155
+ };
156
+ }