Skip to content

Commit bf170ad

Browse files
authored
Add initial @workflow/utils package (#239)
Add a new `@workflow/utils` package to extract common utility functions. - Created a new `@workflow/utils` package with common utility functions: - `parseDurationToDate()` - Parses duration strings, numbers, or Date objects - `withResolvers()` - Polyfill for `Promise.withResolvers()` - `once()` - Creates a lazily-evaluated, memoized function - `PromiseWithResolvers` - Type interface for promise resolvers - Moved these utility functions from the core package to the new utils package - Updated imports in the core package to use the new utils package - Added tests for the utility functions in the new package Signed-off-by: Nathan Rajlich <n@n8.io>
1 parent 86eb60d commit bf170ad

File tree

17 files changed

+252
-269
lines changed

17 files changed

+252
-269
lines changed

.changeset/all-years-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/utils": patch
3+
---
4+
5+
Add initial `@workflow/utils` package

.changeset/pre.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@workflow/world-postgres": "4.0.0",
2020
"@workflow/world-testing": "4.0.0",
2121
"@workflow/world-vercel": "4.0.0",
22+
"@workflow/utils": "4.0.0",
2223
"docs": "4.0.0",
2324
"@workflow/example-app": "0.0.2-alpha.18",
2425
"@workflow/example-hono": "0.0.0",

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@types/ms": "^2.1.0",
5252
"@vercel/functions": "catalog:",
5353
"@workflow/errors": "workspace:*",
54+
"@workflow/utils": "workspace:*",
5455
"@workflow/world": "workspace:*",
5556
"@workflow/world-local": "workspace:*",
5657
"@workflow/world-vercel": "workspace:*",

packages/core/src/step.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { FatalError, WorkflowRuntimeError } from '@workflow/errors';
2+
import { withResolvers } from '@workflow/utils';
23
import { EventConsumerResult } from './events-consumer.js';
34
import { WorkflowSuspension } from './global.js';
45
import { stepLogger } from './logger.js';
56
import type { WorkflowOrchestratorContext } from './private.js';
67
import type { Serializable } from './schemas.js';
78
import { hydrateStepReturnValue } from './serialization.js';
8-
import { withResolvers } from './util.js';
99

1010
export function createUseStep(ctx: WorkflowOrchestratorContext) {
1111
return function useStep<Args extends Serializable[], Result>(

packages/core/src/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Span, SpanOptions } from '@opentelemetry/api';
2-
import { once } from './util.js';
2+
import { once } from '@workflow/utils';
33

44
// ============================================================
55
// Trace Context Propagation Utilities

packages/core/src/util.test.ts

Lines changed: 1 addition & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import {
3-
buildWorkflowSuspensionMessage,
4-
getWorkflowRunStreamId,
5-
parseDurationToDate,
6-
} from './util';
2+
import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util';
73

84
describe('buildWorkflowSuspensionMessage', () => {
95
const runId = 'test-run-123';
@@ -169,174 +165,3 @@ describe('getWorkflowRunStreamId', () => {
169165
expect(result.includes('_user')).toBe(true);
170166
});
171167
});
172-
173-
describe('parseDurationToDate', () => {
174-
describe('string durations', () => {
175-
it('should parse seconds', () => {
176-
const before = Date.now();
177-
const result = parseDurationToDate('5s');
178-
const after = Date.now();
179-
expect(result.getTime()).toBeGreaterThanOrEqual(before + 5000);
180-
expect(result.getTime()).toBeLessThanOrEqual(after + 5000);
181-
});
182-
183-
it('should parse minutes', () => {
184-
const before = Date.now();
185-
const result = parseDurationToDate('2m');
186-
const after = Date.now();
187-
const expected = before + 120000;
188-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
189-
expect(result.getTime()).toBeLessThanOrEqual(after + 120000);
190-
});
191-
192-
it('should parse hours', () => {
193-
const before = Date.now();
194-
const result = parseDurationToDate('1h');
195-
const after = Date.now();
196-
const expected = before + 3600000;
197-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
198-
expect(result.getTime()).toBeLessThanOrEqual(after + 3600000);
199-
});
200-
201-
it('should parse days', () => {
202-
const before = Date.now();
203-
const result = parseDurationToDate('1d');
204-
const after = Date.now();
205-
const expected = before + 86400000;
206-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
207-
expect(result.getTime()).toBeLessThanOrEqual(after + 86400000);
208-
});
209-
210-
it('should parse milliseconds', () => {
211-
const before = Date.now();
212-
const result = parseDurationToDate('500ms');
213-
const after = Date.now();
214-
const expected = before + 500;
215-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
216-
expect(result.getTime()).toBeLessThanOrEqual(after + 500);
217-
});
218-
219-
it('should throw error for invalid string', () => {
220-
expect(() =>
221-
parseDurationToDate(
222-
// @ts-expect-error
223-
'invalid'
224-
)
225-
).toThrow(
226-
'Invalid duration: "invalid". Expected a valid duration string like "1s", "1m", "1h", etc.'
227-
);
228-
});
229-
230-
it('should throw error for negative duration string', () => {
231-
expect(() => parseDurationToDate('-1s')).toThrow(
232-
'Invalid duration: "-1s". Expected a valid duration string like "1s", "1m", "1h", etc.'
233-
);
234-
});
235-
});
236-
237-
describe('number durations (milliseconds)', () => {
238-
it('should parse zero milliseconds', () => {
239-
const before = Date.now();
240-
const result = parseDurationToDate(0);
241-
const after = Date.now();
242-
expect(result.getTime()).toBeGreaterThanOrEqual(before);
243-
expect(result.getTime()).toBeLessThanOrEqual(after);
244-
});
245-
246-
it('should parse positive milliseconds', () => {
247-
const before = Date.now();
248-
const result = parseDurationToDate(10000);
249-
const after = Date.now();
250-
const expected = before + 10000;
251-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
252-
expect(result.getTime()).toBeLessThanOrEqual(after + 10000);
253-
});
254-
255-
it('should parse large milliseconds', () => {
256-
const before = Date.now();
257-
const result = parseDurationToDate(1000000);
258-
const after = Date.now();
259-
const expected = before + 1000000;
260-
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
261-
expect(result.getTime()).toBeLessThanOrEqual(after + 1000000);
262-
});
263-
264-
it('should throw error for negative number', () => {
265-
expect(() => parseDurationToDate(-1000)).toThrow(
266-
'Invalid duration: -1000. Expected a non-negative finite number of milliseconds.'
267-
);
268-
});
269-
270-
it('should throw error for NaN', () => {
271-
expect(() => parseDurationToDate(NaN)).toThrow(
272-
'Invalid duration: NaN. Expected a non-negative finite number of milliseconds.'
273-
);
274-
});
275-
276-
it('should throw error for Infinity', () => {
277-
expect(() => parseDurationToDate(Infinity)).toThrow(
278-
'Invalid duration: Infinity. Expected a non-negative finite number of milliseconds.'
279-
);
280-
});
281-
282-
it('should throw error for -Infinity', () => {
283-
expect(() => parseDurationToDate(-Infinity)).toThrow(
284-
'Invalid duration: -Infinity. Expected a non-negative finite number of milliseconds.'
285-
);
286-
});
287-
});
288-
289-
describe('Date objects', () => {
290-
it('should return Date instance directly', () => {
291-
const targetTime = Date.now() + 60000;
292-
const futureDate = new Date(targetTime);
293-
const result = parseDurationToDate(futureDate);
294-
expect(result).toBe(futureDate);
295-
expect(result.getTime()).toBe(targetTime);
296-
});
297-
298-
it('should handle past dates', () => {
299-
const targetTime = Date.now() - 60000;
300-
const pastDate = new Date(targetTime);
301-
const result = parseDurationToDate(pastDate);
302-
expect(result).toBe(pastDate);
303-
expect(result.getTime()).toBe(targetTime);
304-
});
305-
306-
it('should handle date-like objects from deserialization', () => {
307-
const targetTime = Date.now() + 30000;
308-
const dateLike = {
309-
getTime: () => targetTime,
310-
};
311-
const result = parseDurationToDate(dateLike as any);
312-
expect(result.getTime()).toBe(targetTime);
313-
expect(result instanceof Date).toBe(true);
314-
});
315-
});
316-
317-
describe('invalid inputs', () => {
318-
it('should throw error for null', () => {
319-
expect(() => parseDurationToDate(null as any)).toThrow(
320-
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
321-
);
322-
});
323-
324-
it('should throw error for undefined', () => {
325-
expect(() => parseDurationToDate(undefined as any)).toThrow(
326-
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
327-
);
328-
});
329-
330-
it('should throw error for boolean', () => {
331-
expect(() => parseDurationToDate(true as any)).toThrow(
332-
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
333-
);
334-
});
335-
336-
it('should throw error for object without getTime', () => {
337-
expect(() => parseDurationToDate({} as any)).toThrow(
338-
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
339-
);
340-
});
341-
});
342-
});

packages/core/src/util.ts

Lines changed: 0 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,3 @@
1-
import type { StringValue } from 'ms';
2-
import ms from 'ms';
3-
4-
export interface PromiseWithResolvers<T> {
5-
promise: Promise<T>;
6-
resolve: (value: T) => void;
7-
reject: (reason?: any) => void;
8-
}
9-
10-
/**
11-
* Polyfill for `Promise.withResolvers()`.
12-
*
13-
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
14-
*/
15-
export function withResolvers<T>(): PromiseWithResolvers<T> {
16-
let resolve!: (value: T) => void;
17-
let reject!: (reason?: any) => void;
18-
const promise = new Promise<T>((_resolve, _reject) => {
19-
resolve = _resolve;
20-
reject = _reject;
21-
});
22-
return { promise, resolve, reject };
23-
}
24-
25-
/**
26-
* Creates a lazily-evaluated, memoized version of the provided function.
27-
*
28-
* The returned object exposes a `value` getter that calls `fn` only once,
29-
* caches its result, and returns the cached value on subsequent accesses.
30-
*
31-
* @typeParam T - The return type of the provided function.
32-
* @param fn - The function to be called once and whose result will be cached.
33-
* @returns An object with a `value` property that returns the memoized result of `fn`.
34-
*/
35-
export function once<T>(fn: () => T) {
36-
const result = {
37-
get value() {
38-
const value = fn();
39-
Object.defineProperty(result, 'value', { value });
40-
return value;
41-
},
42-
};
43-
return result;
44-
}
45-
461
/**
472
* Builds a workflow suspension log message based on the counts of steps, hooks, and waits.
483
* @param runId - The workflow run ID
@@ -107,46 +62,3 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) {
10762
);
10863
return `${streamId}_${encodedNamespace}`;
10964
}
110-
111-
/**
112-
* Parses a duration parameter (string, number, or Date) and returns a Date object
113-
* representing when the duration should elapse.
114-
*
115-
* - For strings: Parses duration strings like "1s", "5m", "1h", etc. using the `ms` library
116-
* - For numbers: Treats as milliseconds from now
117-
* - For Date objects: Returns the date directly (handles both Date instances and date-like objects from deserialization)
118-
*
119-
* @param param - The duration parameter (StringValue, Date, or number of milliseconds)
120-
* @returns A Date object representing when the duration should elapse
121-
* @throws {Error} If the parameter is invalid or cannot be parsed
122-
*/
123-
export function parseDurationToDate(param: StringValue | Date | number): Date {
124-
if (typeof param === 'string') {
125-
const durationMs = ms(param);
126-
if (typeof durationMs !== 'number' || durationMs < 0) {
127-
throw new Error(
128-
`Invalid duration: "${param}". Expected a valid duration string like "1s", "1m", "1h", etc.`
129-
);
130-
}
131-
return new Date(Date.now() + durationMs);
132-
} else if (typeof param === 'number') {
133-
if (param < 0 || !Number.isFinite(param)) {
134-
throw new Error(
135-
`Invalid duration: ${param}. Expected a non-negative finite number of milliseconds.`
136-
);
137-
}
138-
return new Date(Date.now() + param);
139-
} else if (
140-
param instanceof Date ||
141-
(param &&
142-
typeof param === 'object' &&
143-
typeof (param as any).getTime === 'function')
144-
) {
145-
// Handle both Date instances and date-like objects (from deserialization)
146-
return param instanceof Date ? param : new Date((param as any).getTime());
147-
} else {
148-
throw new Error(
149-
`Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.`
150-
);
151-
}
152-
}

packages/core/src/workflow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { runInContext } from 'node:vm';
22
import { ERROR_SLUGS } from '@workflow/errors';
3+
import { withResolvers } from '@workflow/utils';
34
import type { Event, WorkflowRun } from '@workflow/world';
45
import * as nanoid from 'nanoid';
56
import { monotonicFactory } from 'ulid';
@@ -20,7 +21,7 @@ import {
2021
} from './symbols.js';
2122
import * as Attribute from './telemetry/semantic-conventions.js';
2223
import { trace } from './telemetry.js';
23-
import { getWorkflowRunStreamId, withResolvers } from './util.js';
24+
import { getWorkflowRunStreamId } from './util.js';
2425
import { createContext } from './vm/index.js';
2526
import type { WorkflowMetadata } from './workflow/get-workflow-metadata.js';
2627
import { WORKFLOW_CONTEXT_SYMBOL } from './workflow/get-workflow-metadata.js';

packages/core/src/workflow/hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import { type PromiseWithResolvers, withResolvers } from '@workflow/utils';
12
import type { HookReceivedEvent } from '@workflow/world';
23
import type { Hook, HookOptions } from '../create-hook.js';
34
import { EventConsumerResult } from '../events-consumer.js';
45
import { WorkflowSuspension } from '../global.js';
56
import { webhookLogger } from '../logger.js';
67
import type { WorkflowOrchestratorContext } from '../private.js';
78
import { hydrateStepReturnValue } from '../serialization.js';
8-
import { type PromiseWithResolvers, withResolvers } from '../util.js';
99

1010
export function createCreateHook(ctx: WorkflowOrchestratorContext) {
1111
return function createHookImpl<T = any>(options: HookOptions = {}): Hook<T> {

packages/core/src/workflow/sleep.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { parseDurationToDate, withResolvers } from '@workflow/utils';
12
import type { StringValue } from 'ms';
23
import { EventConsumerResult } from '../events-consumer.js';
34
import { type WaitInvocationQueueItem, WorkflowSuspension } from '../global.js';
45
import type { WorkflowOrchestratorContext } from '../private.js';
5-
import { parseDurationToDate, withResolvers } from '../util.js';
66

77
export function createSleep(ctx: WorkflowOrchestratorContext) {
88
return async function sleepImpl(

0 commit comments

Comments
 (0)