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,46 @@
|
|
|
1
|
+
import { Job, QueueStats } from "../queue/types.js";
|
|
2
|
+
import {
|
|
3
|
+
Message,
|
|
4
|
+
MessageHandler,
|
|
5
|
+
Subscription,
|
|
6
|
+
StreamInfo,
|
|
7
|
+
} from "../stream/types.js";
|
|
8
|
+
import { WorkflowExecution } from "../workflow/types.js";
|
|
9
|
+
|
|
10
|
+
export interface FlowAdapter {
|
|
11
|
+
// Queue operations
|
|
12
|
+
enqueue(queue: string, job: Job): Promise<string>;
|
|
13
|
+
dequeue(queue: string): Promise<Job | null>;
|
|
14
|
+
ack(queue: string, jobId: string): Promise<void>;
|
|
15
|
+
nack(queue: string, jobId: string, requeue?: boolean): Promise<void>;
|
|
16
|
+
|
|
17
|
+
// Stream operations
|
|
18
|
+
publish(stream: string, message: Message): Promise<string>;
|
|
19
|
+
subscribe(
|
|
20
|
+
stream: string,
|
|
21
|
+
handler: MessageHandler<any>
|
|
22
|
+
): Promise<Subscription>;
|
|
23
|
+
consume(
|
|
24
|
+
stream: string,
|
|
25
|
+
group: string,
|
|
26
|
+
consumer: string,
|
|
27
|
+
handler: MessageHandler<any>
|
|
28
|
+
): Promise<Subscription>;
|
|
29
|
+
createConsumerGroup(stream: string, group: string): Promise<void>;
|
|
30
|
+
|
|
31
|
+
// Workflow operations
|
|
32
|
+
saveWorkflowState(
|
|
33
|
+
workflowId: string,
|
|
34
|
+
state: WorkflowExecution
|
|
35
|
+
): Promise<void>;
|
|
36
|
+
loadWorkflowState(workflowId: string): Promise<WorkflowExecution | null>;
|
|
37
|
+
|
|
38
|
+
// Management
|
|
39
|
+
getJob(queue: string, jobId: string): Promise<Job | null>;
|
|
40
|
+
getJobs(queue: string, status: string): Promise<Job[]>;
|
|
41
|
+
getAllJobs(queue: string): Promise<Job[]>;
|
|
42
|
+
cleanJobs(queue: string, grace: number, status: string): Promise<number>;
|
|
43
|
+
getQueueStats(queue: string): Promise<QueueStats>;
|
|
44
|
+
getStreamInfo(stream: string): Promise<StreamInfo>;
|
|
45
|
+
cleanup(): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { FlowAdapter } from "./base.js";
|
|
2
|
+
import { Job, QueueStats } from "../queue/types.js";
|
|
3
|
+
import {
|
|
4
|
+
Message,
|
|
5
|
+
MessageHandler,
|
|
6
|
+
Subscription,
|
|
7
|
+
StreamInfo,
|
|
8
|
+
} from "../stream/types.js";
|
|
9
|
+
import { WorkflowExecution } from "../workflow/types.js";
|
|
10
|
+
import { EventEmitter } from "eventemitter3";
|
|
11
|
+
|
|
12
|
+
export class MemoryAdapter implements FlowAdapter {
|
|
13
|
+
private queues: Map<string, Job[]> = new Map();
|
|
14
|
+
private allJobs: Map<string, Job> = new Map();
|
|
15
|
+
private streams: Map<string, Message[]> = new Map();
|
|
16
|
+
private streamEmitters: Map<string, EventEmitter> = new Map();
|
|
17
|
+
private workflowStates: Map<string, WorkflowExecution> = new Map();
|
|
18
|
+
|
|
19
|
+
async enqueue(queueName: string, job: Job): Promise<string> {
|
|
20
|
+
if (!this.queues.has(queueName)) {
|
|
21
|
+
this.queues.set(queueName, []);
|
|
22
|
+
}
|
|
23
|
+
const queue = this.queues.get(queueName)!;
|
|
24
|
+
queue.push(job);
|
|
25
|
+
this.allJobs.set(job.id, job);
|
|
26
|
+
return job.id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async dequeue(queueName: string): Promise<Job | null> {
|
|
30
|
+
const queue = this.queues.get(queueName);
|
|
31
|
+
if (!queue || queue.length === 0) return null;
|
|
32
|
+
return queue.shift() || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async ack(queue: string, jobId: string): Promise<void> {
|
|
36
|
+
const job = this.allJobs.get(jobId);
|
|
37
|
+
if (job) {
|
|
38
|
+
job.state = "completed";
|
|
39
|
+
job.finishedOn = Date.now();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async nack(
|
|
44
|
+
queueName: string,
|
|
45
|
+
jobId: string,
|
|
46
|
+
requeue: boolean = true
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const job = this.allJobs.get(jobId);
|
|
49
|
+
if (job && requeue) {
|
|
50
|
+
job.state = "waiting";
|
|
51
|
+
const queue = this.queues.get(queueName);
|
|
52
|
+
if (queue) queue.push(job);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getJob(queueName: string, jobId: string): Promise<Job | null> {
|
|
57
|
+
return this.allJobs.get(jobId) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getJobs(queue: string, status: string): Promise<Job[]> {
|
|
61
|
+
return Array.from(this.allJobs.values()).filter(
|
|
62
|
+
(job) => job.state === status
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getAllJobs(queue: string): Promise<Job[]> {
|
|
67
|
+
return Array.from(this.allJobs.values());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async cleanJobs(
|
|
71
|
+
queue: string,
|
|
72
|
+
grace: number,
|
|
73
|
+
status: string
|
|
74
|
+
): Promise<number> {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const jobsToClean: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const [id, job] of this.allJobs.entries()) {
|
|
79
|
+
if (job.state === status) {
|
|
80
|
+
const timestamp = job.finishedOn || job.timestamp;
|
|
81
|
+
if (now - timestamp > grace) {
|
|
82
|
+
jobsToClean.push(id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const id of jobsToClean) {
|
|
88
|
+
this.allJobs.delete(id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return jobsToClean.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async publish(streamName: string, message: Message): Promise<string> {
|
|
95
|
+
if (!this.streams.has(streamName)) {
|
|
96
|
+
this.streams.set(streamName, []);
|
|
97
|
+
this.streamEmitters.set(streamName, new EventEmitter());
|
|
98
|
+
}
|
|
99
|
+
const stream = this.streams.get(streamName)!;
|
|
100
|
+
stream.push(message);
|
|
101
|
+
this.streamEmitters.get(streamName)!.emit("message", message);
|
|
102
|
+
return message.id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async subscribe(
|
|
106
|
+
streamName: string,
|
|
107
|
+
handler: MessageHandler<any>
|
|
108
|
+
): Promise<Subscription> {
|
|
109
|
+
if (!this.streamEmitters.has(streamName)) {
|
|
110
|
+
this.streamEmitters.set(streamName, new EventEmitter());
|
|
111
|
+
this.streams.set(streamName, []);
|
|
112
|
+
}
|
|
113
|
+
const emitter = this.streamEmitters.get(streamName)!;
|
|
114
|
+
const wrapper = (msg: Message) => {
|
|
115
|
+
handler(msg).catch((err) => console.error("Stream handler error", err));
|
|
116
|
+
};
|
|
117
|
+
emitter.on("message", wrapper);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
unsubscribe: async () => {
|
|
121
|
+
emitter.off("message", wrapper);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async consume(
|
|
127
|
+
stream: string,
|
|
128
|
+
group: string,
|
|
129
|
+
consumer: string,
|
|
130
|
+
handler: MessageHandler<any>
|
|
131
|
+
): Promise<Subscription> {
|
|
132
|
+
return this.subscribe(stream, handler);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async createConsumerGroup(stream: string, group: string): Promise<void> {
|
|
136
|
+
// No-op for memory
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async saveWorkflowState(
|
|
140
|
+
workflowId: string,
|
|
141
|
+
state: WorkflowExecution
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
this.workflowStates.set(workflowId, state);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async loadWorkflowState(
|
|
147
|
+
workflowId: string
|
|
148
|
+
): Promise<WorkflowExecution | null> {
|
|
149
|
+
return this.workflowStates.get(workflowId) || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getQueueStats(queueName: string): Promise<QueueStats> {
|
|
153
|
+
const length = this.queues.get(queueName)?.length || 0;
|
|
154
|
+
const allInQueue = Array.from(this.allJobs.values()).filter(
|
|
155
|
+
(j) => j.state === "completed"
|
|
156
|
+
).length;
|
|
157
|
+
return {
|
|
158
|
+
waiting: length,
|
|
159
|
+
active: 0,
|
|
160
|
+
completed: allInQueue,
|
|
161
|
+
failed: 0,
|
|
162
|
+
delayed: 0,
|
|
163
|
+
paused: 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getStreamInfo(streamName: string): Promise<StreamInfo> {
|
|
168
|
+
const length = this.streams.get(streamName)?.length || 0;
|
|
169
|
+
return {
|
|
170
|
+
name: streamName,
|
|
171
|
+
length,
|
|
172
|
+
groups: 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async cleanup(): Promise<void> {
|
|
177
|
+
this.queues.clear();
|
|
178
|
+
this.allJobs.clear();
|
|
179
|
+
this.streams.clear();
|
|
180
|
+
this.workflowStates.clear();
|
|
181
|
+
this.streamEmitters.clear();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { FlowAdapter } from "../base.js";
|
|
2
|
+
import { Job, QueueStats } from "../../queue/types.js";
|
|
3
|
+
import {
|
|
4
|
+
Message,
|
|
5
|
+
MessageHandler,
|
|
6
|
+
Subscription,
|
|
7
|
+
StreamInfo,
|
|
8
|
+
} from "../../stream/types.js";
|
|
9
|
+
import { WorkflowExecution } from "../../workflow/types.js";
|
|
10
|
+
import {
|
|
11
|
+
jobs,
|
|
12
|
+
messages,
|
|
13
|
+
workflowExecutions,
|
|
14
|
+
consumerGroups,
|
|
15
|
+
} from "./schema.js";
|
|
16
|
+
import { eq, and, lt, asc, sql } from "drizzle-orm";
|
|
17
|
+
import { v4 as uuidv4 } from "uuid";
|
|
18
|
+
|
|
19
|
+
export interface PostgresAdapterOptions {
|
|
20
|
+
db: any; // Drizzle instance
|
|
21
|
+
schema?: string;
|
|
22
|
+
pollInterval?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PostgresAdapter implements FlowAdapter {
|
|
26
|
+
private db: any;
|
|
27
|
+
private pollInterval: number;
|
|
28
|
+
private activeSubscriptions: Map<string, boolean> = new Map();
|
|
29
|
+
|
|
30
|
+
constructor(options: PostgresAdapterOptions) {
|
|
31
|
+
this.db = options.db;
|
|
32
|
+
this.pollInterval = options.pollInterval || 1000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async enqueue(queueName: string, job: Job): Promise<string> {
|
|
36
|
+
await this.db.insert(jobs).values({
|
|
37
|
+
id: job.id,
|
|
38
|
+
queue: queueName,
|
|
39
|
+
name: job.name,
|
|
40
|
+
data: job.data,
|
|
41
|
+
opts: job.opts,
|
|
42
|
+
state: job.opts.delay ? "delayed" : "waiting",
|
|
43
|
+
timestamp: job.timestamp,
|
|
44
|
+
delay: job.opts.delay || 0,
|
|
45
|
+
priority: job.opts.priority || 0,
|
|
46
|
+
attemptsMade: 0,
|
|
47
|
+
});
|
|
48
|
+
return job.id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async dequeue(queueName: string): Promise<Job | null> {
|
|
52
|
+
// Attempt SKIP LOCKED if supported (Postgres)
|
|
53
|
+
// Drizzle doesn't have a standardized "for update skip locked" across all dialects yet in generic query builder,
|
|
54
|
+
// but assuming Postgres dialect here as per name.
|
|
55
|
+
|
|
56
|
+
// We want to find a job that is 'waiting' OR ('delayed' AND timestamp + delay <= now)
|
|
57
|
+
// And mark it 'active' atomically.
|
|
58
|
+
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
return await this.db.transaction(async (tx: any) => {
|
|
62
|
+
// This is a raw SQL approach for Postgres SKIP LOCKED as Drizzle query builder support varies
|
|
63
|
+
// Assuming 'jobs' table is 'flowfn_jobs'
|
|
64
|
+
|
|
65
|
+
// 1. Check for delayed jobs that are ready
|
|
66
|
+
// (In a real high-perf system, a background process might move delayed -> waiting)
|
|
67
|
+
// For simplicity, we check both here.
|
|
68
|
+
|
|
69
|
+
const result = await tx.execute(sql`
|
|
70
|
+
UPDATE flowfn_jobs
|
|
71
|
+
SET state = 'active', processed_on = ${now}
|
|
72
|
+
WHERE id = (
|
|
73
|
+
SELECT id
|
|
74
|
+
FROM flowfn_jobs
|
|
75
|
+
WHERE queue = ${queueName}
|
|
76
|
+
AND (
|
|
77
|
+
state = 'waiting'
|
|
78
|
+
OR (state = 'delayed' AND (timestamp + delay) <= ${now})
|
|
79
|
+
)
|
|
80
|
+
ORDER BY priority DESC, timestamp ASC
|
|
81
|
+
FOR UPDATE SKIP LOCKED
|
|
82
|
+
LIMIT 1
|
|
83
|
+
)
|
|
84
|
+
RETURNING *
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
if (result.length === 0) return null;
|
|
88
|
+
|
|
89
|
+
const row = result[0];
|
|
90
|
+
// Normalize back to Job object
|
|
91
|
+
return {
|
|
92
|
+
id: row.id,
|
|
93
|
+
name: row.name,
|
|
94
|
+
data: row.data,
|
|
95
|
+
opts: row.opts,
|
|
96
|
+
state: row.state,
|
|
97
|
+
progress: row.progress,
|
|
98
|
+
returnvalue: row.return_value,
|
|
99
|
+
timestamp: Number(row.timestamp),
|
|
100
|
+
processedOn: Number(row.processed_on),
|
|
101
|
+
finishedOn: Number(row.finished_on),
|
|
102
|
+
delay: Number(row.delay),
|
|
103
|
+
attemptsMade: row.attempts_made,
|
|
104
|
+
failedReason: row.failed_reason,
|
|
105
|
+
stacktrace: row.stacktrace,
|
|
106
|
+
// Re-bind methods in queue implementation
|
|
107
|
+
} as unknown as Job;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async ack(queue: string, jobId: string): Promise<void> {
|
|
112
|
+
// Usually mark as completed or delete.
|
|
113
|
+
// If removeOnComplete, delete.
|
|
114
|
+
// For now, mark completed.
|
|
115
|
+
await this.db
|
|
116
|
+
.update(jobs)
|
|
117
|
+
.set({ state: "completed", finishedOn: Date.now() })
|
|
118
|
+
.where(eq(jobs.id, jobId));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async nack(
|
|
122
|
+
queue: string,
|
|
123
|
+
jobId: string,
|
|
124
|
+
requeue: boolean = true
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
if (requeue) {
|
|
127
|
+
await this.db
|
|
128
|
+
.update(jobs)
|
|
129
|
+
.set({ state: "waiting", processedOn: null }) // Reset for retry
|
|
130
|
+
.where(eq(jobs.id, jobId));
|
|
131
|
+
} else {
|
|
132
|
+
// Leave as active? Or failed?
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async publish(streamName: string, message: Message): Promise<string> {
|
|
137
|
+
await this.db.insert(messages).values({
|
|
138
|
+
id: message.id,
|
|
139
|
+
stream: streamName,
|
|
140
|
+
data: message.data,
|
|
141
|
+
headers: message.headers,
|
|
142
|
+
timestamp: message.timestamp,
|
|
143
|
+
partition: message.partition,
|
|
144
|
+
key: message.key,
|
|
145
|
+
});
|
|
146
|
+
return message.id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async subscribe(
|
|
150
|
+
streamName: string,
|
|
151
|
+
handler: MessageHandler<any>
|
|
152
|
+
): Promise<Subscription> {
|
|
153
|
+
// Polling implementation for streams
|
|
154
|
+
// Real postgres could use LISTEN/NOTIFY
|
|
155
|
+
|
|
156
|
+
const subId = uuidv4();
|
|
157
|
+
this.activeSubscriptions.set(subId, true);
|
|
158
|
+
|
|
159
|
+
let lastTimestamp = Date.now(); // Start from now
|
|
160
|
+
|
|
161
|
+
const poll = async () => {
|
|
162
|
+
if (!this.activeSubscriptions.get(subId)) return;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const msgs = await this.db
|
|
166
|
+
.select()
|
|
167
|
+
.from(messages)
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(messages.stream, streamName),
|
|
171
|
+
sql`${messages.timestamp} > ${lastTimestamp}`
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
.orderBy(asc(messages.timestamp))
|
|
175
|
+
.limit(100);
|
|
176
|
+
|
|
177
|
+
for (const row of msgs) {
|
|
178
|
+
lastTimestamp = Math.max(lastTimestamp, Number(row.timestamp));
|
|
179
|
+
|
|
180
|
+
const msg: Message = {
|
|
181
|
+
id: row.id,
|
|
182
|
+
stream: row.stream,
|
|
183
|
+
data: row.data,
|
|
184
|
+
headers: row.headers as any,
|
|
185
|
+
timestamp: Number(row.timestamp),
|
|
186
|
+
ack: async () => {},
|
|
187
|
+
nack: async () => {},
|
|
188
|
+
};
|
|
189
|
+
handler(msg).catch(console.error);
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.error("Postgres stream poll error", e);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (this.activeSubscriptions.get(subId)) {
|
|
196
|
+
setTimeout(poll, this.pollInterval);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
setTimeout(poll, 0);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
unsubscribe: async () => {
|
|
204
|
+
this.activeSubscriptions.set(subId, false);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async createConsumerGroup(stream: string, group: string): Promise<void> {
|
|
210
|
+
// Manage in DB
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async saveWorkflowState(
|
|
214
|
+
workflowId: string,
|
|
215
|
+
state: WorkflowExecution
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
// Upsert execution
|
|
218
|
+
await this.db
|
|
219
|
+
.insert(workflowExecutions)
|
|
220
|
+
.values({
|
|
221
|
+
id: state.id,
|
|
222
|
+
workflowId: state.workflowId,
|
|
223
|
+
status: state.status,
|
|
224
|
+
input: state.input,
|
|
225
|
+
output: state.output,
|
|
226
|
+
error:
|
|
227
|
+
state.error instanceof Error
|
|
228
|
+
? state.error.message
|
|
229
|
+
: String(state.error),
|
|
230
|
+
startedAt: state.startedAt,
|
|
231
|
+
completedAt: state.completedAt,
|
|
232
|
+
})
|
|
233
|
+
.onConflictDoUpdate({
|
|
234
|
+
target: workflowExecutions.id,
|
|
235
|
+
set: {
|
|
236
|
+
status: state.status,
|
|
237
|
+
output: state.output,
|
|
238
|
+
error:
|
|
239
|
+
state.error instanceof Error
|
|
240
|
+
? state.error.message
|
|
241
|
+
: String(state.error),
|
|
242
|
+
updatedAt: Date.now(),
|
|
243
|
+
completedAt: state.completedAt,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async loadWorkflowState(
|
|
249
|
+
executionId: string
|
|
250
|
+
): Promise<WorkflowExecution | null> {
|
|
251
|
+
const rows = await this.db
|
|
252
|
+
.select()
|
|
253
|
+
.from(workflowExecutions)
|
|
254
|
+
.where(eq(workflowExecutions.id, executionId));
|
|
255
|
+
if (rows.length === 0) return null;
|
|
256
|
+
const row = rows[0];
|
|
257
|
+
return {
|
|
258
|
+
id: row.id,
|
|
259
|
+
workflowId: row.workflowId,
|
|
260
|
+
status: row.status as any, // Cast from DB string to union type
|
|
261
|
+
input: row.input,
|
|
262
|
+
output: row.output,
|
|
263
|
+
error: row.error,
|
|
264
|
+
startedAt: Number(row.startedAt),
|
|
265
|
+
completedAt: row.completedAt ? Number(row.completedAt) : undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getJob(queue: string, jobId: string): Promise<Job | null> {
|
|
270
|
+
const rows = await this.db
|
|
271
|
+
.select()
|
|
272
|
+
.from(jobs)
|
|
273
|
+
.where(and(eq(jobs.queue, queue), eq(jobs.id, jobId)));
|
|
274
|
+
if (rows.length === 0) return null;
|
|
275
|
+
const row = rows[0];
|
|
276
|
+
return {
|
|
277
|
+
id: row.id,
|
|
278
|
+
name: row.name,
|
|
279
|
+
data: row.data,
|
|
280
|
+
opts: row.opts,
|
|
281
|
+
state: row.state,
|
|
282
|
+
timestamp: Number(row.timestamp),
|
|
283
|
+
attemptsMade: row.attempts_made,
|
|
284
|
+
} as any;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async getQueueStats(queueName: string): Promise<QueueStats> {
|
|
288
|
+
const counts = await this.db
|
|
289
|
+
.select({
|
|
290
|
+
state: jobs.state,
|
|
291
|
+
count: sql<number>`count(*)`,
|
|
292
|
+
})
|
|
293
|
+
.from(jobs)
|
|
294
|
+
.where(eq(jobs.queue, queueName))
|
|
295
|
+
.groupBy(jobs.state);
|
|
296
|
+
|
|
297
|
+
const stats: QueueStats = {
|
|
298
|
+
waiting: 0,
|
|
299
|
+
active: 0,
|
|
300
|
+
completed: 0,
|
|
301
|
+
failed: 0,
|
|
302
|
+
delayed: 0,
|
|
303
|
+
paused: 0,
|
|
304
|
+
};
|
|
305
|
+
for (const c of counts) {
|
|
306
|
+
if (c.state in stats) {
|
|
307
|
+
(stats as any)[c.state] = Number(c.count);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return stats;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async getStreamInfo(streamName: string): Promise<StreamInfo> {
|
|
314
|
+
return { name: streamName, length: 0, groups: 0 };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async consume(
|
|
318
|
+
stream: string,
|
|
319
|
+
group: string,
|
|
320
|
+
consumer: string,
|
|
321
|
+
handler: MessageHandler<any>
|
|
322
|
+
): Promise<Subscription> {
|
|
323
|
+
// Simplified consumer group implementation
|
|
324
|
+
return this.subscribe(stream, handler);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async getJobs(queue: string, status: string): Promise<Job[]> {
|
|
328
|
+
const rows = await this.db
|
|
329
|
+
.select()
|
|
330
|
+
.from(jobs)
|
|
331
|
+
.where(and(eq(jobs.queue, queue), eq(jobs.state, status)));
|
|
332
|
+
return rows.map(
|
|
333
|
+
(row: any) =>
|
|
334
|
+
({
|
|
335
|
+
id: row.id,
|
|
336
|
+
name: row.name,
|
|
337
|
+
data: row.data,
|
|
338
|
+
opts: row.opts,
|
|
339
|
+
state: row.state,
|
|
340
|
+
timestamp: Number(row.timestamp),
|
|
341
|
+
attemptsMade: row.attempts_made,
|
|
342
|
+
}) as any
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async getAllJobs(queue: string): Promise<Job[]> {
|
|
347
|
+
const rows = await this.db.select().from(jobs).where(eq(jobs.queue, queue));
|
|
348
|
+
return rows.map(
|
|
349
|
+
(row: any) =>
|
|
350
|
+
({
|
|
351
|
+
id: row.id,
|
|
352
|
+
name: row.name,
|
|
353
|
+
data: row.data,
|
|
354
|
+
opts: row.opts,
|
|
355
|
+
state: row.state,
|
|
356
|
+
timestamp: Number(row.timestamp),
|
|
357
|
+
attemptsMade: row.attempts_made,
|
|
358
|
+
}) as any
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async cleanJobs(
|
|
363
|
+
queue: string,
|
|
364
|
+
grace: number,
|
|
365
|
+
status: string
|
|
366
|
+
): Promise<number> {
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
const result = await this.db
|
|
369
|
+
.delete(jobs)
|
|
370
|
+
.where(
|
|
371
|
+
and(
|
|
372
|
+
eq(jobs.queue, queue),
|
|
373
|
+
eq(jobs.state, status),
|
|
374
|
+
sql`${jobs.finishedOn} < ${now - grace}`
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
return result.rowCount || 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async cleanup(): Promise<void> {
|
|
381
|
+
this.activeSubscriptions.clear();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { PostgresAdapter } from './index.js';
|
|
3
|
+
import { JobImpl } from '../../queue/job.js';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { jobs, messages, workflowExecutions } from './schema.js';
|
|
7
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
8
|
+
import { sql } from 'drizzle-orm';
|
|
9
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
10
|
+
|
|
11
|
+
// We need to verify compatibility. PostgresAdapter uses Postgres specific SQL (SKIP LOCKED).
|
|
12
|
+
// Testing with SQLite won't work for enqueue/dequeue if we use raw SQL SKIP LOCKED.
|
|
13
|
+
// However, for unit testing logic without a real Postgres, we might need to mock db.execute
|
|
14
|
+
// OR implement a fallback in the adapter for non-Postgres (which we kind of did but with raw SQL).
|
|
15
|
+
|
|
16
|
+
// Since we can't easily spin up Postgres in this environment without docker,
|
|
17
|
+
// and the adapter has hardcoded Postgres SQL for dequeue,
|
|
18
|
+
// we will Mock the DB instance.
|
|
19
|
+
|
|
20
|
+
describe('PostgresAdapter', () => {
|
|
21
|
+
let adapter: PostgresAdapter;
|
|
22
|
+
let mockDb: any;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockDb = {
|
|
26
|
+
insert: () => ({ values: () => Promise.resolve() }), // mock insert
|
|
27
|
+
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
|
|
28
|
+
select: () => ({ from: () => ({ where: () => ({ groupBy: () => Promise.resolve([]), orderBy: () => ({ limit: () => Promise.resolve([]) }) }), limit: () => Promise.resolve([]) }) }), // mock select chain
|
|
29
|
+
transaction: async (cb: any) => cb(mockDb),
|
|
30
|
+
execute: async () => [],
|
|
31
|
+
};
|
|
32
|
+
adapter = new PostgresAdapter({ db: mockDb });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should enqueue a job', async () => {
|
|
36
|
+
// Just verify it calls insert
|
|
37
|
+
let inserted = false;
|
|
38
|
+
mockDb.insert = (table) => {
|
|
39
|
+
return {
|
|
40
|
+
values: (vals) => {
|
|
41
|
+
inserted = true;
|
|
42
|
+
expect(vals.queue).toBe('test-queue');
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const job = new JobImpl('test-job', { data: 123 });
|
|
49
|
+
await adapter.enqueue('test-queue', job);
|
|
50
|
+
expect(inserted).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should dequeue a job (simulated)', async () => {
|
|
54
|
+
// Mock execute to return a job
|
|
55
|
+
mockDb.execute = async (query) => {
|
|
56
|
+
// Check if it's the dequeue query
|
|
57
|
+
// Drizzle sql`` returns an object with sql/params or just strings array
|
|
58
|
+
// The exact structure depends on implementation but for mocking we check what we get
|
|
59
|
+
const sqlStr = query.sql || (query.strings ? query.strings.join('') : String(query));
|
|
60
|
+
|
|
61
|
+
if (sqlStr.includes('UPDATE flowfn_jobs') || JSON.stringify(query).includes('UPDATE flowfn_jobs')) {
|
|
62
|
+
return [{
|
|
63
|
+
id: 'job-1',
|
|
64
|
+
queue: 'test-queue',
|
|
65
|
+
name: 'test-job',
|
|
66
|
+
data: { data: 123 },
|
|
67
|
+
opts: {},
|
|
68
|
+
state: 'active',
|
|
69
|
+
timestamp: Date.now()
|
|
70
|
+
}];
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const job = await adapter.dequeue('test-queue');
|
|
76
|
+
expect(job).toBeDefined();
|
|
77
|
+
expect(job!.id).toBe('job-1');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should save workflow state', async () => {
|
|
81
|
+
let upserted = false;
|
|
82
|
+
mockDb.insert = () => ({
|
|
83
|
+
values: (v) => ({
|
|
84
|
+
onConflictDoUpdate: (opts) => {
|
|
85
|
+
upserted = true;
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await adapter.saveWorkflowState('wf-1', {
|
|
92
|
+
id: 'exec-1',
|
|
93
|
+
workflowId: 'wf-1',
|
|
94
|
+
status: 'running',
|
|
95
|
+
startedAt: Date.now()
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(upserted).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|