@auto-engineer/pipeline 1.65.0 → 1.67.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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +135 -0
- package/dist/src/builder/define-v2.d.ts +101 -0
- package/dist/src/builder/define-v2.d.ts.map +1 -0
- package/dist/src/builder/define-v2.js +209 -0
- package/dist/src/builder/define-v2.js.map +1 -0
- package/dist/src/engine/command-dispatcher.d.ts +31 -0
- package/dist/src/engine/command-dispatcher.d.ts.map +1 -0
- package/dist/src/engine/command-dispatcher.js +26 -0
- package/dist/src/engine/command-dispatcher.js.map +1 -0
- package/dist/src/engine/event-router.d.ts +21 -0
- package/dist/src/engine/event-router.d.ts.map +1 -0
- package/dist/src/engine/event-router.js +22 -0
- package/dist/src/engine/event-router.js.map +1 -0
- package/dist/src/engine/index.d.ts +15 -0
- package/dist/src/engine/index.d.ts.map +1 -0
- package/dist/src/engine/index.js +15 -0
- package/dist/src/engine/index.js.map +1 -0
- package/dist/src/engine/pipeline-engine.d.ts +37 -0
- package/dist/src/engine/pipeline-engine.d.ts.map +1 -0
- package/dist/src/engine/pipeline-engine.js +53 -0
- package/dist/src/engine/pipeline-engine.js.map +1 -0
- package/dist/src/engine/projections/item-status.d.ts +9 -0
- package/dist/src/engine/projections/item-status.d.ts.map +1 -0
- package/dist/src/engine/projections/item-status.js +9 -0
- package/dist/src/engine/projections/item-status.js.map +1 -0
- package/dist/src/engine/projections/latest-run.d.ts +9 -0
- package/dist/src/engine/projections/latest-run.d.ts.map +1 -0
- package/dist/src/engine/projections/latest-run.js +9 -0
- package/dist/src/engine/projections/latest-run.js.map +1 -0
- package/dist/src/engine/projections/message-log.d.ts +9 -0
- package/dist/src/engine/projections/message-log.d.ts.map +1 -0
- package/dist/src/engine/projections/message-log.js +10 -0
- package/dist/src/engine/projections/message-log.js.map +1 -0
- package/dist/src/engine/projections/node-status.d.ts +9 -0
- package/dist/src/engine/projections/node-status.d.ts.map +1 -0
- package/dist/src/engine/projections/node-status.js +9 -0
- package/dist/src/engine/projections/node-status.js.map +1 -0
- package/dist/src/engine/projections/stats.d.ts +9 -0
- package/dist/src/engine/projections/stats.d.ts.map +1 -0
- package/dist/src/engine/projections/stats.js +9 -0
- package/dist/src/engine/projections/stats.js.map +1 -0
- package/dist/src/engine/sqlite-consumer.d.ts +11 -0
- package/dist/src/engine/sqlite-consumer.d.ts.map +1 -0
- package/dist/src/engine/sqlite-consumer.js +27 -0
- package/dist/src/engine/sqlite-consumer.js.map +1 -0
- package/dist/src/engine/sqlite-store.d.ts +10 -0
- package/dist/src/engine/sqlite-store.d.ts.map +1 -0
- package/dist/src/engine/sqlite-store.js +14 -0
- package/dist/src/engine/sqlite-store.js.map +1 -0
- package/dist/src/engine/workflow-processor.d.ts +20 -0
- package/dist/src/engine/workflow-processor.d.ts.map +1 -0
- package/dist/src/engine/workflow-processor.js +36 -0
- package/dist/src/engine/workflow-processor.js.map +1 -0
- package/dist/src/engine/workflows/await-workflow.d.ts +33 -0
- package/dist/src/engine/workflows/await-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/await-workflow.js +45 -0
- package/dist/src/engine/workflows/await-workflow.js.map +1 -0
- package/dist/src/engine/workflows/phased-workflow.d.ts +64 -0
- package/dist/src/engine/workflows/phased-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/phased-workflow.js +103 -0
- package/dist/src/engine/workflows/phased-workflow.js.map +1 -0
- package/dist/src/engine/workflows/settled-workflow.d.ts +62 -0
- package/dist/src/engine/workflows/settled-workflow.d.ts.map +1 -0
- package/dist/src/engine/workflows/settled-workflow.js +92 -0
- package/dist/src/engine/workflows/settled-workflow.js.map +1 -0
- package/dist/src/graph/types.d.ts +1 -1
- package/dist/src/graph/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/server/pipeline-server-v2.d.ts +48 -0
- package/dist/src/server/pipeline-server-v2.d.ts.map +1 -0
- package/dist/src/server/pipeline-server-v2.js +61 -0
- package/dist/src/server/pipeline-server-v2.js.map +1 -0
- package/dist/src/server/pipeline-server.d.ts +5 -1
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.js +71 -10
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +13 -0
- package/package.json +3 -3
- package/src/builder/define-v2.specs.ts +236 -0
- package/src/builder/define-v2.ts +351 -0
- package/src/engine/command-dispatcher.specs.ts +62 -0
- package/src/engine/command-dispatcher.ts +46 -0
- package/src/engine/event-router.specs.ts +75 -0
- package/src/engine/event-router.ts +36 -0
- package/src/engine/index.ts +39 -0
- package/src/engine/pipeline-engine-e2e.specs.ts +776 -0
- package/src/engine/pipeline-engine.integration.specs.ts +126 -0
- package/src/engine/pipeline-engine.specs.ts +70 -0
- package/src/engine/pipeline-engine.ts +82 -0
- package/src/engine/projections/item-status.ts +11 -0
- package/src/engine/projections/latest-run.ts +10 -0
- package/src/engine/projections/message-log.ts +11 -0
- package/src/engine/projections/node-status.ts +10 -0
- package/src/engine/projections/projections.specs.ts +176 -0
- package/src/engine/projections/stats.ts +10 -0
- package/src/engine/sqlite-consumer.specs.ts +42 -0
- package/src/engine/sqlite-consumer.ts +34 -0
- package/src/engine/sqlite-store.specs.ts +46 -0
- package/src/engine/sqlite-store.ts +21 -0
- package/src/engine/workflow-processor.specs.ts +37 -0
- package/src/engine/workflow-processor.ts +57 -0
- package/src/engine/workflows/await-workflow.specs.ts +104 -0
- package/src/engine/workflows/await-workflow.ts +66 -0
- package/src/engine/workflows/phased-workflow.specs.ts +383 -0
- package/src/engine/workflows/phased-workflow.ts +153 -0
- package/src/engine/workflows/settled-workflow.specs.ts +364 -0
- package/src/engine/workflows/settled-workflow.ts +139 -0
- package/src/graph/types.ts +1 -1
- package/src/index.ts +2 -0
- package/src/server/pipeline-server-v2.specs.ts +91 -0
- package/src/server/pipeline-server-v2.ts +70 -0
- package/src/server/pipeline-server.specs.ts +327 -134
- package/src/server/pipeline-server.ts +77 -11
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { defineV2, toGraph } from '../builder/define-v2.js';
|
|
2
|
+
import { createPipelineEngine } from './pipeline-engine.js';
|
|
3
|
+
import { createSettledWorkflow } from './workflows/settled-workflow.js';
|
|
4
|
+
|
|
5
|
+
describe('PipelineEngine integration', () => {
|
|
6
|
+
it('runs emit chain: command A produces event, triggers command B', async () => {
|
|
7
|
+
const engine = await createPipelineEngine();
|
|
8
|
+
const events: string[] = [];
|
|
9
|
+
|
|
10
|
+
engine.registerCommandHandler('StartBuild', () => [{ type: 'BuildStarted', data: {} }]);
|
|
11
|
+
engine.registerCommandHandler('RunTests', () => [{ type: 'TestsPassed', data: {} }]);
|
|
12
|
+
engine.registerEmitMapping({
|
|
13
|
+
eventType: 'BuildStarted',
|
|
14
|
+
commands: [{ commandType: 'RunTests', data: {} }],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
engine.onEvent((e) => events.push(e.type));
|
|
18
|
+
await engine.dispatch({ type: 'StartBuild', data: {} });
|
|
19
|
+
|
|
20
|
+
expect(events).toEqual(['BuildStarted', 'TestsPassed']);
|
|
21
|
+
await engine.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('runs settled workflow through full lifecycle', async () => {
|
|
25
|
+
const engine = await createPipelineEngine();
|
|
26
|
+
const events: string[] = [];
|
|
27
|
+
|
|
28
|
+
engine.registerCommandHandler('CheckTests', () => [
|
|
29
|
+
{ type: 'CommandCompleted', data: { commandType: 'CheckTests', result: 'success', event: {} } },
|
|
30
|
+
]);
|
|
31
|
+
engine.registerCommandHandler('CheckTypes', () => [
|
|
32
|
+
{ type: 'CommandCompleted', data: { commandType: 'CheckTypes', result: 'success', event: {} } },
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
engine.registerWorkflow({
|
|
36
|
+
id: 'settled-checks',
|
|
37
|
+
workflow: createSettledWorkflow({ commandTypes: ['CheckTests', 'CheckTypes'] }),
|
|
38
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
engine.onEvent((e) => events.push(e.type));
|
|
42
|
+
|
|
43
|
+
engine.processWorkflowEvent({
|
|
44
|
+
type: 'StartSettled',
|
|
45
|
+
data: { correlationId: 'c1', commandTypes: ['CheckTests', 'CheckTypes'] },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await engine.dispatch({ type: 'CheckTests', data: {} });
|
|
49
|
+
await engine.dispatch({ type: 'CheckTypes', data: {} });
|
|
50
|
+
|
|
51
|
+
expect(events).toContain('CommandCompleted');
|
|
52
|
+
expect(events).toContain('AllSettled');
|
|
53
|
+
await engine.close();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('runs settled workflow with failure and retry', async () => {
|
|
57
|
+
const engine = await createPipelineEngine();
|
|
58
|
+
const events: string[] = [];
|
|
59
|
+
let callCount = 0;
|
|
60
|
+
|
|
61
|
+
engine.registerCommandHandler('CheckTests', () => {
|
|
62
|
+
callCount++;
|
|
63
|
+
if (callCount === 1) {
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
type: 'CommandCompleted',
|
|
67
|
+
data: { commandType: 'CheckTests', result: 'failure', event: { error: 'failed' } },
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
return [{ type: 'CommandCompleted', data: { commandType: 'CheckTests', result: 'success', event: {} } }];
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
engine.registerWorkflow({
|
|
75
|
+
id: 'retry-settled',
|
|
76
|
+
workflow: createSettledWorkflow({ commandTypes: ['CheckTests'], maxRetries: 3 }),
|
|
77
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
engine.onEvent((e) => events.push(e.type));
|
|
81
|
+
|
|
82
|
+
engine.processWorkflowEvent({
|
|
83
|
+
type: 'StartSettled',
|
|
84
|
+
data: { correlationId: 'c1', commandTypes: ['CheckTests'] },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await engine.dispatch({ type: 'CheckTests', data: {} });
|
|
88
|
+
|
|
89
|
+
expect(events).toContain('RetryCommands');
|
|
90
|
+
await engine.close();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('crash recovery', () => {
|
|
95
|
+
it('event store persists events across operations', async () => {
|
|
96
|
+
const engine = await createPipelineEngine();
|
|
97
|
+
|
|
98
|
+
engine.registerCommandHandler('StoreData', () => [{ type: 'DataStored', data: { key: 'test' } }]);
|
|
99
|
+
|
|
100
|
+
await engine.dispatch({ type: 'StoreData', data: {} });
|
|
101
|
+
|
|
102
|
+
const events: string[] = [];
|
|
103
|
+
engine.onEvent((e) => events.push(e.type));
|
|
104
|
+
|
|
105
|
+
engine.registerCommandHandler('MoreData', () => [{ type: 'MoreDataStored', data: {} }]);
|
|
106
|
+
await engine.dispatch({ type: 'MoreData', data: {} });
|
|
107
|
+
|
|
108
|
+
expect(events).toEqual(['MoreDataStored']);
|
|
109
|
+
await engine.close();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('A/B parity', () => {
|
|
114
|
+
it('v2 graph output has same structure as v1 format', () => {
|
|
115
|
+
const pipeline = defineV2('test').on('EventA').emit('CommandB', {}).build();
|
|
116
|
+
|
|
117
|
+
const graph = toGraph(pipeline);
|
|
118
|
+
|
|
119
|
+
expect(graph).toEqual({
|
|
120
|
+
nodes: expect.arrayContaining([
|
|
121
|
+
expect.objectContaining({ id: expect.any(String), type: expect.any(String), label: expect.any(String) }),
|
|
122
|
+
]),
|
|
123
|
+
edges: expect.arrayContaining([expect.objectContaining({ from: expect.any(String), to: expect.any(String) })]),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createPipelineEngine } from './pipeline-engine.js';
|
|
2
|
+
import { createSettledWorkflow } from './workflows/settled-workflow.js';
|
|
3
|
+
|
|
4
|
+
describe('PipelineEngine', () => {
|
|
5
|
+
it('dispatches command through registered handler', async () => {
|
|
6
|
+
const engine = await createPipelineEngine();
|
|
7
|
+
const calls: string[] = [];
|
|
8
|
+
|
|
9
|
+
engine.registerCommandHandler('CheckTests', () => {
|
|
10
|
+
calls.push('CheckTests');
|
|
11
|
+
return [{ type: 'CheckTestsPassed', data: {} }];
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await engine.dispatch({ type: 'CheckTests', data: {} });
|
|
15
|
+
|
|
16
|
+
expect(calls).toEqual(['CheckTests']);
|
|
17
|
+
await engine.close();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('routes events to emit mappings after command dispatch', async () => {
|
|
21
|
+
const engine = await createPipelineEngine();
|
|
22
|
+
const calls: string[] = [];
|
|
23
|
+
|
|
24
|
+
engine.registerCommandHandler('A', () => [{ type: 'ACompleted', data: {} }]);
|
|
25
|
+
engine.registerCommandHandler('B', () => {
|
|
26
|
+
calls.push('B');
|
|
27
|
+
return [{ type: 'BCompleted', data: {} }];
|
|
28
|
+
});
|
|
29
|
+
engine.registerEmitMapping({
|
|
30
|
+
eventType: 'ACompleted',
|
|
31
|
+
commands: [{ commandType: 'B', data: {} }],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await engine.dispatch({ type: 'A', data: {} });
|
|
35
|
+
|
|
36
|
+
expect(calls).toEqual(['B']);
|
|
37
|
+
await engine.close();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('processes full settled workflow flow', async () => {
|
|
41
|
+
const engine = await createPipelineEngine();
|
|
42
|
+
const events: string[] = [];
|
|
43
|
+
|
|
44
|
+
engine.registerCommandHandler('CheckTests', () => [
|
|
45
|
+
{ type: 'CommandCompleted', data: { commandType: 'CheckTests', result: 'success', event: {} } },
|
|
46
|
+
]);
|
|
47
|
+
engine.registerCommandHandler('CheckTypes', () => [
|
|
48
|
+
{ type: 'CommandCompleted', data: { commandType: 'CheckTypes', result: 'success', event: {} } },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
engine.registerWorkflow({
|
|
52
|
+
id: 'settled-checks',
|
|
53
|
+
workflow: createSettledWorkflow({ commandTypes: ['CheckTests', 'CheckTypes'] }),
|
|
54
|
+
inputEvents: ['StartSettled', 'CommandCompleted'],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
engine.onEvent((event) => events.push(event.type));
|
|
58
|
+
|
|
59
|
+
engine.processWorkflowEvent({
|
|
60
|
+
type: 'StartSettled',
|
|
61
|
+
data: { correlationId: 'c1', commandTypes: ['CheckTests', 'CheckTypes'] },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await engine.dispatch({ type: 'CheckTests', data: {} });
|
|
65
|
+
await engine.dispatch({ type: 'CheckTypes', data: {} });
|
|
66
|
+
|
|
67
|
+
expect(events).toContain('AllSettled');
|
|
68
|
+
await engine.close();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createCommandDispatcher, dispatchAndStore } from './command-dispatcher.js';
|
|
2
|
+
import { createEventRouter } from './event-router.js';
|
|
3
|
+
import { createPipelineStore } from './sqlite-store.js';
|
|
4
|
+
import type { WorkflowRegistration } from './workflow-processor.js';
|
|
5
|
+
import { createWorkflowProcessor } from './workflow-processor.js';
|
|
6
|
+
|
|
7
|
+
type Event = { type: string; data: Record<string, unknown> };
|
|
8
|
+
|
|
9
|
+
type CommandHandler = (command: {
|
|
10
|
+
type: string;
|
|
11
|
+
data: Record<string, unknown>;
|
|
12
|
+
}) =>
|
|
13
|
+
| Array<{ type: string; data: Record<string, unknown> }>
|
|
14
|
+
| Promise<Array<{ type: string; data: Record<string, unknown> }>>;
|
|
15
|
+
|
|
16
|
+
type EmitMapping = {
|
|
17
|
+
eventType: string;
|
|
18
|
+
commands: Array<{
|
|
19
|
+
commandType: string;
|
|
20
|
+
data: Record<string, unknown> | ((event: Event) => Record<string, unknown>);
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function createPipelineEngine() {
|
|
25
|
+
const store = await createPipelineStore();
|
|
26
|
+
const dispatcher = createCommandDispatcher();
|
|
27
|
+
const router = createEventRouter(dispatcher);
|
|
28
|
+
const workflows = createWorkflowProcessor();
|
|
29
|
+
const eventListeners: Array<(event: Event) => void> = [];
|
|
30
|
+
let streamCounter = 0;
|
|
31
|
+
|
|
32
|
+
async function processEvents(events: Event[]): Promise<void> {
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
for (const listener of eventListeners) {
|
|
35
|
+
listener(event);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const routedEvents = await router.route(event);
|
|
39
|
+
const workflowOutputs = workflows.process(event);
|
|
40
|
+
|
|
41
|
+
await processEvents(routedEvents);
|
|
42
|
+
await processEvents(workflowOutputs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
registerCommandHandler(commandType: string, handler: CommandHandler): void {
|
|
48
|
+
dispatcher.register(commandType, handler);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
registerEmitMapping(mapping: EmitMapping): void {
|
|
52
|
+
router.register(mapping);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
registerWorkflow(registration: WorkflowRegistration): void {
|
|
56
|
+
workflows.register(registration);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
registeredCommands(): string[] {
|
|
60
|
+
return dispatcher.registeredTypes();
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
onEvent(listener: (event: Event) => void): void {
|
|
64
|
+
eventListeners.push(listener);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
processWorkflowEvent(event: Event): Event[] {
|
|
68
|
+
return workflows.process(event);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async dispatch(command: { type: string; data: Record<string, unknown> }): Promise<void> {
|
|
72
|
+
streamCounter++;
|
|
73
|
+
const streamName = `pipeline-${streamCounter}`;
|
|
74
|
+
const results = await dispatchAndStore(dispatcher, store.eventStore, streamName, command);
|
|
75
|
+
await processEvents(results);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async close(): Promise<void> {
|
|
79
|
+
await store.close();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { evolve, type ItemStatusChangedEvent, type ItemStatusDocument } from '../../projections/item-status-projection';
|
|
2
|
+
|
|
3
|
+
export { evolve, type ItemStatusChangedEvent, type ItemStatusDocument };
|
|
4
|
+
|
|
5
|
+
export const itemStatusProjection = {
|
|
6
|
+
name: 'item-status',
|
|
7
|
+
canHandle: ['ItemStatusChanged'] as const,
|
|
8
|
+
evolve,
|
|
9
|
+
getDocumentId: (event: ItemStatusChangedEvent) =>
|
|
10
|
+
`${event.data.correlationId}-${event.data.commandType}-${event.data.itemKey}`,
|
|
11
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { evolve, type LatestRunDocument } from '../../projections/latest-run-projection';
|
|
2
|
+
|
|
3
|
+
export { evolve, type LatestRunDocument };
|
|
4
|
+
|
|
5
|
+
export const latestRunProjection = {
|
|
6
|
+
name: 'latest-run',
|
|
7
|
+
canHandle: ['PipelineRunStarted'] as const,
|
|
8
|
+
evolve,
|
|
9
|
+
getDocumentId: () => 'latest',
|
|
10
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import { evolve, type MessageLogDocument, type MessageLogEvent } from '../../projections/message-log-projection';
|
|
3
|
+
|
|
4
|
+
export { evolve, type MessageLogDocument, type MessageLogEvent };
|
|
5
|
+
|
|
6
|
+
export const messageLogProjection = {
|
|
7
|
+
name: 'message-log',
|
|
8
|
+
canHandle: ['CommandDispatched', 'DomainEventEmitted', 'PipelineRunStarted', 'NodeStatusChanged'] as const,
|
|
9
|
+
evolve,
|
|
10
|
+
getDocumentId: () => nanoid(),
|
|
11
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { evolve, type NodeStatusChangedEvent, type NodeStatusDocument } from '../../projections/node-status-projection';
|
|
2
|
+
|
|
3
|
+
export { evolve, type NodeStatusChangedEvent, type NodeStatusDocument };
|
|
4
|
+
|
|
5
|
+
export const nodeStatusProjection = {
|
|
6
|
+
name: 'node-status',
|
|
7
|
+
canHandle: ['NodeStatusChanged'] as const,
|
|
8
|
+
evolve,
|
|
9
|
+
getDocumentId: (event: NodeStatusChangedEvent) => `${event.data.correlationId}-${event.data.commandName}`,
|
|
10
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { itemStatusProjection } from './item-status';
|
|
3
|
+
import { latestRunProjection } from './latest-run';
|
|
4
|
+
import { messageLogProjection } from './message-log';
|
|
5
|
+
import { nodeStatusProjection } from './node-status';
|
|
6
|
+
import { statsProjection } from './stats';
|
|
7
|
+
|
|
8
|
+
describe('engine projections', () => {
|
|
9
|
+
describe('item-status', () => {
|
|
10
|
+
it('evolves ItemStatusChanged into document', () => {
|
|
11
|
+
const event = {
|
|
12
|
+
type: 'ItemStatusChanged' as const,
|
|
13
|
+
data: {
|
|
14
|
+
correlationId: 'c1',
|
|
15
|
+
commandType: 'CheckTests',
|
|
16
|
+
itemKey: 'k1',
|
|
17
|
+
requestId: 'r1',
|
|
18
|
+
status: 'running' as const,
|
|
19
|
+
attemptCount: 1,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const result = itemStatusProjection.evolve(null, event);
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
correlationId: 'c1',
|
|
25
|
+
commandType: 'CheckTests',
|
|
26
|
+
itemKey: 'k1',
|
|
27
|
+
currentRequestId: 'r1',
|
|
28
|
+
status: 'running',
|
|
29
|
+
attemptCount: 1,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('generates document id from correlation, command, and item key', () => {
|
|
34
|
+
const event = {
|
|
35
|
+
type: 'ItemStatusChanged' as const,
|
|
36
|
+
data: {
|
|
37
|
+
correlationId: 'c1',
|
|
38
|
+
commandType: 'CT',
|
|
39
|
+
itemKey: 'k1',
|
|
40
|
+
requestId: 'r1',
|
|
41
|
+
status: 'running' as const,
|
|
42
|
+
attemptCount: 1,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
expect(itemStatusProjection.getDocumentId(event)).toBe('c1-CT-k1');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('node-status', () => {
|
|
50
|
+
it('evolves NodeStatusChanged into document', () => {
|
|
51
|
+
const event = {
|
|
52
|
+
type: 'NodeStatusChanged' as const,
|
|
53
|
+
data: {
|
|
54
|
+
correlationId: 'c1',
|
|
55
|
+
commandName: 'CheckTests',
|
|
56
|
+
nodeId: 'n1',
|
|
57
|
+
status: 'running' as const,
|
|
58
|
+
previousStatus: 'idle' as const,
|
|
59
|
+
pendingCount: 3,
|
|
60
|
+
endedCount: 0,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const result = nodeStatusProjection.evolve(null, event);
|
|
64
|
+
expect(result).toEqual({
|
|
65
|
+
correlationId: 'c1',
|
|
66
|
+
commandName: 'CheckTests',
|
|
67
|
+
status: 'running',
|
|
68
|
+
pendingCount: 3,
|
|
69
|
+
endedCount: 0,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('generates document id from correlation and command name', () => {
|
|
74
|
+
const event = {
|
|
75
|
+
type: 'NodeStatusChanged' as const,
|
|
76
|
+
data: {
|
|
77
|
+
correlationId: 'c1',
|
|
78
|
+
commandName: 'CT',
|
|
79
|
+
nodeId: 'n1',
|
|
80
|
+
status: 'idle' as const,
|
|
81
|
+
previousStatus: 'idle' as const,
|
|
82
|
+
pendingCount: 0,
|
|
83
|
+
endedCount: 0,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
expect(nodeStatusProjection.getDocumentId(event)).toBe('c1-CT');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('latest-run', () => {
|
|
91
|
+
it('evolves PipelineRunStarted into document', () => {
|
|
92
|
+
const event = {
|
|
93
|
+
type: 'PipelineRunStarted' as const,
|
|
94
|
+
data: { correlationId: 'c1', triggerCommand: 'StartPipeline' },
|
|
95
|
+
};
|
|
96
|
+
const result = latestRunProjection.evolve(null, event);
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
latestCorrelationId: 'c1',
|
|
99
|
+
triggerCommand: 'StartPipeline',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('uses constant document id', () => {
|
|
104
|
+
expect(latestRunProjection.getDocumentId()).toBe('latest');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('message-log', () => {
|
|
109
|
+
it('evolves CommandDispatched into document', () => {
|
|
110
|
+
const now = new Date();
|
|
111
|
+
const event = {
|
|
112
|
+
type: 'CommandDispatched' as const,
|
|
113
|
+
data: {
|
|
114
|
+
correlationId: 'c1',
|
|
115
|
+
requestId: 'r1',
|
|
116
|
+
commandType: 'CheckTests',
|
|
117
|
+
commandData: { x: 1 },
|
|
118
|
+
timestamp: now,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const result = messageLogProjection.evolve(null, event);
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
correlationId: 'c1',
|
|
124
|
+
requestId: 'r1',
|
|
125
|
+
messageType: 'command',
|
|
126
|
+
messageName: 'CheckTests',
|
|
127
|
+
messageData: { x: 1 },
|
|
128
|
+
timestamp: now,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('generates unique document ids', () => {
|
|
133
|
+
const id1 = messageLogProjection.getDocumentId();
|
|
134
|
+
const id2 = messageLogProjection.getDocumentId();
|
|
135
|
+
expect(id1).not.toBe(id2);
|
|
136
|
+
expect(typeof id1).toBe('string');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('stats', () => {
|
|
141
|
+
it('evolves CommandDispatched by incrementing command count', () => {
|
|
142
|
+
const event = {
|
|
143
|
+
type: 'CommandDispatched' as const,
|
|
144
|
+
data: {
|
|
145
|
+
correlationId: 'c1',
|
|
146
|
+
requestId: 'r1',
|
|
147
|
+
commandType: 'CT',
|
|
148
|
+
commandData: {},
|
|
149
|
+
timestamp: new Date(),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const result = statsProjection.evolve(null, event);
|
|
153
|
+
expect(result).toEqual({ totalMessages: 1, totalCommands: 1, totalEvents: 0 });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('evolves DomainEventEmitted by incrementing event count', () => {
|
|
157
|
+
const existing = { totalMessages: 1, totalCommands: 1, totalEvents: 0 };
|
|
158
|
+
const event = {
|
|
159
|
+
type: 'DomainEventEmitted' as const,
|
|
160
|
+
data: {
|
|
161
|
+
correlationId: 'c1',
|
|
162
|
+
requestId: 'r1',
|
|
163
|
+
eventType: 'ET',
|
|
164
|
+
eventData: {},
|
|
165
|
+
timestamp: new Date(),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const result = statsProjection.evolve(existing, event);
|
|
169
|
+
expect(result).toEqual({ totalMessages: 2, totalCommands: 1, totalEvents: 1 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('uses constant document id', () => {
|
|
173
|
+
expect(statsProjection.getDocumentId()).toBe('global');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { evolve, type StatsDocument } from '../../projections/stats-projection';
|
|
2
|
+
|
|
3
|
+
export { evolve, type StatsDocument };
|
|
4
|
+
|
|
5
|
+
export const statsProjection = {
|
|
6
|
+
name: 'stats',
|
|
7
|
+
canHandle: ['CommandDispatched', 'DomainEventEmitted'] as const,
|
|
8
|
+
evolve,
|
|
9
|
+
getDocumentId: () => 'global',
|
|
10
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createConsumer } from './sqlite-consumer.js';
|
|
2
|
+
import { createPipelineStore } from './sqlite-store.js';
|
|
3
|
+
|
|
4
|
+
describe('SQLiteConsumer', () => {
|
|
5
|
+
it('delivers events to registered handler', async () => {
|
|
6
|
+
const store = await createPipelineStore();
|
|
7
|
+
const consumer = createConsumer(store);
|
|
8
|
+
const received: Array<{ type: string }> = [];
|
|
9
|
+
|
|
10
|
+
consumer.on('TestEvent', (event) => {
|
|
11
|
+
received.push(event);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await store.eventStore.appendToStream('test-stream', [{ type: 'TestEvent', data: { value: 1 } }]);
|
|
15
|
+
|
|
16
|
+
await consumer.poll();
|
|
17
|
+
|
|
18
|
+
expect(received).toEqual([expect.objectContaining({ type: 'TestEvent', data: { value: 1 } })]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('delivers events in order', async () => {
|
|
22
|
+
const store = await createPipelineStore();
|
|
23
|
+
const consumer = createConsumer(store);
|
|
24
|
+
const types: string[] = [];
|
|
25
|
+
|
|
26
|
+
consumer.on('A', (event) => {
|
|
27
|
+
types.push(event.type);
|
|
28
|
+
});
|
|
29
|
+
consumer.on('B', (event) => {
|
|
30
|
+
types.push(event.type);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await store.eventStore.appendToStream('s1', [
|
|
34
|
+
{ type: 'A', data: {} },
|
|
35
|
+
{ type: 'B', data: {} },
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
await consumer.poll();
|
|
39
|
+
|
|
40
|
+
expect(types).toEqual(['A', 'B']);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readMessagesBatch, sqliteConnection } from '@event-driven-io/emmett-sqlite';
|
|
2
|
+
import type { PipelineStore } from './sqlite-store.js';
|
|
3
|
+
|
|
4
|
+
type EventHandler = (event: { type: string; data: Record<string, unknown> }) => void;
|
|
5
|
+
|
|
6
|
+
export function createConsumer(store: PipelineStore) {
|
|
7
|
+
const handlers = new Map<string, EventHandler>();
|
|
8
|
+
let lastPosition = 0n;
|
|
9
|
+
const connection = sqliteConnection({ fileName: store.fileName });
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
on(eventType: string, handler: EventHandler): void {
|
|
13
|
+
handlers.set(eventType, handler);
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async poll(): Promise<void> {
|
|
17
|
+
const { messages, currentGlobalPosition } = await readMessagesBatch(connection, {
|
|
18
|
+
after: lastPosition,
|
|
19
|
+
batchSize: 1000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
for (const message of messages) {
|
|
23
|
+
const handler = handlers.get(message.type);
|
|
24
|
+
if (handler) {
|
|
25
|
+
handler({ type: message.type, data: message.data as Record<string, unknown> });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (messages.length > 0) {
|
|
30
|
+
lastPosition = currentGlobalPosition;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createPipelineStore } from './sqlite-store.js';
|
|
2
|
+
|
|
3
|
+
describe('createPipelineStore', () => {
|
|
4
|
+
it('creates a pipeline store with in-memory SQLite', async () => {
|
|
5
|
+
const store = await createPipelineStore();
|
|
6
|
+
expect(store.eventStore).toBeDefined();
|
|
7
|
+
expect(store.close).toBeInstanceOf(Function);
|
|
8
|
+
await store.close();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('appends events to a stream and reads them back', async () => {
|
|
12
|
+
const store = await createPipelineStore();
|
|
13
|
+
|
|
14
|
+
await store.eventStore.appendToStream('test-stream-1', [
|
|
15
|
+
{ type: 'TaskStarted', data: { taskId: 'a' } },
|
|
16
|
+
{ type: 'TaskCompleted', data: { taskId: 'a' } },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const result = await store.eventStore.readStream('test-stream-1');
|
|
20
|
+
|
|
21
|
+
expect(result.events).toHaveLength(2);
|
|
22
|
+
expect(result.events[0]!.type).toEqual('TaskStarted');
|
|
23
|
+
expect(result.events[0]!.data).toEqual({ taskId: 'a' });
|
|
24
|
+
expect(result.events[1]!.type).toEqual('TaskCompleted');
|
|
25
|
+
expect(result.events[1]!.data).toEqual({ taskId: 'a' });
|
|
26
|
+
|
|
27
|
+
await store.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns events in append order', async () => {
|
|
31
|
+
const store = await createPipelineStore();
|
|
32
|
+
|
|
33
|
+
await store.eventStore.appendToStream('test-stream-2', [
|
|
34
|
+
{ type: 'Step1', data: { order: 1 } },
|
|
35
|
+
{ type: 'Step2', data: { order: 2 } },
|
|
36
|
+
{ type: 'Step3', data: { order: 3 } },
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const result = await store.eventStore.readStream('test-stream-2');
|
|
40
|
+
|
|
41
|
+
expect(result.events.map((e) => e.type)).toEqual(['Step1', 'Step2', 'Step3']);
|
|
42
|
+
expect(result.events.map((e) => e.data)).toEqual([{ order: 1 }, { order: 2 }, { order: 3 }]);
|
|
43
|
+
|
|
44
|
+
await store.close();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getSQLiteEventStore, type SQLiteEventStore } from '@event-driven-io/emmett-sqlite';
|
|
2
|
+
|
|
3
|
+
export interface PipelineStore {
|
|
4
|
+
eventStore: SQLiteEventStore;
|
|
5
|
+
fileName: string;
|
|
6
|
+
close: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createPipelineStore(options?: { fileName?: string }): Promise<PipelineStore> {
|
|
10
|
+
const fileName = options?.fileName ?? 'file::memory:?cache=shared';
|
|
11
|
+
const eventStore = getSQLiteEventStore({
|
|
12
|
+
fileName,
|
|
13
|
+
schema: { autoMigration: 'CreateOrUpdate' },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
eventStore,
|
|
18
|
+
fileName,
|
|
19
|
+
close: async () => {},
|
|
20
|
+
};
|
|
21
|
+
}
|