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,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Stream,
|
|
3
|
+
Message,
|
|
4
|
+
PublishOptions,
|
|
5
|
+
SubscribeOptions,
|
|
6
|
+
MessageHandler,
|
|
7
|
+
Subscription,
|
|
8
|
+
ConsumerOptions,
|
|
9
|
+
Consumer,
|
|
10
|
+
StreamInfo,
|
|
11
|
+
TrimStrategy,
|
|
12
|
+
StreamOptions,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import { FlowAdapter } from "../adapters/base.js";
|
|
15
|
+
import { v4 as uuidv4 } from "uuid";
|
|
16
|
+
|
|
17
|
+
export class StreamImpl<T = any> implements Stream<T> {
|
|
18
|
+
name: string;
|
|
19
|
+
private adapter: FlowAdapter;
|
|
20
|
+
private options: StreamOptions;
|
|
21
|
+
private messages: Map<string, Message<T>> = new Map();
|
|
22
|
+
|
|
23
|
+
constructor(name: string, adapter: FlowAdapter, options: StreamOptions = {}) {
|
|
24
|
+
this.name = name;
|
|
25
|
+
this.adapter = adapter;
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async publish(data: T, options?: PublishOptions): Promise<string> {
|
|
30
|
+
const message: Message<T> = {
|
|
31
|
+
id: uuidv4(),
|
|
32
|
+
stream: this.name,
|
|
33
|
+
data,
|
|
34
|
+
headers: options?.headers,
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
partition: options?.partition,
|
|
37
|
+
key: options?.key,
|
|
38
|
+
ack: async () => {},
|
|
39
|
+
nack: async () => {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Store message locally for retrieval
|
|
43
|
+
this.messages.set(message.id, message);
|
|
44
|
+
|
|
45
|
+
// Auto-trim if maxLength is set
|
|
46
|
+
if (this.options.maxLength && this.messages.size > this.options.maxLength) {
|
|
47
|
+
await this.trim({ maxLength: this.options.maxLength });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return this.adapter.publish(this.name, message);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async publishBatch(
|
|
54
|
+
messages: Array<{ data: T; options?: PublishOptions }>
|
|
55
|
+
): Promise<string[]> {
|
|
56
|
+
return Promise.all(messages.map((m) => this.publish(m.data, m.options)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async subscribe(
|
|
60
|
+
handler: MessageHandler<T>,
|
|
61
|
+
options?: SubscribeOptions
|
|
62
|
+
): Promise<Subscription> {
|
|
63
|
+
return this.adapter.subscribe(this.name, handler as any);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
createConsumer(consumerId: string, options: ConsumerOptions): Consumer<T> {
|
|
67
|
+
let subscription: Subscription | null = null;
|
|
68
|
+
let paused = false;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
subscribe: async (handler: MessageHandler<T>) => {
|
|
72
|
+
// If fromBeginning, replay existing messages first
|
|
73
|
+
if (options.fromBeginning && this.messages.size > 0) {
|
|
74
|
+
const sortedMessages = Array.from(this.messages.values()).sort(
|
|
75
|
+
(a, b) => a.timestamp - b.timestamp
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
for (const msg of sortedMessages) {
|
|
79
|
+
if (!paused) {
|
|
80
|
+
await handler(msg).catch(console.error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
subscription = await this.adapter.consume(
|
|
86
|
+
this.name,
|
|
87
|
+
options.groupId,
|
|
88
|
+
consumerId,
|
|
89
|
+
handler as any
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
pause: async () => {
|
|
93
|
+
paused = true;
|
|
94
|
+
},
|
|
95
|
+
resume: async () => {
|
|
96
|
+
paused = false;
|
|
97
|
+
},
|
|
98
|
+
close: async () => {
|
|
99
|
+
if (subscription) await subscription.unsubscribe();
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getInfo(): Promise<StreamInfo> {
|
|
105
|
+
const info = await this.adapter.getStreamInfo(this.name);
|
|
106
|
+
return {
|
|
107
|
+
...info,
|
|
108
|
+
length: this.messages.size,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async trim(strategy: TrimStrategy): Promise<number> {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const messagesToDelete: string[] = [];
|
|
115
|
+
|
|
116
|
+
if (strategy.maxLength) {
|
|
117
|
+
// Sort by timestamp and keep only the newest maxLength messages
|
|
118
|
+
const sortedMessages = Array.from(this.messages.entries()).sort(
|
|
119
|
+
(a, b) => b[1].timestamp - a[1].timestamp
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (sortedMessages.length > strategy.maxLength) {
|
|
123
|
+
const toRemove = sortedMessages.slice(strategy.maxLength);
|
|
124
|
+
messagesToDelete.push(...toRemove.map(([id]) => id));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (strategy.maxAgeSeconds) {
|
|
129
|
+
const maxAge = strategy.maxAgeSeconds * 1000;
|
|
130
|
+
for (const [id, message] of this.messages.entries()) {
|
|
131
|
+
if (now - message.timestamp > maxAge) {
|
|
132
|
+
messagesToDelete.push(id);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Remove duplicates
|
|
138
|
+
const uniqueToDelete = [...new Set(messagesToDelete)];
|
|
139
|
+
for (const id of uniqueToDelete) {
|
|
140
|
+
this.messages.delete(id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return uniqueToDelete.length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getMessages(
|
|
147
|
+
start: string,
|
|
148
|
+
end: string,
|
|
149
|
+
count?: number
|
|
150
|
+
): Promise<Message<T>[]> {
|
|
151
|
+
const allMessages = Array.from(this.messages.values()).sort(
|
|
152
|
+
(a, b) => a.timestamp - b.timestamp
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Filter by ID range (lexicographic comparison)
|
|
156
|
+
let filtered = allMessages.filter((m) => m.id >= start && m.id <= end);
|
|
157
|
+
|
|
158
|
+
// Apply count limit if specified
|
|
159
|
+
if (count !== undefined && count > 0) {
|
|
160
|
+
filtered = filtered.slice(0, count);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return filtered;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Replay messages from a specific timestamp
|
|
168
|
+
*/
|
|
169
|
+
async replay(
|
|
170
|
+
fromTimestamp: number,
|
|
171
|
+
handler: MessageHandler<T>
|
|
172
|
+
): Promise<number> {
|
|
173
|
+
const messages = Array.from(this.messages.values())
|
|
174
|
+
.filter((m) => m.timestamp >= fromTimestamp)
|
|
175
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
176
|
+
|
|
177
|
+
for (const message of messages) {
|
|
178
|
+
await handler(message);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return messages.length;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get message count
|
|
186
|
+
*/
|
|
187
|
+
getMessageCount(): number {
|
|
188
|
+
return this.messages.size;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async close(): Promise<void> {
|
|
192
|
+
this.messages.clear();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export interface StreamOptions {
|
|
2
|
+
maxLength?: number;
|
|
3
|
+
retention?: number;
|
|
4
|
+
partitions?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Stream<T = any> {
|
|
8
|
+
name: string;
|
|
9
|
+
|
|
10
|
+
// Publishing
|
|
11
|
+
publish(data: T, options?: PublishOptions): Promise<string>;
|
|
12
|
+
publishBatch(messages: Array<{ data: T; options?: PublishOptions }>): Promise<string[]>;
|
|
13
|
+
|
|
14
|
+
// Subscribing
|
|
15
|
+
subscribe(handler: MessageHandler<T>, options?: SubscribeOptions): Promise<Subscription>;
|
|
16
|
+
createConsumer(consumerId: string, options: ConsumerOptions): Consumer<T>;
|
|
17
|
+
|
|
18
|
+
// Management
|
|
19
|
+
getInfo(): Promise<StreamInfo>;
|
|
20
|
+
trim(strategy: TrimStrategy): Promise<number>;
|
|
21
|
+
getMessages(start: string, end: string, count?: number): Promise<Message<T>[]>;
|
|
22
|
+
|
|
23
|
+
// Lifecycle
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Message<T = any> {
|
|
28
|
+
id: string;
|
|
29
|
+
stream: string;
|
|
30
|
+
data: T;
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
partition?: number;
|
|
34
|
+
offset?: number;
|
|
35
|
+
key?: string;
|
|
36
|
+
|
|
37
|
+
ack(): Promise<void>;
|
|
38
|
+
nack(requeue?: boolean): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PublishOptions {
|
|
42
|
+
key?: string;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
partition?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SubscribeOptions {
|
|
48
|
+
// Basic subscription options if any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ConsumerOptions {
|
|
52
|
+
groupId: string;
|
|
53
|
+
fromBeginning?: boolean;
|
|
54
|
+
autoCommit?: boolean;
|
|
55
|
+
commitInterval?: number;
|
|
56
|
+
maxInFlight?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Consumer<T = any> {
|
|
60
|
+
subscribe(handler: MessageHandler<T>): Promise<void>;
|
|
61
|
+
pause(): Promise<void>;
|
|
62
|
+
resume(): Promise<void>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type MessageHandler<T> = (message: Message<T>) => Promise<void>;
|
|
67
|
+
|
|
68
|
+
export interface Subscription {
|
|
69
|
+
unsubscribe(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StreamInfo {
|
|
73
|
+
name: string;
|
|
74
|
+
length: number;
|
|
75
|
+
groups: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TrimStrategy {
|
|
79
|
+
maxLength?: number;
|
|
80
|
+
maxAgeSeconds?: number;
|
|
81
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a hash for job deduplication
|
|
5
|
+
*/
|
|
6
|
+
export function hashJob(data: any, options?: { algorithm?: string }): string {
|
|
7
|
+
const algorithm = options?.algorithm || "sha256";
|
|
8
|
+
const hash = createHash(algorithm);
|
|
9
|
+
|
|
10
|
+
// Normalize and stringify the data for consistent hashing
|
|
11
|
+
const normalized = JSON.stringify(data, Object.keys(data).sort());
|
|
12
|
+
hash.update(normalized);
|
|
13
|
+
|
|
14
|
+
return hash.digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a deduplication key from job name and data
|
|
19
|
+
*/
|
|
20
|
+
export function generateDeduplicationKey(name: string, data: any): string {
|
|
21
|
+
return `${name}:${hashJob(data)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if two job payloads are equivalent
|
|
26
|
+
*/
|
|
27
|
+
export function areJobsEquivalent(job1: any, job2: any): boolean {
|
|
28
|
+
return hashJob(job1) === hashJob(job2);
|
|
29
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { v4 as uuidv4, v5 as uuidv5, v1 as uuidv1 } from "uuid";
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ID generation strategies
|
|
6
|
+
*/
|
|
7
|
+
export type IdStrategy =
|
|
8
|
+
| "uuid-v4"
|
|
9
|
+
| "uuid-v5"
|
|
10
|
+
| "uuid-v1"
|
|
11
|
+
| "nanoid"
|
|
12
|
+
| "incremental"
|
|
13
|
+
| "custom";
|
|
14
|
+
|
|
15
|
+
export interface IdGeneratorOptions {
|
|
16
|
+
strategy?: IdStrategy;
|
|
17
|
+
namespace?: string; // For UUID v5
|
|
18
|
+
prefix?: string;
|
|
19
|
+
customGenerator?: () => string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a unique identifier based on the specified strategy
|
|
24
|
+
*/
|
|
25
|
+
export function generateId(options: IdGeneratorOptions = {}): string {
|
|
26
|
+
const {
|
|
27
|
+
strategy = "uuid-v4",
|
|
28
|
+
namespace,
|
|
29
|
+
prefix = "",
|
|
30
|
+
customGenerator,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
let id: string;
|
|
34
|
+
|
|
35
|
+
switch (strategy) {
|
|
36
|
+
case "uuid-v4":
|
|
37
|
+
id = uuidv4();
|
|
38
|
+
break;
|
|
39
|
+
|
|
40
|
+
case "uuid-v5":
|
|
41
|
+
if (!namespace) {
|
|
42
|
+
throw new Error("UUID v5 requires a namespace");
|
|
43
|
+
}
|
|
44
|
+
const name = `${Date.now()}-${randomBytes(8).toString("hex")}`;
|
|
45
|
+
id = uuidv5(name, namespace);
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case "uuid-v1":
|
|
49
|
+
id = uuidv1();
|
|
50
|
+
break;
|
|
51
|
+
|
|
52
|
+
case "nanoid":
|
|
53
|
+
// Simple nanoid-like implementation
|
|
54
|
+
id = randomBytes(16).toString("base64url");
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case "incremental":
|
|
58
|
+
// Simple timestamp-based incremental ID
|
|
59
|
+
id = `${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
case "custom":
|
|
63
|
+
if (!customGenerator) {
|
|
64
|
+
throw new Error("Custom strategy requires customGenerator function");
|
|
65
|
+
}
|
|
66
|
+
id = customGenerator();
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
default:
|
|
70
|
+
id = uuidv4();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return prefix ? `${prefix}${id}` : id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Generate a job ID with optional prefix
|
|
78
|
+
*/
|
|
79
|
+
export function generateJobId(prefix?: string): string {
|
|
80
|
+
return generateId({ prefix: prefix || "job_" });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate a workflow execution ID
|
|
85
|
+
*/
|
|
86
|
+
export function generateExecutionId(prefix?: string): string {
|
|
87
|
+
return generateId({ prefix: prefix || "exec_" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a message ID for streams
|
|
92
|
+
*/
|
|
93
|
+
export function generateMessageId(prefix?: string): string {
|
|
94
|
+
return generateId({ prefix: prefix || "msg_" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate a deterministic ID based on content (useful for idempotency)
|
|
99
|
+
*/
|
|
100
|
+
export function generateDeterministicId(
|
|
101
|
+
content: string | object,
|
|
102
|
+
prefix?: string
|
|
103
|
+
): string {
|
|
104
|
+
const hash = createHash("sha256");
|
|
105
|
+
const data = typeof content === "string" ? content : JSON.stringify(content);
|
|
106
|
+
hash.update(data);
|
|
107
|
+
const id = hash.digest("hex").slice(0, 32);
|
|
108
|
+
return prefix ? `${prefix}${id}` : id;
|
|
109
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization utilities for FlowFn
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SerializationOptions {
|
|
6
|
+
pretty?: boolean;
|
|
7
|
+
includeUndefined?: boolean;
|
|
8
|
+
dateFormat?: "iso" | "timestamp";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Serialize job data to JSON string
|
|
13
|
+
*/
|
|
14
|
+
export function serialize<T = any>(
|
|
15
|
+
data: T,
|
|
16
|
+
options: SerializationOptions = {}
|
|
17
|
+
): string {
|
|
18
|
+
const { pretty = false, dateFormat = "iso" } = options;
|
|
19
|
+
|
|
20
|
+
const replacer = (key: string, value: any) => {
|
|
21
|
+
// Handle dates
|
|
22
|
+
if (value instanceof Date) {
|
|
23
|
+
return dateFormat === "timestamp" ? value.getTime() : value.toISOString();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Handle undefined
|
|
27
|
+
if (value === undefined && !options.includeUndefined) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle functions (skip them)
|
|
32
|
+
if (typeof value === "function") {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle BigInt
|
|
37
|
+
if (typeof value === "bigint") {
|
|
38
|
+
return value.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return JSON.stringify(data, replacer, pretty ? 2 : undefined);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deserialize JSON string to object
|
|
49
|
+
*/
|
|
50
|
+
export function deserialize<T = any>(json: string): T {
|
|
51
|
+
return JSON.parse(json, (key, value) => {
|
|
52
|
+
// Try to parse ISO date strings
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
|
|
55
|
+
if (isoDateRegex.test(value)) {
|
|
56
|
+
return new Date(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Safely serialize data with circular reference handling
|
|
65
|
+
*/
|
|
66
|
+
export function serializeSafe<T = any>(
|
|
67
|
+
data: T,
|
|
68
|
+
options: SerializationOptions = {}
|
|
69
|
+
): string {
|
|
70
|
+
const seen = new WeakSet();
|
|
71
|
+
const { pretty = false } = options;
|
|
72
|
+
|
|
73
|
+
const replacer = (key: string, value: any) => {
|
|
74
|
+
if (typeof value === "object" && value !== null) {
|
|
75
|
+
if (seen.has(value)) {
|
|
76
|
+
return "[Circular]";
|
|
77
|
+
}
|
|
78
|
+
seen.add(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle dates
|
|
82
|
+
if (value instanceof Date) {
|
|
83
|
+
return value.toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle functions
|
|
87
|
+
if (typeof value === "function") {
|
|
88
|
+
return "[Function]";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle BigInt
|
|
92
|
+
if (typeof value === "bigint") {
|
|
93
|
+
return value.toString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return JSON.stringify(data, replacer, pretty ? 2 : undefined);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clone an object using serialization
|
|
104
|
+
*/
|
|
105
|
+
export function cloneViaSerialization<T>(obj: T): T {
|
|
106
|
+
return deserialize(serialize(obj));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Serialize with compression (base64 encode)
|
|
111
|
+
*/
|
|
112
|
+
export function serializeCompressed<T = any>(data: T): string {
|
|
113
|
+
const json = serialize(data);
|
|
114
|
+
return Buffer.from(json).toString("base64");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deserialize compressed data
|
|
119
|
+
*/
|
|
120
|
+
export function deserializeCompressed<T = any>(compressed: string): T {
|
|
121
|
+
const json = Buffer.from(compressed, "base64").toString("utf-8");
|
|
122
|
+
return deserialize(json);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if a value is serializable
|
|
127
|
+
*/
|
|
128
|
+
export function isSerializable(value: any): boolean {
|
|
129
|
+
try {
|
|
130
|
+
serialize(value);
|
|
131
|
+
return true;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get size of serialized data in bytes
|
|
139
|
+
*/
|
|
140
|
+
export function getSerializedSize(data: any): number {
|
|
141
|
+
return Buffer.byteLength(serialize(data), "utf-8");
|
|
142
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time utility functions for FlowFn
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Duration units
|
|
7
|
+
*/
|
|
8
|
+
export interface Duration {
|
|
9
|
+
milliseconds?: number;
|
|
10
|
+
seconds?: number;
|
|
11
|
+
minutes?: number;
|
|
12
|
+
hours?: number;
|
|
13
|
+
days?: number;
|
|
14
|
+
weeks?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert duration object to milliseconds
|
|
19
|
+
*/
|
|
20
|
+
export function toMilliseconds(duration: Duration): number {
|
|
21
|
+
let ms = 0;
|
|
22
|
+
|
|
23
|
+
if (duration.milliseconds) ms += duration.milliseconds;
|
|
24
|
+
if (duration.seconds) ms += duration.seconds * 1000;
|
|
25
|
+
if (duration.minutes) ms += duration.minutes * 60 * 1000;
|
|
26
|
+
if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
|
|
27
|
+
if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
|
|
28
|
+
if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
return ms;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert milliseconds to duration object
|
|
35
|
+
*/
|
|
36
|
+
export function fromMilliseconds(ms: number): Duration {
|
|
37
|
+
const weeks = Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
|
|
38
|
+
ms %= 7 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
|
41
|
+
ms %= 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
const hours = Math.floor(ms / (60 * 60 * 1000));
|
|
44
|
+
ms %= 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
const minutes = Math.floor(ms / (60 * 1000));
|
|
47
|
+
ms %= 60 * 1000;
|
|
48
|
+
|
|
49
|
+
const seconds = Math.floor(ms / 1000);
|
|
50
|
+
const milliseconds = ms % 1000;
|
|
51
|
+
|
|
52
|
+
return { weeks, days, hours, minutes, seconds, milliseconds };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Format duration as human-readable string
|
|
57
|
+
*/
|
|
58
|
+
export function formatDuration(duration: Duration): string {
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
|
|
61
|
+
if (duration.weeks) parts.push(`${duration.weeks}w`);
|
|
62
|
+
if (duration.days) parts.push(`${duration.days}d`);
|
|
63
|
+
if (duration.hours) parts.push(`${duration.hours}h`);
|
|
64
|
+
if (duration.minutes) parts.push(`${duration.minutes}m`);
|
|
65
|
+
if (duration.seconds) parts.push(`${duration.seconds}s`);
|
|
66
|
+
if (duration.milliseconds) parts.push(`${duration.milliseconds}ms`);
|
|
67
|
+
|
|
68
|
+
return parts.join(" ") || "0ms";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sleep for specified milliseconds
|
|
73
|
+
*/
|
|
74
|
+
export function sleep(ms: number): Promise<void> {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sleep for specified duration
|
|
80
|
+
*/
|
|
81
|
+
export async function sleepDuration(duration: Duration): Promise<void> {
|
|
82
|
+
return sleep(toMilliseconds(duration));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a timeout promise
|
|
87
|
+
*/
|
|
88
|
+
export function timeout<T>(
|
|
89
|
+
promise: Promise<T>,
|
|
90
|
+
ms: number,
|
|
91
|
+
message?: string
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
return Promise.race([
|
|
94
|
+
promise,
|
|
95
|
+
new Promise<T>((_, reject) =>
|
|
96
|
+
setTimeout(
|
|
97
|
+
() => reject(new Error(message || `Timeout after ${ms}ms`)),
|
|
98
|
+
ms
|
|
99
|
+
)
|
|
100
|
+
),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get current timestamp in milliseconds
|
|
106
|
+
*/
|
|
107
|
+
export function now(): number {
|
|
108
|
+
return Date.now();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Calculate delay until a specific timestamp
|
|
113
|
+
*/
|
|
114
|
+
export function delayUntil(timestamp: number): number {
|
|
115
|
+
return Math.max(0, timestamp - Date.now());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a timestamp is in the past
|
|
120
|
+
*/
|
|
121
|
+
export function isPast(timestamp: number): boolean {
|
|
122
|
+
return timestamp < Date.now();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if a timestamp is in the future
|
|
127
|
+
*/
|
|
128
|
+
export function isFuture(timestamp: number): boolean {
|
|
129
|
+
return timestamp > Date.now();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add duration to timestamp
|
|
134
|
+
*/
|
|
135
|
+
export function addDuration(timestamp: number, duration: Duration): number {
|
|
136
|
+
return timestamp + toMilliseconds(duration);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse cron-like duration string (e.g., "5m", "1h", "30s")
|
|
141
|
+
*/
|
|
142
|
+
export function parseDuration(str: string): number {
|
|
143
|
+
const match = str.match(/^(\d+)(ms|s|m|h|d|w)$/);
|
|
144
|
+
if (!match) {
|
|
145
|
+
throw new Error(`Invalid duration string: ${str}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const value = parseInt(match[1], 10);
|
|
149
|
+
const unit = match[2];
|
|
150
|
+
|
|
151
|
+
switch (unit) {
|
|
152
|
+
case "ms":
|
|
153
|
+
return value;
|
|
154
|
+
case "s":
|
|
155
|
+
return value * 1000;
|
|
156
|
+
case "m":
|
|
157
|
+
return value * 60 * 1000;
|
|
158
|
+
case "h":
|
|
159
|
+
return value * 60 * 60 * 1000;
|
|
160
|
+
case "d":
|
|
161
|
+
return value * 24 * 60 * 60 * 1000;
|
|
162
|
+
case "w":
|
|
163
|
+
return value * 7 * 24 * 60 * 60 * 1000;
|
|
164
|
+
default:
|
|
165
|
+
throw new Error(`Unknown unit: ${unit}`);
|
|
166
|
+
}
|
|
167
|
+
}
|