Skip to content

Commit 5411868

Browse files
committed
feat(schedule-actions): add backfill schedule API (PR05)
Wire BackfillSchedule gRPC method, route handler with Zod validation and ISO-to-Timestamp transform, and POST API route per SLICE-5.
1 parent cb6d7bc commit 5411868

9 files changed

Lines changed: 354 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type NextRequest } from 'next/server';
2+
3+
import { backfillSchedule } from '@/route-handlers/backfill-schedule/backfill-schedule';
4+
import { type RouteParams } from '@/route-handlers/backfill-schedule/backfill-schedule.types';
5+
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
6+
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';
7+
8+
export async function POST(
9+
request: NextRequest,
10+
options: { params: RouteParams }
11+
) {
12+
return routeHandlerWithMiddlewares(
13+
backfillSchedule,
14+
request,
15+
options,
16+
routeHandlersDefaultMiddlewares
17+
);
18+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { status } from '@grpc/grpc-js';
2+
import { NextRequest } from 'next/server';
3+
4+
import { ScheduleOverlapPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleOverlapPolicy';
5+
import { GRPCError } from '@/utils/grpc/grpc-error';
6+
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';
7+
8+
import { backfillSchedule } from '../backfill-schedule';
9+
import { type Context } from '../backfill-schedule.types';
10+
11+
const defaultRequestBody = {
12+
startTime: '2026-01-01T00:00:00.000Z',
13+
endTime: '2026-01-02T00:00:00.000Z',
14+
};
15+
16+
describe(backfillSchedule.name, () => {
17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
21+
it('calls backfillSchedule and returns valid response', async () => {
22+
const { res, mockBackfillSchedule } = await setup({});
23+
24+
expect(mockBackfillSchedule).toHaveBeenCalledWith({
25+
domain: 'mock-domain',
26+
scheduleId: 'mock-schedule-id',
27+
startTime: { seconds: 1_767_225_600, nanos: 0 },
28+
endTime: { seconds: 1_767_312_000, nanos: 0 },
29+
overlapPolicy: undefined,
30+
backfillId: undefined,
31+
});
32+
33+
expect(res.status).toEqual(200);
34+
expect(await res.json()).toEqual({});
35+
});
36+
37+
it('passes optional overlapPolicy and backfillId to gRPC', async () => {
38+
const { mockBackfillSchedule } = await setup({
39+
requestBody: JSON.stringify({
40+
...defaultRequestBody,
41+
overlapPolicy: ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_BUFFER,
42+
backfillId: 'custom-backfill-id',
43+
}),
44+
});
45+
46+
expect(mockBackfillSchedule).toHaveBeenCalledWith(
47+
expect.objectContaining({
48+
overlapPolicy: ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_BUFFER,
49+
backfillId: 'custom-backfill-id',
50+
})
51+
);
52+
});
53+
54+
it('returns an error when startTime is missing', async () => {
55+
const { res, mockBackfillSchedule } = await setup({
56+
requestBody: JSON.stringify({
57+
endTime: defaultRequestBody.endTime,
58+
}),
59+
});
60+
61+
expect(mockBackfillSchedule).not.toHaveBeenCalled();
62+
expect(res.status).toEqual(400);
63+
expect(await res.json()).toEqual(
64+
expect.objectContaining({
65+
message: 'Invalid values provided for schedule backfill',
66+
})
67+
);
68+
});
69+
70+
it('returns an error when endTime is before startTime', async () => {
71+
const { res, mockBackfillSchedule } = await setup({
72+
requestBody: JSON.stringify({
73+
startTime: '2026-01-02T00:00:00.000Z',
74+
endTime: '2026-01-01T00:00:00.000Z',
75+
}),
76+
});
77+
78+
expect(mockBackfillSchedule).not.toHaveBeenCalled();
79+
expect(res.status).toEqual(400);
80+
expect(await res.json()).toEqual(
81+
expect.objectContaining({
82+
message: 'Invalid values provided for schedule backfill',
83+
})
84+
);
85+
});
86+
87+
it('returns an error if backfillSchedule throws a GRPCError', async () => {
88+
const { res, mockBackfillSchedule } = await setup({
89+
error: new GRPCError('Schedule not found', {
90+
grpcStatusCode: status.NOT_FOUND,
91+
}),
92+
});
93+
94+
expect(mockBackfillSchedule).toHaveBeenCalled();
95+
expect(res.status).toEqual(404);
96+
expect(await res.json()).toEqual(
97+
expect.objectContaining({
98+
message: 'Schedule not found',
99+
})
100+
);
101+
});
102+
});
103+
104+
async function setup({
105+
requestBody = JSON.stringify(defaultRequestBody),
106+
scheduleId = 'mock-schedule-id',
107+
error,
108+
}: {
109+
requestBody?: string;
110+
scheduleId?: string;
111+
error?: GRPCError;
112+
}) {
113+
const mockBackfillSchedule = jest
114+
.spyOn(mockGrpcClusterMethods, 'backfillSchedule')
115+
.mockImplementation(async () => {
116+
if (error) {
117+
throw error;
118+
}
119+
return {};
120+
});
121+
122+
const res = await backfillSchedule(
123+
new NextRequest('http://localhost', {
124+
method: 'POST',
125+
body: requestBody,
126+
}),
127+
{
128+
params: {
129+
domain: 'mock-domain',
130+
cluster: 'mock-cluster',
131+
scheduleId,
132+
},
133+
},
134+
{
135+
grpcClusterMethods: mockGrpcClusterMethods,
136+
} as Context
137+
);
138+
139+
return { res, mockBackfillSchedule };
140+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type NextRequest, NextResponse } from 'next/server';
2+
3+
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
4+
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';
5+
6+
import transformBackfillScheduleBodyToGrpcInput from './helpers/transform-backfill-schedule-body-to-grpc-input';
7+
import backfillScheduleRequestBodySchema from './schemas/backfill-schedule-request-body-schema';
8+
import {
9+
type BackfillScheduleResponse,
10+
type Context,
11+
type RequestParams,
12+
} from './backfill-schedule.types';
13+
14+
export async function backfillSchedule(
15+
request: NextRequest,
16+
requestParams: RequestParams,
17+
ctx: Context
18+
) {
19+
const requestBody = await request.json().catch(() => ({}));
20+
const { data, error } =
21+
backfillScheduleRequestBodySchema.safeParse(requestBody);
22+
23+
if (error) {
24+
return NextResponse.json(
25+
{
26+
message: 'Invalid values provided for schedule backfill',
27+
validationErrors: error.errors,
28+
},
29+
{ status: 400 }
30+
);
31+
}
32+
33+
const params = requestParams.params;
34+
35+
try {
36+
const response = await ctx.grpcClusterMethods.backfillSchedule(
37+
transformBackfillScheduleBodyToGrpcInput({
38+
domain: params.domain,
39+
scheduleId: params.scheduleId,
40+
body: data,
41+
})
42+
);
43+
44+
return NextResponse.json(response satisfies BackfillScheduleResponse);
45+
} catch (e) {
46+
logger.error<RouteHandlerErrorPayload>(
47+
{ requestParams: params, error: e },
48+
'Error backfilling schedule'
49+
);
50+
51+
return NextResponse.json(
52+
{
53+
message:
54+
e instanceof GRPCError ? e.message : 'Error backfilling schedule',
55+
cause: e,
56+
},
57+
{ status: getHTTPStatusCode(e) }
58+
);
59+
}
60+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type z } from 'zod';
2+
3+
import { type BackfillScheduleResponse as BackfillScheduleResponseProto } from '@/__generated__/proto-ts/uber/cadence/api/v1/BackfillScheduleResponse';
4+
import { type DefaultMiddlewaresContext } from '@/utils/route-handlers-middleware';
5+
6+
import { type backfillScheduleRequestBodySchema } from './schemas/backfill-schedule-request-body-schema';
7+
8+
export type RouteParams = {
9+
domain: string;
10+
cluster: string;
11+
scheduleId: string;
12+
};
13+
14+
export type RequestParams = {
15+
params: RouteParams;
16+
};
17+
18+
export type BackfillScheduleResponse = BackfillScheduleResponseProto;
19+
20+
export type BackfillScheduleRequestBody = z.infer<
21+
typeof backfillScheduleRequestBodySchema
22+
>;
23+
24+
/** POST body shape shared by the backfill form UI. */
25+
export type BackfillScheduleSubmissionData = BackfillScheduleRequestBody;
26+
27+
export type Context = DefaultMiddlewaresContext;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { ScheduleOverlapPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleOverlapPolicy';
2+
3+
import transformBackfillScheduleBodyToGrpcInput from '../transform-backfill-schedule-body-to-grpc-input';
4+
5+
describe(transformBackfillScheduleBodyToGrpcInput.name, () => {
6+
it('transforms ISO strings to gRPC timestamps', () => {
7+
expect(
8+
transformBackfillScheduleBodyToGrpcInput({
9+
domain: 'test-domain',
10+
scheduleId: 'test-schedule',
11+
body: {
12+
startTime: '2026-01-01T00:00:00.000Z',
13+
endTime: '2026-01-02T00:00:00.000Z',
14+
},
15+
})
16+
).toEqual({
17+
domain: 'test-domain',
18+
scheduleId: 'test-schedule',
19+
startTime: { seconds: 1_767_225_600, nanos: 0 },
20+
endTime: { seconds: 1_767_312_000, nanos: 0 },
21+
overlapPolicy: undefined,
22+
backfillId: undefined,
23+
});
24+
});
25+
26+
it('passes optional overlapPolicy and backfillId', () => {
27+
expect(
28+
transformBackfillScheduleBodyToGrpcInput({
29+
domain: 'test-domain',
30+
scheduleId: 'test-schedule',
31+
body: {
32+
startTime: '2026-01-01T00:00:00.000Z',
33+
endTime: '2026-01-02T00:00:00.000Z',
34+
overlapPolicy: ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_BUFFER,
35+
backfillId: 'backfill-123',
36+
},
37+
})
38+
).toEqual(
39+
expect.objectContaining({
40+
overlapPolicy: ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_BUFFER,
41+
backfillId: 'backfill-123',
42+
})
43+
);
44+
});
45+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type BackfillScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/BackfillScheduleRequest';
2+
import getGrpcTimestampFromIso from '@/utils/datetime/get-grpc-timestamp-from-iso';
3+
4+
import { type BackfillScheduleRequestBody } from '../backfill-schedule.types';
5+
6+
export default function transformBackfillScheduleBodyToGrpcInput({
7+
domain,
8+
scheduleId,
9+
body,
10+
}: {
11+
domain: string;
12+
scheduleId: string;
13+
body: BackfillScheduleRequestBody;
14+
}): BackfillScheduleRequest__Input {
15+
return {
16+
domain,
17+
scheduleId,
18+
startTime: getGrpcTimestampFromIso(body.startTime),
19+
endTime: getGrpcTimestampFromIso(body.endTime),
20+
overlapPolicy: body.overlapPolicy,
21+
backfillId: body.backfillId,
22+
};
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from 'zod';
2+
3+
import { ScheduleOverlapPolicy } from '@/__generated__/proto-ts/uber/cadence/api/v1/ScheduleOverlapPolicy';
4+
import getSchedulePeriodError from '@/route-handlers/create-schedule/helpers/get-schedule-period-error';
5+
6+
export const backfillScheduleRequestBodySchema = z
7+
.object({
8+
startTime: z.string().datetime(),
9+
endTime: z.string().datetime(),
10+
overlapPolicy: z.nativeEnum(ScheduleOverlapPolicy).optional(),
11+
backfillId: z.string().optional(),
12+
})
13+
.superRefine((data, ctx) => {
14+
const schedulePeriodError = getSchedulePeriodError(
15+
data.startTime,
16+
data.endTime
17+
);
18+
19+
if (schedulePeriodError) {
20+
ctx.addIssue({
21+
code: z.ZodIssueCode.custom,
22+
message: schedulePeriodError.message,
23+
path: ['endTime'],
24+
});
25+
}
26+
});
27+
28+
export default backfillScheduleRequestBodySchema;

src/utils/grpc/grpc-client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { type DescribeClusterResponse } from '@/__generated__/proto-ts/uber/cade
33
import { type DescribeWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/admin/v1/DescribeWorkflowExecutionRequest';
44
import { type CountWorkflowExecutionsRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/CountWorkflowExecutionsRequest';
55
import { type CountWorkflowExecutionsResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/CountWorkflowExecutionsResponse';
6+
import { type BackfillScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/BackfillScheduleRequest';
7+
import { type BackfillScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/BackfillScheduleResponse';
68
import { type CreateScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/CreateScheduleRequest';
79
import { type CreateScheduleResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/CreateScheduleResponse';
810
import { type DeleteScheduleRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/DeleteScheduleRequest';
@@ -88,6 +90,9 @@ export type GRPCClusterMethods = {
8890
createSchedule: (
8991
payload: CreateScheduleRequest__Input
9092
) => Promise<CreateScheduleResponse>;
93+
backfillSchedule: (
94+
payload: BackfillScheduleRequest__Input
95+
) => Promise<BackfillScheduleResponse>;
9196
deleteSchedule: (
9297
payload: DeleteScheduleRequest__Input
9398
) => Promise<DeleteScheduleResponse>;
@@ -275,6 +280,13 @@ const getClusterServicesMethods = async (
275280
method: 'CreateSchedule',
276281
metadata: metadata,
277282
}),
283+
backfillSchedule: scheduleService.request<
284+
BackfillScheduleRequest__Input,
285+
BackfillScheduleResponse
286+
>({
287+
method: 'BackfillSchedule',
288+
metadata: metadata,
289+
}),
278290
deleteSchedule: scheduleService.request<
279291
DeleteScheduleRequest__Input,
280292
DeleteScheduleResponse

src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const mockGrpcClusterMethods: GRPCClusterMethods = {
55
closedWorkflows: jest.fn(),
66
countWorkflows: jest.fn(),
77
createSchedule: jest.fn(),
8+
backfillSchedule: jest.fn(),
89
deleteSchedule: jest.fn(),
910
describeSchedule: jest.fn(),
1011
describeCluster: jest.fn(),

0 commit comments

Comments
 (0)