Skip to content

Commit c48b7cd

Browse files
Make duration strings type safe
1 parent ba58bd7 commit c48b7cd

File tree

4 files changed

+61
-11
lines changed

4 files changed

+61
-11
lines changed

packages/openworkflow/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Backend, WorkflowRun } from "./backend.js";
2+
import { DurationString } from "./duration.js";
23
import { Worker } from "./worker.js";
34

45
const DEFAULT_RESULT_POLL_INTERVAL_MS = 1000; // 1s
@@ -170,7 +171,7 @@ export interface StepApi {
170171
config: StepFunctionConfig,
171172
fn: StepFunction<Output>,
172173
): Promise<Output>;
173-
sleep(name: string, duration: string): Promise<void>;
174+
sleep(name: string, duration: DurationString): Promise<void>;
174175
}
175176

176177
/**

packages/openworkflow/duration.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,15 @@ describe("parseDuration", () => {
193193
});
194194

195195
test("parses case-insensitive long format", () => {
196+
// @ts-expect-error - mixed-case (not in type but accepted at runtime)
196197
expect(parseDuration("53 YeArS")).toBe(1_672_552_800_000);
198+
// @ts-expect-error - mixed-case (not in type but accepted at runtime)
197199
expect(parseDuration("53 WeEkS")).toBe(32_054_400_000);
200+
// @ts-expect-error - mixed-case (not in type but accepted at runtime)
198201
expect(parseDuration("53 DaYS")).toBe(4_579_200_000);
202+
// @ts-expect-error - mixed-case (not in type but accepted at runtime)
199203
expect(parseDuration("53 HoUrs")).toBe(190_800_000);
204+
// @ts-expect-error - mixed-case (not in type but accepted at runtime)
200205
expect(parseDuration("53 MiLliSeCondS")).toBe(53);
201206
});
202207
});
@@ -237,43 +242,55 @@ describe("parseDuration", () => {
237242

238243
describe("error cases", () => {
239244
test("throws on invalid format", () => {
245+
// @ts-expect-error - invalid format
240246
expect(() => parseDuration("invalid")).toThrow(
241247
'Invalid duration format: "invalid"',
242248
);
249+
// @ts-expect-error - invalid format
243250
expect(() => parseDuration("10-.5")).toThrow(
244251
'Invalid duration format: "10-.5"',
245252
);
253+
// @ts-expect-error - invalid format
246254
expect(() => parseDuration("foo")).toThrow(
247255
'Invalid duration format: "foo"',
248256
);
249257
});
250258

251259
test("throws on empty string", () => {
260+
// @ts-expect-error - empty string
252261
expect(() => parseDuration("")).toThrow('Invalid duration format: ""');
253262
});
254263

255264
test("throws on missing number", () => {
265+
// @ts-expect-error - unit without number
256266
expect(() => parseDuration("ms")).toThrow(
257267
'Invalid duration format: "ms"',
258268
);
269+
// @ts-expect-error - unit without number
259270
expect(() => parseDuration("s")).toThrow('Invalid duration format: "s"');
271+
// @ts-expect-error - unit without number
260272
expect(() => parseDuration("m")).toThrow('Invalid duration format: "m"');
273+
// @ts-expect-error - unit without number
261274
expect(() => parseDuration("h")).toThrow('Invalid duration format: "h"');
262275
});
263276

264277
test("throws on unknown unit", () => {
278+
// @ts-expect-error - unknown unit
265279
expect(() => parseDuration("100x")).toThrow(
266280
'Invalid duration format: "100x"',
267281
);
282+
// @ts-expect-error - unknown unit
268283
expect(() => parseDuration("5z")).toThrow(
269284
'Invalid duration format: "5z"',
270285
);
271286
});
272287

273288
test("throws on multiple units", () => {
289+
// @ts-expect-error - multiple units
274290
expect(() => parseDuration("1h30m")).toThrow(
275291
'Invalid duration format: "1h30m"',
276292
);
293+
// @ts-expect-error - multiple units
277294
expect(() => parseDuration("5s100ms")).toThrow(
278295
'Invalid duration format: "5s100ms"',
279296
);
@@ -283,34 +300,44 @@ describe("parseDuration", () => {
283300
expect(() => parseDuration(" 5s")).toThrow(
284301
'Invalid duration format: " 5s"',
285302
);
303+
// @ts-expect-error - trailing space
286304
expect(() => parseDuration("5s ")).toThrow(
287305
'Invalid duration format: "5s "',
288306
);
289307
});
290308

291309
test("throws on special characters", () => {
310+
// @ts-expect-error - special characters
292311
expect(() => parseDuration("5s!")).toThrow(
293312
'Invalid duration format: "5s!"',
294313
);
314+
// @ts-expect-error - special characters
295315
expect(() => parseDuration("@5s")).toThrow(
296316
'Invalid duration format: "@5s"',
297317
);
298318
});
299319

300320
test("throws on non-string types", () => {
321+
// @ts-expect-error - non-string type
301322
expect(() => parseDuration(undefined as unknown as string)).toThrow(
302323
TypeError,
303324
);
325+
// @ts-expect-error - non-string type
304326
expect(() => parseDuration(null as unknown as string)).toThrow(TypeError);
327+
// @ts-expect-error - non-string type
305328
expect(() => parseDuration([] as unknown as string)).toThrow(TypeError);
329+
// @ts-expect-error - non-string type
306330
expect(() => parseDuration({} as unknown as string)).toThrow(TypeError);
331+
// @ts-expect-error - non-string type
307332
expect(() => parseDuration(Number.NaN as unknown as string)).toThrow(
308333
TypeError,
309334
);
310335
expect(() =>
336+
// @ts-expect-error - non-string type
311337
parseDuration(Number.POSITIVE_INFINITY as unknown as string),
312338
).toThrow(TypeError);
313339
expect(() =>
340+
// @ts-expect-error - non-string type
314341
parseDuration(Number.NEGATIVE_INFINITY as unknown as string),
315342
).toThrow(TypeError);
316343
});

packages/openworkflow/duration.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
1+
type Years = "years" | "year" | "yrs" | "yr" | "y";
2+
type Months = "months" | "month" | "mo";
3+
type Weeks = "weeks" | "week" | "w";
4+
type Days = "days" | "day" | "d";
5+
type Hours = "hours" | "hour" | "hrs" | "hr" | "h";
6+
type Minutes = "minutes" | "minute" | "mins" | "min" | "m";
7+
type Seconds = "seconds" | "second" | "secs" | "sec" | "s";
8+
type Milliseconds = "milliseconds" | "millisecond" | "msecs" | "msec" | "ms";
9+
type Unit =
10+
| Years
11+
| Months
12+
| Weeks
13+
| Days
14+
| Hours
15+
| Minutes
16+
| Seconds
17+
| Milliseconds;
18+
type UnitAnyCase = Capitalize<Unit> | Uppercase<Unit> | Lowercase<Unit>;
19+
export type DurationString =
20+
| `${number}`
21+
| `${number}${UnitAnyCase}`
22+
| `${number} ${UnitAnyCase}`;
23+
124
/**
225
* Parse a duration string into milliseconds. Exmaples:
326
* - short units: "1ms", "5s", "30m", "2h", "7d", "3w", "1y"
427
* - long units: "1 millisecond", "5 seconds", "30 minutes", "2 hours", "7 days", "3 weeks", "1 year"
528
*/
6-
export function parseDuration(duration: string): number {
7-
if (typeof duration !== "string") {
29+
export function parseDuration(str: DurationString): number {
30+
if (typeof str !== "string") {
831
throw new TypeError(
9-
"Invalid duration format: expected a string but received " +
10-
typeof duration,
32+
"Invalid duration format: expected a string but received " + typeof str,
1133
);
1234
}
1335

14-
if (duration.length === 0) {
36+
if (str.length === 0) {
1537
throw new Error('Invalid duration format: ""');
1638
}
1739

18-
const match = /^(-?\.?\d+(?:\.\d+)?)\s*([a-z]+)?$/i.exec(duration);
40+
const match = /^(-?\.?\d+(?:\.\d+)?)\s*([a-z]+)?$/i.exec(str);
1941

2042
if (!match?.[1]) {
21-
throw new Error(`Invalid duration format: "${duration}"`);
43+
throw new Error(`Invalid duration format: "${str}"`);
2244
}
2345

2446
const numValue = Number.parseFloat(match[1]);
@@ -63,7 +85,7 @@ export function parseDuration(duration: string): number {
6385

6486
const multiplier = multipliers[unit];
6587
if (multiplier === undefined) {
66-
throw new Error(`Invalid duration format: "${duration}"`);
88+
throw new Error(`Invalid duration format: "${str}"`);
6789
}
6890

6991
return numValue * multiplier;

packages/openworkflow/worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
StepFunctionConfig,
1111
WorkflowDefinition,
1212
} from "./client.js";
13-
import { parseDuration } from "./duration.js";
13+
import { DurationString, parseDuration } from "./duration.js";
1414
import { randomUUID } from "node:crypto";
1515

1616
const DEFAULT_LEASE_DURATION_MS = 30 * 1000; // 30s
@@ -428,7 +428,7 @@ class StepExecutor implements StepApi {
428428
}
429429
}
430430

431-
async sleep(name: string, duration: string): Promise<void> {
431+
async sleep(name: string, duration: DurationString): Promise<void> {
432432
// return cached result if this sleep already completed
433433
const existingAttempt = this.successfulAttemptsByName.get(name);
434434
if (existingAttempt) return;

0 commit comments

Comments
 (0)