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.
Files changed (59) hide show
  1. package/dist/index.d.mts +1305 -0
  2. package/dist/index.d.ts +1305 -0
  3. package/dist/index.js +3180 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/index.mjs +3088 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/docs/API.md +801 -0
  8. package/docs/USAGE.md +619 -0
  9. package/package.json +75 -0
  10. package/src/adapters/base.ts +46 -0
  11. package/src/adapters/memory.ts +183 -0
  12. package/src/adapters/postgres/index.ts +383 -0
  13. package/src/adapters/postgres/postgres.test.ts +100 -0
  14. package/src/adapters/postgres/schema.ts +110 -0
  15. package/src/adapters/redis.test.ts +124 -0
  16. package/src/adapters/redis.ts +331 -0
  17. package/src/core/flow-fn.test.ts +70 -0
  18. package/src/core/flow-fn.ts +198 -0
  19. package/src/core/metrics.ts +198 -0
  20. package/src/core/scheduler.test.ts +80 -0
  21. package/src/core/scheduler.ts +154 -0
  22. package/src/index.ts +57 -0
  23. package/src/monitoring/health.ts +261 -0
  24. package/src/patterns/backoff.ts +30 -0
  25. package/src/patterns/batching.ts +248 -0
  26. package/src/patterns/circuit-breaker.test.ts +52 -0
  27. package/src/patterns/circuit-breaker.ts +52 -0
  28. package/src/patterns/priority.ts +146 -0
  29. package/src/patterns/rate-limit.ts +290 -0
  30. package/src/patterns/retry.test.ts +62 -0
  31. package/src/queue/batch.test.ts +35 -0
  32. package/src/queue/dependencies.test.ts +33 -0
  33. package/src/queue/dlq.ts +222 -0
  34. package/src/queue/job.ts +67 -0
  35. package/src/queue/queue.ts +243 -0
  36. package/src/queue/types.ts +153 -0
  37. package/src/queue/worker.ts +66 -0
  38. package/src/storage/event-log.ts +205 -0
  39. package/src/storage/job-storage.ts +206 -0
  40. package/src/storage/workflow-storage.ts +182 -0
  41. package/src/stream/stream.ts +194 -0
  42. package/src/stream/types.ts +81 -0
  43. package/src/utils/hashing.ts +29 -0
  44. package/src/utils/id-generator.ts +109 -0
  45. package/src/utils/serialization.ts +142 -0
  46. package/src/utils/time.ts +167 -0
  47. package/src/workflow/advanced.test.ts +43 -0
  48. package/src/workflow/events.test.ts +39 -0
  49. package/src/workflow/types.ts +132 -0
  50. package/src/workflow/workflow.test.ts +55 -0
  51. package/src/workflow/workflow.ts +422 -0
  52. package/tests/dlq.test.ts +205 -0
  53. package/tests/health.test.ts +228 -0
  54. package/tests/integration.test.ts +253 -0
  55. package/tests/stream.test.ts +233 -0
  56. package/tests/workflow.test.ts +286 -0
  57. package/tsconfig.json +17 -0
  58. package/tsup.config.ts +10 -0
  59. 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
+ });