Skip to content

Commit bd99602

Browse files
committed
add non-null check constraint
1 parent 686efad commit bd99602

3 files changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Add server support for stable execution windows on scheduled tasks while preserving each occurrence's nominal timestamp.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import type { PrismaClient } from "@trigger.dev/database";
2+
import { describe, expect, it } from "vitest";
3+
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
4+
import { getTestServer } from "./helpers/sharedTestServer";
5+
6+
const TASK_IDENTIFIER = "scheduled-task";
7+
8+
describe("Schedules API windows", () => {
9+
it("creates, retrieves, updates, and clears a window", async () => {
10+
const server = getTestServer();
11+
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
12+
await seedScheduledTask(server.prisma, project.id, environment.id);
13+
14+
const createResponse = await server.webapp.fetch("/api/v1/schedules", {
15+
method: "POST",
16+
headers: authHeaders(apiKey),
17+
body: JSON.stringify({
18+
task: TASK_IDENTIFIER,
19+
cron: "0 * * * *",
20+
deduplicationKey: "window-lifecycle",
21+
window: "30%",
22+
}),
23+
});
24+
25+
expect(createResponse.status).toBe(200);
26+
const created = await createResponse.json();
27+
expect(created).toMatchObject({
28+
task: TASK_IDENTIFIER,
29+
timezone: "UTC",
30+
window: "30%",
31+
});
32+
33+
const retrieveResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
34+
headers: authHeaders(apiKey),
35+
});
36+
expect(retrieveResponse.status).toBe(200);
37+
await expect(retrieveResponse.json()).resolves.toMatchObject({
38+
id: created.id,
39+
window: "30%",
40+
});
41+
42+
const updateResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
43+
method: "PUT",
44+
headers: authHeaders(apiKey),
45+
body: JSON.stringify({
46+
task: TASK_IDENTIFIER,
47+
cron: "0 0 * * *",
48+
window: "2h",
49+
}),
50+
});
51+
expect(updateResponse.status).toBe(200);
52+
await expect(updateResponse.json()).resolves.toMatchObject({
53+
id: created.id,
54+
window: "2h",
55+
});
56+
57+
const clearResponse = await server.webapp.fetch(`/api/v1/schedules/${created.id}`, {
58+
method: "PUT",
59+
headers: authHeaders(apiKey),
60+
body: JSON.stringify({
61+
task: TASK_IDENTIFIER,
62+
cron: "0 0 * * *",
63+
}),
64+
});
65+
expect(clearResponse.status).toBe(200);
66+
const cleared = await clearResponse.json();
67+
expect(cleared.id).toBe(created.id);
68+
expect(cleared).not.toHaveProperty("window");
69+
70+
const stored = await server.prisma.taskSchedule.findUniqueOrThrow({
71+
where: { friendlyId: created.id },
72+
select: { windowDurationSeconds: true, windowPercentage: true },
73+
});
74+
expect(stored).toEqual({
75+
windowDurationSeconds: null,
76+
windowPercentage: null,
77+
});
78+
});
79+
80+
it("accepts zero duration and percentage windows", async () => {
81+
const server = getTestServer();
82+
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
83+
await seedScheduledTask(server.prisma, project.id, environment.id);
84+
85+
for (const [index, window] of ["0m", "0h", "0d", "0%"].entries()) {
86+
const response = await server.webapp.fetch("/api/v1/schedules", {
87+
method: "POST",
88+
headers: authHeaders(apiKey),
89+
body: JSON.stringify({
90+
task: TASK_IDENTIFIER,
91+
cron: "0 * * * *",
92+
deduplicationKey: `zero-window-${index}`,
93+
window,
94+
}),
95+
});
96+
97+
expect(response.status).toBe(200);
98+
await expect(response.json()).resolves.toMatchObject({
99+
window: window === "0%" ? "0%" : "0m",
100+
});
101+
}
102+
});
103+
104+
it("returns safe errors for invalid windows", async () => {
105+
const server = getTestServer();
106+
const { apiKey, project, environment } = await seedTestEnvironment(server.prisma);
107+
await seedScheduledTask(server.prisma, project.id, environment.id);
108+
109+
const invalidRequests = [
110+
{ window: 30, expectedStatus: 400 },
111+
{ window: "30.5%", expectedStatus: 422 },
112+
{ window: "2h", expectedStatus: 422 },
113+
];
114+
115+
for (const [index, { window, expectedStatus }] of invalidRequests.entries()) {
116+
const response = await server.webapp.fetch("/api/v1/schedules", {
117+
method: "POST",
118+
headers: authHeaders(apiKey),
119+
body: JSON.stringify({
120+
task: TASK_IDENTIFIER,
121+
cron: "0 * * * *",
122+
deduplicationKey: `invalid-window-${index}`,
123+
window,
124+
}),
125+
});
126+
127+
expect(response.status).toBe(expectedStatus);
128+
await expect(response.json()).resolves.toHaveProperty("error");
129+
}
130+
});
131+
});
132+
133+
function authHeaders(apiKey: string) {
134+
return {
135+
Authorization: `Bearer ${apiKey}`,
136+
"Content-Type": "application/json",
137+
};
138+
}
139+
140+
async function seedScheduledTask(
141+
prisma: PrismaClient,
142+
projectId: string,
143+
runtimeEnvironmentId: string
144+
) {
145+
const worker = await prisma.backgroundWorker.create({
146+
data: {
147+
friendlyId: `worker_${runtimeEnvironmentId}`,
148+
contentHash: `hash_${runtimeEnvironmentId}`,
149+
version: "20260811.1",
150+
metadata: {},
151+
projectId,
152+
runtimeEnvironmentId,
153+
},
154+
});
155+
156+
await prisma.backgroundWorkerTask.create({
157+
data: {
158+
friendlyId: `task_${runtimeEnvironmentId}`,
159+
slug: TASK_IDENTIFIER,
160+
filePath: "src/trigger/scheduled-task.ts",
161+
workerId: worker.id,
162+
projectId,
163+
runtimeEnvironmentId,
164+
triggerSource: "SCHEDULED",
165+
},
166+
});
167+
}

internal-packages/database/prisma/migrations/20260810130446_add_cron_spread_fields/migration.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ ALTER TABLE "public"."TaskSchedule"
33
ADD COLUMN "windowDurationSeconds" INTEGER,
44
ADD COLUMN "windowPercentage" INTEGER;
55

6+
ALTER TABLE "public"."TaskSchedule"
7+
ADD CONSTRAINT "TaskSchedule_window_exclusive"
8+
CHECK (
9+
"windowDurationSeconds" IS NULL
10+
OR "windowPercentage" IS NULL
11+
) NOT VALID;
12+
613
-- AlterTable
714
ALTER TABLE "public"."TaskScheduleInstance"
815
ADD COLUMN "schedulePhase" INTEGER;

0 commit comments

Comments
 (0)