Skip to content

Commit b6fbf74

Browse files
committed
improve window type
1 parent 9f656ff commit b6fbf74

8 files changed

Lines changed: 346 additions & 72 deletions

File tree

apps/webapp/app/v3/scheduleWindow.server.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { calculateEffectiveScheduleTime, calculateSchedulePhase } from "@internal/schedule-engine";
12
import {
2-
calculateEffectiveScheduleTime,
3-
calculateSchedulePhase,
3+
ScheduleWindow,
44
parseScheduleWindow,
55
type NormalizedScheduleWindow,
6-
} from "@internal/schedule-engine";
6+
} from "@trigger.dev/core/v3";
77
import { nextScheduledTimestamps } from "./utils/calculateNextSchedule.server";
88

99
const SECONDS_PER_UNIT = {
@@ -127,13 +127,13 @@ export function validateScheduleWindowSyntax(
127127
return { valid: true };
128128
}
129129

130-
try {
131-
parseScheduleWindow(window);
130+
const result = ScheduleWindow.safeParse(window);
131+
if (result.success) {
132132
return { valid: true };
133-
} catch (error) {
134-
return {
135-
valid: false,
136-
message: error instanceof Error ? error.message : String(error),
137-
};
138133
}
134+
135+
return {
136+
valid: false,
137+
message: result.error.issues[0]?.message ?? "Invalid schedule window",
138+
};
139139
}

internal-packages/schedule-engine/src/engine/scheduleTiming.ts

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import {
2+
MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS,
3+
parseScheduleWindow,
4+
type NormalizedScheduleWindow,
5+
} from "@trigger.dev/core/v3";
16
import { createHmac } from "node:crypto";
27

8+
export { MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS, parseScheduleWindow };
9+
export type { NormalizedScheduleWindow };
10+
311
export const SCHEDULE_PHASE_DENOMINATOR = 2_147_483_648;
412
export const MAX_SCHEDULE_PHASE = SCHEDULE_PHASE_DENOMINATOR - 1;
513
export const MINIMUM_SCHEDULE_RANGE_MS = 60_000;
6-
export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60;
714

815
const PERCENTAGE_DENOMINATOR = 100;
916

10-
export type NormalizedScheduleWindow =
11-
| { type: "duration"; durationSeconds: number }
12-
| { type: "percentage"; percentage: number };
13-
1417
export type SchedulePhaseInput = {
1518
secret: string | Buffer;
1619
environmentId: string;
@@ -28,42 +31,6 @@ export type EffectiveScheduleTime = {
2831
windowWasCappedToInterval: boolean;
2932
};
3033

31-
/**
32-
* Parses the public schedule-window syntax.
33-
*
34-
* Durations are non-negative whole minutes or hours up to 24 hours.
35-
* Percentages are whole numbers from 0% through 100%.
36-
*/
37-
export function parseScheduleWindow(value: string): NormalizedScheduleWindow {
38-
const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value);
39-
40-
if (durationMatch) {
41-
const amount = Number(durationMatch[1]);
42-
const unit = durationMatch[2] as "m" | "h";
43-
const unitSeconds = unit === "m" ? 60 : 3_600;
44-
const durationSeconds = amount * unitSeconds;
45-
46-
if (
47-
!Number.isSafeInteger(durationSeconds) ||
48-
durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS
49-
) {
50-
throw new RangeError("Schedule window duration cannot exceed 24 hours");
51-
}
52-
53-
return { type: "duration", durationSeconds };
54-
}
55-
56-
const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value);
57-
58-
if (percentageMatch) {
59-
return { type: "percentage", percentage: Number(percentageMatch[1]) };
60-
}
61-
62-
throw new TypeError(
63-
'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"'
64-
);
65-
}
66-
6734
export function validateScheduleWindow(window: NormalizedScheduleWindow): void {
6835
if (window.type === "duration") {
6936
if (
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, expectTypeOf, it } from "vitest";
2+
import {
3+
ScheduleWindow,
4+
parseScheduleWindow,
5+
type ValidatedScheduleWindow,
6+
} from "./scheduleWindow.js";
7+
8+
describe("ScheduleWindow", () => {
9+
it.each(["0m", "30m", "1440m", "0h", "2h", "24h", "0%", "30%", "100%"])(
10+
"accepts %s",
11+
(window) => {
12+
expect(ScheduleWindow.safeParse(window).success).toBe(true);
13+
}
14+
);
15+
16+
it.each([
17+
"",
18+
"00m",
19+
"01m",
20+
"1.5h",
21+
"0d",
22+
"1d",
23+
"25h",
24+
"1441m",
25+
"30s",
26+
"-1m",
27+
"0.5%",
28+
"101%",
29+
"1e2%",
30+
" 30m",
31+
"30m ",
32+
])("rejects %j", (window) => {
33+
expect(ScheduleWindow.safeParse(window).success).toBe(false);
34+
});
35+
36+
it("normalizes valid windows", () => {
37+
expect(parseScheduleWindow("30m")).toEqual({
38+
type: "duration",
39+
durationSeconds: 1_800,
40+
});
41+
expect(parseScheduleWindow("25%")).toEqual({
42+
type: "percentage",
43+
percentage: 25,
44+
});
45+
});
46+
47+
it("validates literal types without rejecting runtime strings", () => {
48+
expectTypeOf<ValidatedScheduleWindow<"30m">>().toEqualTypeOf<"30m">();
49+
expectTypeOf<ValidatedScheduleWindow<"24h">>().toEqualTypeOf<"24h">();
50+
expectTypeOf<ValidatedScheduleWindow<"100%">>().toEqualTypeOf<"100%">();
51+
expectTypeOf<ValidatedScheduleWindow<string>>().toEqualTypeOf<string>();
52+
expectTypeOf<ValidatedScheduleWindow<undefined>>().toEqualTypeOf<undefined>();
53+
expectTypeOf<
54+
ValidatedScheduleWindow<"25h">
55+
>().toEqualTypeOf<"⛔ window duration cannot exceed 24 hours">();
56+
expectTypeOf<
57+
ValidatedScheduleWindow<"101%">
58+
>().toEqualTypeOf<"⛔ percentage cannot exceed 100%">();
59+
expectTypeOf<
60+
ValidatedScheduleWindow<"1d">
61+
>().toEqualTypeOf<'⛔ window must look like "30m", "2h", or "50%"'>();
62+
});
63+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { z } from "zod";
2+
3+
export const MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS = 24 * 60 * 60;
4+
5+
export type NormalizedScheduleWindow =
6+
| { type: "duration"; durationSeconds: number }
7+
| { type: "percentage"; percentage: number };
8+
9+
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
10+
11+
type DigitsBelow = {
12+
"0": never;
13+
"1": "0";
14+
"2": "0" | "1";
15+
"3": "0" | "1" | "2";
16+
"4": "0" | "1" | "2" | "3";
17+
"5": "0" | "1" | "2" | "3" | "4";
18+
"6": "0" | "1" | "2" | "3" | "4" | "5";
19+
"7": "0" | "1" | "2" | "3" | "4" | "5" | "6";
20+
"8": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7";
21+
"9": "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8";
22+
};
23+
24+
type DigitLength<
25+
Value extends string,
26+
Result extends 0[] = [],
27+
> = Value extends `${Digit}${infer Rest}` ? DigitLength<Rest, [...Result, 0]> : Result;
28+
29+
type CompareEqualLength<
30+
A extends string,
31+
B extends string,
32+
> = A extends `${infer ADigit extends Digit}${infer ARest}`
33+
? B extends `${infer BDigit extends Digit}${infer BRest}`
34+
? ADigit extends BDigit
35+
? CompareEqualLength<ARest, BRest>
36+
: ADigit extends DigitsBelow[BDigit]
37+
? "lt"
38+
: "gt"
39+
: "eq"
40+
: "eq";
41+
42+
type DecimalStringLTE<A extends string, B extends string> =
43+
DigitLength<A> extends DigitLength<B>
44+
? CompareEqualLength<A, B> extends "gt"
45+
? false
46+
: true
47+
: DigitLength<B> extends [...DigitLength<A>, ...0[]]
48+
? true
49+
: false;
50+
51+
type IsCanonicalUnsignedInteger<Value extends string> = Value extends `${bigint}`
52+
? Value extends `-${string}`
53+
? false
54+
: true
55+
: false;
56+
57+
/** The literal validation error for a configured schedule window, or `never` when valid. */
58+
export type ScheduleWindowError<Window extends string> = string extends Window
59+
? never
60+
: Window extends `${infer Amount}m`
61+
? IsCanonicalUnsignedInteger<Amount> extends false
62+
? "⛔ window must be a whole non-negative number"
63+
: DecimalStringLTE<Amount, "1440"> extends true
64+
? never
65+
: "⛔ window duration cannot exceed 24 hours"
66+
: Window extends `${infer Amount}h`
67+
? IsCanonicalUnsignedInteger<Amount> extends false
68+
? "⛔ window must be a whole non-negative number"
69+
: DecimalStringLTE<Amount, "24"> extends true
70+
? never
71+
: "⛔ window duration cannot exceed 24 hours"
72+
: Window extends `${infer Amount}%`
73+
? IsCanonicalUnsignedInteger<Amount> extends false
74+
? "⛔ percentage must be a whole non-negative number"
75+
: DecimalStringLTE<Amount, "100"> extends true
76+
? never
77+
: "⛔ percentage cannot exceed 100%"
78+
: '⛔ window must look like "30m", "2h", or "50%"';
79+
80+
/**
81+
* Preserves valid schedule-window literals and replaces invalid literals with a descriptive type
82+
* error. Wide `string` values pass through for authoritative runtime validation.
83+
*/
84+
export type ValidatedScheduleWindow<Window extends string | undefined> = Window extends string
85+
? [ScheduleWindowError<Window>] extends [never]
86+
? Window
87+
: ScheduleWindowError<Window>
88+
: Window;
89+
90+
/** Parses and normalizes the public schedule-window syntax. */
91+
export function parseScheduleWindow(value: string): NormalizedScheduleWindow {
92+
const durationMatch = /^(0|[1-9]\d*)([mh])$/.exec(value);
93+
94+
if (durationMatch) {
95+
const amount = Number(durationMatch[1]);
96+
const unit = durationMatch[2] as "m" | "h";
97+
const durationSeconds = amount * (unit === "m" ? 60 : 3_600);
98+
99+
if (
100+
!Number.isSafeInteger(durationSeconds) ||
101+
durationSeconds > MAX_ABSOLUTE_SCHEDULE_WINDOW_SECONDS
102+
) {
103+
throw new RangeError("Schedule window duration cannot exceed 24 hours");
104+
}
105+
106+
return { type: "duration", durationSeconds };
107+
}
108+
109+
const percentageMatch = /^(0|[1-9]\d?|100)%$/.exec(value);
110+
if (percentageMatch) {
111+
return { type: "percentage", percentage: Number(percentageMatch[1]) };
112+
}
113+
114+
throw new TypeError(
115+
'Schedule window must be a whole duration such as "0m", "30m", or "24h", or a percentage such as "30%"'
116+
);
117+
}
118+
119+
/** Runtime authority for public schedule-window values. */
120+
export const ScheduleWindow = z.string().superRefine((value, ctx) => {
121+
try {
122+
parseScheduleWindow(value);
123+
} catch (error) {
124+
ctx.addIssue({
125+
code: z.ZodIssueCode.custom,
126+
message: error instanceof Error ? error.message : String(error),
127+
});
128+
}
129+
});

packages/core/src/v3/schemas/schemas.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
TaskRunExecution,
88
V3TaskRunExecution,
99
} from "./common.js";
10+
import { ScheduleWindow } from "./scheduleWindow.js";
11+
12+
export * from "./scheduleWindow.js";
1013

1114
/*
1215
WARNING: Never import anything from ./messages here. If it's needed in both, put it here instead.
@@ -174,15 +177,6 @@ export const QueueManifest = z.object({
174177

175178
export type QueueManifest = z.infer<typeof QueueManifest>;
176179

177-
/**
178-
* A delay window after a nominal cron tick.
179-
*
180-
* The server's schedule timing domain validates and normalizes the public syntax.
181-
*/
182-
export const ScheduleWindow = z.string().min(1);
183-
184-
export type ScheduleWindow = `${bigint}${"m" | "h" | "%"}`;
185-
186180
export const ScheduleMetadata = z.object({
187181
cron: z.string(),
188182
timezone: z.string(),
Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
export type {
2-
CreateScheduleOptions,
3-
ScheduledTaskPayload,
1+
import type {
2+
CreateScheduleOptions as CoreCreateScheduleOptions,
43
ListScheduleOptions,
5-
UpdateScheduleOptions,
4+
ScheduledTaskPayload,
5+
UpdateScheduleOptions as CoreUpdateScheduleOptions,
6+
ValidatedScheduleWindow,
67
} from "@trigger.dev/core/v3";
8+
9+
export type { ListScheduleOptions, ScheduledTaskPayload };
10+
11+
export type CreateScheduleOptions<Window extends string | undefined = string | undefined> = Omit<
12+
CoreCreateScheduleOptions,
13+
"window"
14+
> & {
15+
window?: ValidatedScheduleWindow<Window>;
16+
};
17+
18+
export type UpdateScheduleOptions<Window extends string | undefined = string | undefined> = Omit<
19+
CoreUpdateScheduleOptions,
20+
"window"
21+
> & {
22+
window?: ValidatedScheduleWindow<Window>;
23+
};

0 commit comments

Comments
 (0)