flowfn 0.0.1
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/dist/index.d.mts +1305 -0
- package/dist/index.d.ts +1305 -0
- package/dist/index.js +3180 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3088 -0
- package/dist/index.mjs.map +1 -0
- package/docs/API.md +801 -0
- package/docs/USAGE.md +619 -0
- package/package.json +75 -0
- package/src/adapters/base.ts +46 -0
- package/src/adapters/memory.ts +183 -0
- package/src/adapters/postgres/index.ts +383 -0
- package/src/adapters/postgres/postgres.test.ts +100 -0
- package/src/adapters/postgres/schema.ts +110 -0
- package/src/adapters/redis.test.ts +124 -0
- package/src/adapters/redis.ts +331 -0
- package/src/core/flow-fn.test.ts +70 -0
- package/src/core/flow-fn.ts +198 -0
- package/src/core/metrics.ts +198 -0
- package/src/core/scheduler.test.ts +80 -0
- package/src/core/scheduler.ts +154 -0
- package/src/index.ts +57 -0
- package/src/monitoring/health.ts +261 -0
- package/src/patterns/backoff.ts +30 -0
- package/src/patterns/batching.ts +248 -0
- package/src/patterns/circuit-breaker.test.ts +52 -0
- package/src/patterns/circuit-breaker.ts +52 -0
- package/src/patterns/priority.ts +146 -0
- package/src/patterns/rate-limit.ts +290 -0
- package/src/patterns/retry.test.ts +62 -0
- package/src/queue/batch.test.ts +35 -0
- package/src/queue/dependencies.test.ts +33 -0
- package/src/queue/dlq.ts +222 -0
- package/src/queue/job.ts +67 -0
- package/src/queue/queue.ts +243 -0
- package/src/queue/types.ts +153 -0
- package/src/queue/worker.ts +66 -0
- package/src/storage/event-log.ts +205 -0
- package/src/storage/job-storage.ts +206 -0
- package/src/storage/workflow-storage.ts +182 -0
- package/src/stream/stream.ts +194 -0
- package/src/stream/types.ts +81 -0
- package/src/utils/hashing.ts +29 -0
- package/src/utils/id-generator.ts +109 -0
- package/src/utils/serialization.ts +142 -0
- package/src/utils/time.ts +167 -0
- package/src/workflow/advanced.test.ts +43 -0
- package/src/workflow/events.test.ts +39 -0
- package/src/workflow/types.ts +132 -0
- package/src/workflow/workflow.test.ts +55 -0
- package/src/workflow/workflow.ts +422 -0
- package/tests/dlq.test.ts +205 -0
- package/tests/health.test.ts +228 -0
- package/tests/integration.test.ts +253 -0
- package/tests/stream.test.ts +233 -0
- package/tests/workflow.test.ts +286 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createFlow } from '../core/flow-fn.js';
|
|
3
|
+
|
|
4
|
+
describe('Advanced Workflow Features', () => {
|
|
5
|
+
let flow;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
flow = createFlow({ adapter: 'memory' });
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await flow.close();
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should delay until specific time', async () => {
|
|
18
|
+
const targetTime = Date.now() + 1000;
|
|
19
|
+
|
|
20
|
+
const workflow = flow.workflow('delay-until')
|
|
21
|
+
.step('start', async (ctx) => { ctx.set('step1', Date.now()); })
|
|
22
|
+
.delayUntil(() => targetTime)
|
|
23
|
+
.step('end', async (ctx) => { ctx.set('step2', Date.now()); })
|
|
24
|
+
.build();
|
|
25
|
+
|
|
26
|
+
const execution = await workflow.execute({});
|
|
27
|
+
|
|
28
|
+
// Initial part runs
|
|
29
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
30
|
+
|
|
31
|
+
// Wait for delay
|
|
32
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
33
|
+
|
|
34
|
+
const state = await workflow.getExecution(execution.id);
|
|
35
|
+
expect(state.status).toBe('completed');
|
|
36
|
+
|
|
37
|
+
const step1Time = state.output.step1;
|
|
38
|
+
const step2Time = state.output.step2;
|
|
39
|
+
|
|
40
|
+
expect(step2Time).toBeGreaterThanOrEqual(targetTime);
|
|
41
|
+
expect(step2Time - step1Time).toBeGreaterThanOrEqual(1000);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createFlow } from '../core/flow-fn.js';
|
|
3
|
+
|
|
4
|
+
describe('Workflow Events', () => {
|
|
5
|
+
let flow;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
flow = createFlow({ adapter: 'memory' });
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await flow.close();
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should wait for event (timeout simulation)', async () => {
|
|
18
|
+
const workflow = flow.workflow('wait-event')
|
|
19
|
+
.step('start', async (ctx) => { ctx.set('started', true); })
|
|
20
|
+
.waitForEvent('user.signup', { timeout: 1000 })
|
|
21
|
+
.step('end', async (ctx) => { ctx.set('ended', true); })
|
|
22
|
+
.build();
|
|
23
|
+
|
|
24
|
+
const execution = await workflow.execute({});
|
|
25
|
+
|
|
26
|
+
// Start
|
|
27
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
28
|
+
|
|
29
|
+
// Check intermediate state if possible (not easily exposed yet)
|
|
30
|
+
|
|
31
|
+
// Wait for timeout
|
|
32
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
33
|
+
|
|
34
|
+
const state = await workflow.getExecution(execution.id);
|
|
35
|
+
expect(state.status).toBe('completed');
|
|
36
|
+
expect(state.output.started).toBe(true);
|
|
37
|
+
expect(state.output.ended).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export interface WorkflowContext<T = any> {
|
|
2
|
+
workflowId: string;
|
|
3
|
+
executionId: string;
|
|
4
|
+
input: T;
|
|
5
|
+
state: Record<string, any>;
|
|
6
|
+
metadata: Record<string, any>;
|
|
7
|
+
|
|
8
|
+
set(key: string, value: any): void;
|
|
9
|
+
get(key: string): any;
|
|
10
|
+
sleep(ms: number): Promise<void>;
|
|
11
|
+
waitForEvent(event: string, timeout?: number): Promise<any>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Workflow<T = any> {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
|
|
18
|
+
execute(input: T): Promise<WorkflowExecution>;
|
|
19
|
+
getExecution(executionId: string): Promise<WorkflowExecution>;
|
|
20
|
+
listExecutions(options?: ListOptions): Promise<WorkflowExecution[]>;
|
|
21
|
+
|
|
22
|
+
cancelExecution(executionId: string): Promise<void>;
|
|
23
|
+
retryExecution(executionId: string): Promise<WorkflowExecution>;
|
|
24
|
+
|
|
25
|
+
getExecutionHistory(executionId: string): Promise<WorkflowEvent[]>;
|
|
26
|
+
getMetrics(): Promise<WorkflowMetrics>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WorkflowExecution {
|
|
30
|
+
id: string;
|
|
31
|
+
workflowId: string;
|
|
32
|
+
status: ExecutionStatus;
|
|
33
|
+
input: any;
|
|
34
|
+
output?: any;
|
|
35
|
+
error?: any;
|
|
36
|
+
state?: Record<string, any>;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
completedAt?: number;
|
|
39
|
+
createdAt?: number;
|
|
40
|
+
updatedAt?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ExecutionStatus =
|
|
44
|
+
| "pending"
|
|
45
|
+
| "running"
|
|
46
|
+
| "completed"
|
|
47
|
+
| "failed"
|
|
48
|
+
| "cancelled";
|
|
49
|
+
|
|
50
|
+
export interface WorkflowEvent {
|
|
51
|
+
timestamp: number;
|
|
52
|
+
type: string;
|
|
53
|
+
step?: string;
|
|
54
|
+
data?: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface WorkflowMetrics {
|
|
58
|
+
totalExecutions: number;
|
|
59
|
+
successRate: number;
|
|
60
|
+
avgDuration: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ListOptions {
|
|
64
|
+
status?: ExecutionStatus;
|
|
65
|
+
limit?: number;
|
|
66
|
+
offset?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type StepHandler<T, R> = (
|
|
70
|
+
ctx: WorkflowContext<T>,
|
|
71
|
+
...args: any[]
|
|
72
|
+
) => Promise<R>;
|
|
73
|
+
export type ErrorHandler<T> = (
|
|
74
|
+
ctx: WorkflowContext<T>,
|
|
75
|
+
error: Error
|
|
76
|
+
) => Promise<void>;
|
|
77
|
+
export type CompensateHandler<T> = (ctx: WorkflowContext<T>) => Promise<void>;
|
|
78
|
+
|
|
79
|
+
export interface BranchOptions<T> {
|
|
80
|
+
condition: (ctx: WorkflowContext<T>) => boolean | Promise<boolean>;
|
|
81
|
+
then: WorkflowBuilder<T>;
|
|
82
|
+
else?: WorkflowBuilder<T>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SagaDefinition<T> {
|
|
86
|
+
execute: StepHandler<T, any>;
|
|
87
|
+
compensate: CompensateHandler<T>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ApprovalOptions<T> {
|
|
91
|
+
timeout: number;
|
|
92
|
+
data?: (ctx: WorkflowContext<T>) => any;
|
|
93
|
+
onTimeout?: (ctx: WorkflowContext<T>) => Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface EventOptions {
|
|
97
|
+
timeout?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Duration = {
|
|
101
|
+
ms?: number;
|
|
102
|
+
seconds?: number;
|
|
103
|
+
minutes?: number;
|
|
104
|
+
hours?: number;
|
|
105
|
+
days?: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface WorkflowBuilder<T = any> {
|
|
109
|
+
input<I>(): WorkflowBuilder<I>;
|
|
110
|
+
|
|
111
|
+
step<R>(name: string, handler: StepHandler<T, R>): WorkflowBuilder<T>;
|
|
112
|
+
parallel(steps: StepHandler<T, any>[]): WorkflowBuilder<T>;
|
|
113
|
+
branch(options: BranchOptions<T>): WorkflowBuilder<T>;
|
|
114
|
+
|
|
115
|
+
saga(name: string, saga: SagaDefinition<T>): WorkflowBuilder<T>;
|
|
116
|
+
|
|
117
|
+
delay(duration: Duration): WorkflowBuilder<T>;
|
|
118
|
+
delayUntil(
|
|
119
|
+
condition: (ctx: WorkflowContext<T>) => number
|
|
120
|
+
): WorkflowBuilder<T>;
|
|
121
|
+
|
|
122
|
+
waitForApproval(
|
|
123
|
+
approver: string,
|
|
124
|
+
options: ApprovalOptions<T>
|
|
125
|
+
): WorkflowBuilder<T>;
|
|
126
|
+
waitForEvent(event: string, options?: EventOptions): WorkflowBuilder<T>;
|
|
127
|
+
|
|
128
|
+
onError(step: string, handler: ErrorHandler<T>): WorkflowBuilder<T>;
|
|
129
|
+
compensate(step: string, handler: CompensateHandler<T>): WorkflowBuilder<T>;
|
|
130
|
+
|
|
131
|
+
build(): Workflow<T>;
|
|
132
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createFlow } from '../core/flow-fn.js';
|
|
3
|
+
|
|
4
|
+
describe('Workflow Engine', () => {
|
|
5
|
+
let flow;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
flow = createFlow({ adapter: 'memory' });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await flow.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should execute branching logic (true)', async () => {
|
|
16
|
+
const workflow = flow.workflow('branch-test')
|
|
17
|
+
.input<{ value: number }>()
|
|
18
|
+
.branch({
|
|
19
|
+
condition: (ctx) => ctx.input.value > 10,
|
|
20
|
+
then: flow.workflow('then-branch').step('high', async (ctx) => { ctx.set('result', 'high'); }),
|
|
21
|
+
else: flow.workflow('else-branch').step('low', async (ctx) => { ctx.set('result', 'low'); })
|
|
22
|
+
})
|
|
23
|
+
.build();
|
|
24
|
+
|
|
25
|
+
const execution = await workflow.execute({ value: 20 });
|
|
26
|
+
|
|
27
|
+
await new Promise(r => setTimeout(r, 50));
|
|
28
|
+
|
|
29
|
+
const state = await workflow.getExecution(execution.id);
|
|
30
|
+
expect(state.status).toBe('completed');
|
|
31
|
+
expect(state.output.result).toBe('high');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should execute saga compensation on failure', async () => {
|
|
35
|
+
let compensationCalled = false;
|
|
36
|
+
|
|
37
|
+
const workflow = flow.workflow('saga-test')
|
|
38
|
+
.saga('step1', {
|
|
39
|
+
execute: async (ctx) => { ctx.set('step1', 'done'); },
|
|
40
|
+
compensate: async (ctx) => { compensationCalled = true; }
|
|
41
|
+
})
|
|
42
|
+
.step('fail-step', async (ctx) => {
|
|
43
|
+
throw new Error('Boom');
|
|
44
|
+
})
|
|
45
|
+
.build();
|
|
46
|
+
|
|
47
|
+
const execution = await workflow.execute({});
|
|
48
|
+
|
|
49
|
+
await new Promise(r => setTimeout(r, 50));
|
|
50
|
+
|
|
51
|
+
const state = await workflow.getExecution(execution.id);
|
|
52
|
+
expect(state.status).toBe('failed');
|
|
53
|
+
expect(compensationCalled).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Workflow,
|
|
3
|
+
WorkflowBuilder,
|
|
4
|
+
WorkflowContext,
|
|
5
|
+
WorkflowExecution,
|
|
6
|
+
StepHandler,
|
|
7
|
+
ErrorHandler,
|
|
8
|
+
CompensateHandler,
|
|
9
|
+
BranchOptions,
|
|
10
|
+
SagaDefinition,
|
|
11
|
+
ApprovalOptions,
|
|
12
|
+
EventOptions,
|
|
13
|
+
Duration,
|
|
14
|
+
WorkflowEvent,
|
|
15
|
+
WorkflowMetrics,
|
|
16
|
+
ListOptions,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { v4 as uuidv4 } from "uuid";
|
|
19
|
+
import { FlowAdapter } from "../adapters/base.js";
|
|
20
|
+
import {
|
|
21
|
+
MemoryWorkflowStorage,
|
|
22
|
+
WorkflowStorage,
|
|
23
|
+
} from "../storage/workflow-storage.js";
|
|
24
|
+
import { MemoryEventLog, EventLog } from "../storage/event-log.js";
|
|
25
|
+
|
|
26
|
+
type StepDefinition = {
|
|
27
|
+
type:
|
|
28
|
+
| "step"
|
|
29
|
+
| "parallel"
|
|
30
|
+
| "branch"
|
|
31
|
+
| "saga"
|
|
32
|
+
| "delay"
|
|
33
|
+
| "delayUntil"
|
|
34
|
+
| "wait";
|
|
35
|
+
name?: string;
|
|
36
|
+
handler?: Function;
|
|
37
|
+
compensate?: Function;
|
|
38
|
+
options?: any;
|
|
39
|
+
steps?: StepDefinition[]; // for parallel/branch-then
|
|
40
|
+
elseSteps?: StepDefinition[]; // for branch-else
|
|
41
|
+
condition?: (
|
|
42
|
+
ctx: WorkflowContext<any>
|
|
43
|
+
) => boolean | Promise<boolean> | number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export class WorkflowBuilderImpl<T = any> implements WorkflowBuilder<T> {
|
|
47
|
+
public steps: StepDefinition[] = [];
|
|
48
|
+
private name: string;
|
|
49
|
+
private adapter: FlowAdapter;
|
|
50
|
+
|
|
51
|
+
constructor(name: string, adapter: FlowAdapter) {
|
|
52
|
+
this.name = name;
|
|
53
|
+
this.adapter = adapter;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
input<I>(): WorkflowBuilder<I> {
|
|
57
|
+
return this as unknown as WorkflowBuilder<I>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
step<R>(name: string, handler: StepHandler<T, R>): WorkflowBuilder<T> {
|
|
61
|
+
this.steps.push({ type: "step", name, handler });
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
parallel(steps: StepHandler<T, any>[]): WorkflowBuilder<T> {
|
|
66
|
+
this.steps.push({
|
|
67
|
+
type: "parallel",
|
|
68
|
+
steps: steps.map((h, i) => ({
|
|
69
|
+
type: "step",
|
|
70
|
+
name: `parallel-${i}`,
|
|
71
|
+
handler: h,
|
|
72
|
+
})),
|
|
73
|
+
});
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
branch(options: BranchOptions<T>): WorkflowBuilder<T> {
|
|
78
|
+
const thenSteps = (options.then as unknown as WorkflowBuilderImpl<T>).steps;
|
|
79
|
+
const elseSteps = options.else
|
|
80
|
+
? (options.else as unknown as WorkflowBuilderImpl<T>).steps
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
this.steps.push({
|
|
84
|
+
type: "branch",
|
|
85
|
+
condition: options.condition,
|
|
86
|
+
steps: thenSteps,
|
|
87
|
+
elseSteps: elseSteps,
|
|
88
|
+
});
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
saga(name: string, saga: SagaDefinition<T>): WorkflowBuilder<T> {
|
|
93
|
+
this.steps.push({
|
|
94
|
+
type: "saga",
|
|
95
|
+
name,
|
|
96
|
+
handler: saga.execute,
|
|
97
|
+
compensate: saga.compensate,
|
|
98
|
+
});
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
delay(duration: Duration): WorkflowBuilder<T> {
|
|
103
|
+
this.steps.push({ type: "delay", options: duration });
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
delayUntil(
|
|
108
|
+
condition: (ctx: WorkflowContext<T>) => number
|
|
109
|
+
): WorkflowBuilder<T> {
|
|
110
|
+
this.steps.push({ type: "delayUntil", condition });
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
waitForApproval(
|
|
115
|
+
approver: string,
|
|
116
|
+
options: ApprovalOptions<T>
|
|
117
|
+
): WorkflowBuilder<T> {
|
|
118
|
+
this.steps.push({
|
|
119
|
+
type: "wait",
|
|
120
|
+
name: "approval",
|
|
121
|
+
options: { approver, ...options },
|
|
122
|
+
});
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
waitForEvent(event: string, options?: EventOptions): WorkflowBuilder<T> {
|
|
127
|
+
this.steps.push({
|
|
128
|
+
type: "wait",
|
|
129
|
+
name: "event",
|
|
130
|
+
options: { event, ...options },
|
|
131
|
+
});
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onError(step: string, handler: ErrorHandler<T>): WorkflowBuilder<T> {
|
|
136
|
+
const s = this.steps.find((s) => s.name === step);
|
|
137
|
+
if (s) {
|
|
138
|
+
// Attach error handler
|
|
139
|
+
}
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
compensate(step: string, handler: CompensateHandler<T>): WorkflowBuilder<T> {
|
|
144
|
+
const s = this.steps.find((s) => s.name === step);
|
|
145
|
+
if (s) {
|
|
146
|
+
s.compensate = handler;
|
|
147
|
+
}
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
build(): Workflow<T> {
|
|
152
|
+
return new WorkflowImpl(this.name, this.steps, this.adapter);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export class WorkflowImpl<T = any> implements Workflow<T> {
|
|
157
|
+
id: string;
|
|
158
|
+
name: string;
|
|
159
|
+
private steps: StepDefinition[];
|
|
160
|
+
private adapter: FlowAdapter;
|
|
161
|
+
private storage: WorkflowStorage;
|
|
162
|
+
private eventLog: EventLog;
|
|
163
|
+
private executionCount = 0;
|
|
164
|
+
private successCount = 0;
|
|
165
|
+
private totalDuration = 0;
|
|
166
|
+
|
|
167
|
+
constructor(name: string, steps: StepDefinition[], adapter: FlowAdapter) {
|
|
168
|
+
this.id = uuidv4();
|
|
169
|
+
this.name = name;
|
|
170
|
+
this.steps = steps;
|
|
171
|
+
this.adapter = adapter;
|
|
172
|
+
this.storage = new MemoryWorkflowStorage();
|
|
173
|
+
this.eventLog = new MemoryEventLog();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async execute(input: T): Promise<WorkflowExecution> {
|
|
177
|
+
const executionId = uuidv4();
|
|
178
|
+
const execution: WorkflowExecution = {
|
|
179
|
+
id: executionId,
|
|
180
|
+
workflowId: this.id,
|
|
181
|
+
status: "running",
|
|
182
|
+
input,
|
|
183
|
+
state: {},
|
|
184
|
+
startedAt: Date.now(),
|
|
185
|
+
createdAt: Date.now(),
|
|
186
|
+
updatedAt: Date.now(),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Save initial state
|
|
190
|
+
await this.storage.save(this.id, execution);
|
|
191
|
+
await this.logEvent(executionId, "execution.started", { input });
|
|
192
|
+
|
|
193
|
+
this.runExecution(execution).catch(console.error);
|
|
194
|
+
|
|
195
|
+
return execution;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async runExecution(execution: WorkflowExecution) {
|
|
199
|
+
const context: WorkflowContext<T> = {
|
|
200
|
+
workflowId: this.id,
|
|
201
|
+
executionId: execution.id,
|
|
202
|
+
input: execution.input,
|
|
203
|
+
state: execution.state || {},
|
|
204
|
+
metadata: {},
|
|
205
|
+
set: (k, v) => {
|
|
206
|
+
context.state[k] = v;
|
|
207
|
+
this.storage
|
|
208
|
+
.updateState(execution.id, context.state)
|
|
209
|
+
.catch(console.error);
|
|
210
|
+
},
|
|
211
|
+
get: (k) => context.state[k],
|
|
212
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
213
|
+
waitForEvent: async (event, timeout) => {
|
|
214
|
+
// Simplified wait: sleep for now
|
|
215
|
+
// In real system, this suspends workflow until external signal
|
|
216
|
+
await new Promise((r) => setTimeout(r, timeout || 100));
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const executedSagas: StepDefinition[] = [];
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await this.executeSteps(this.steps, context, executedSagas);
|
|
224
|
+
execution.status = "completed";
|
|
225
|
+
execution.output = context.state;
|
|
226
|
+
execution.completedAt = Date.now();
|
|
227
|
+
|
|
228
|
+
await this.logEvent(execution.id, "execution.completed", {
|
|
229
|
+
output: execution.output,
|
|
230
|
+
});
|
|
231
|
+
this.executionCount++;
|
|
232
|
+
this.successCount++;
|
|
233
|
+
this.totalDuration += execution.completedAt - execution.startedAt;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
execution.status = "failed";
|
|
236
|
+
execution.error = error;
|
|
237
|
+
execution.completedAt = Date.now();
|
|
238
|
+
|
|
239
|
+
await this.logEvent(execution.id, "execution.failed", {
|
|
240
|
+
error: (error as Error).message,
|
|
241
|
+
});
|
|
242
|
+
await this.compensate(executedSagas, context);
|
|
243
|
+
this.executionCount++;
|
|
244
|
+
this.totalDuration += execution.completedAt - execution.startedAt;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update storage with final state
|
|
248
|
+
await this.storage.save(this.id, execution);
|
|
249
|
+
await this.adapter.saveWorkflowState(execution.id, execution);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async logEvent(
|
|
253
|
+
executionId: string,
|
|
254
|
+
type: string,
|
|
255
|
+
data: any
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
await this.eventLog.append({
|
|
258
|
+
type,
|
|
259
|
+
aggregateId: executionId,
|
|
260
|
+
aggregateType: "workflow_execution",
|
|
261
|
+
data,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private async executeSteps(
|
|
266
|
+
steps: StepDefinition[],
|
|
267
|
+
context: WorkflowContext<T>,
|
|
268
|
+
executedSagas: StepDefinition[]
|
|
269
|
+
) {
|
|
270
|
+
for (const step of steps) {
|
|
271
|
+
if (step.type === "step" && step.handler) {
|
|
272
|
+
await this.logEvent(context.executionId, "step.started", {
|
|
273
|
+
step: step.name,
|
|
274
|
+
});
|
|
275
|
+
await step.handler(context);
|
|
276
|
+
await this.logEvent(context.executionId, "step.completed", {
|
|
277
|
+
step: step.name,
|
|
278
|
+
});
|
|
279
|
+
} else if (step.type === "saga" && step.handler) {
|
|
280
|
+
await this.logEvent(context.executionId, "saga.started", {
|
|
281
|
+
step: step.name,
|
|
282
|
+
});
|
|
283
|
+
await step.handler(context);
|
|
284
|
+
executedSagas.push(step);
|
|
285
|
+
await this.logEvent(context.executionId, "saga.completed", {
|
|
286
|
+
step: step.name,
|
|
287
|
+
});
|
|
288
|
+
} else if (step.type === "parallel" && step.steps) {
|
|
289
|
+
await this.logEvent(context.executionId, "parallel.started", {
|
|
290
|
+
count: step.steps.length,
|
|
291
|
+
});
|
|
292
|
+
await Promise.all(
|
|
293
|
+
step.steps.map((s) =>
|
|
294
|
+
s.handler ? s.handler(context) : Promise.resolve()
|
|
295
|
+
)
|
|
296
|
+
);
|
|
297
|
+
await this.logEvent(context.executionId, "parallel.completed", {});
|
|
298
|
+
} else if (step.type === "branch" && step.condition) {
|
|
299
|
+
const conditionMet = await (step.condition as any)(context);
|
|
300
|
+
if (conditionMet && step.steps) {
|
|
301
|
+
await this.executeSteps(step.steps, context, executedSagas);
|
|
302
|
+
} else if (!conditionMet && step.elseSteps) {
|
|
303
|
+
await this.executeSteps(step.elseSteps, context, executedSagas);
|
|
304
|
+
}
|
|
305
|
+
} else if (step.type === "delay") {
|
|
306
|
+
const ms = step.options?.ms || 0;
|
|
307
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
308
|
+
} else if (step.type === "delayUntil" && step.condition) {
|
|
309
|
+
const targetTime = await (step.condition as any)(context);
|
|
310
|
+
if (typeof targetTime === "number") {
|
|
311
|
+
const now = Date.now();
|
|
312
|
+
const delay = Math.max(0, targetTime - now);
|
|
313
|
+
if (delay > 0) {
|
|
314
|
+
// For long delays, we should suspend execution.
|
|
315
|
+
// Here we just sleep.
|
|
316
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else if (step.type === "wait") {
|
|
320
|
+
// waitForApproval / waitForEvent
|
|
321
|
+
// Need a mechanism to suspend/resume
|
|
322
|
+
// Currently implementing as simple sleep/mock
|
|
323
|
+
if (step.options?.timeout) {
|
|
324
|
+
await new Promise((r) => setTimeout(r, step.options.timeout));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async compensate(
|
|
331
|
+
executedSagas: StepDefinition[],
|
|
332
|
+
context: WorkflowContext<T>
|
|
333
|
+
) {
|
|
334
|
+
for (let i = executedSagas.length - 1; i >= 0; i--) {
|
|
335
|
+
const step = executedSagas[i];
|
|
336
|
+
if (step.compensate) {
|
|
337
|
+
try {
|
|
338
|
+
await step.compensate(context);
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error(`Compensation failed for step ${step.name}`, err);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async getExecution(executionId: string): Promise<WorkflowExecution> {
|
|
347
|
+
const execution = await this.storage.get(executionId);
|
|
348
|
+
if (!execution) {
|
|
349
|
+
// Fallback to adapter
|
|
350
|
+
const adapterExecution =
|
|
351
|
+
await this.adapter.loadWorkflowState(executionId);
|
|
352
|
+
if (!adapterExecution) {
|
|
353
|
+
throw new Error(`Execution ${executionId} not found`);
|
|
354
|
+
}
|
|
355
|
+
return adapterExecution;
|
|
356
|
+
}
|
|
357
|
+
return execution;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async listExecutions(options?: ListOptions): Promise<WorkflowExecution[]> {
|
|
361
|
+
return this.storage.list(this.id, options);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async cancelExecution(executionId: string): Promise<void> {
|
|
365
|
+
const execution = await this.storage.get(executionId);
|
|
366
|
+
if (!execution) {
|
|
367
|
+
throw new Error(`Execution ${executionId} not found`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (
|
|
371
|
+
execution.status === "completed" ||
|
|
372
|
+
execution.status === "failed" ||
|
|
373
|
+
execution.status === "cancelled"
|
|
374
|
+
) {
|
|
375
|
+
throw new Error(`Cannot cancel execution in ${execution.status} state`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await this.storage.updateStatus(executionId, "cancelled");
|
|
379
|
+
await this.logEvent(executionId, "execution.cancelled", {});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async retryExecution(executionId: string): Promise<WorkflowExecution> {
|
|
383
|
+
const execution = await this.storage.get(executionId);
|
|
384
|
+
if (!execution) {
|
|
385
|
+
throw new Error(`Execution ${executionId} not found`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (execution.status !== "failed") {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`Can only retry failed executions, current status: ${execution.status}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Create new execution with same input
|
|
395
|
+
return this.execute(execution.input);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getExecutionHistory(executionId: string): Promise<WorkflowEvent[]> {
|
|
399
|
+
const events = await this.eventLog.getAggregateEvents(executionId);
|
|
400
|
+
return events.map((e) => ({
|
|
401
|
+
timestamp: e.timestamp,
|
|
402
|
+
type: e.type,
|
|
403
|
+
step: e.data.step,
|
|
404
|
+
data: e.data,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async getMetrics(): Promise<WorkflowMetrics> {
|
|
409
|
+
const successRate =
|
|
410
|
+
this.executionCount > 0
|
|
411
|
+
? (this.successCount / this.executionCount) * 100
|
|
412
|
+
: 0;
|
|
413
|
+
const avgDuration =
|
|
414
|
+
this.executionCount > 0 ? this.totalDuration / this.executionCount : 0;
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
totalExecutions: this.executionCount,
|
|
418
|
+
successRate,
|
|
419
|
+
avgDuration,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|