Skip to content

Commit 31c8cc2

Browse files
authored
fix(client): Schedules specs can be empty (#1032)
1 parent 2dab039 commit 31c8cc2

File tree

6 files changed

+90
-31
lines changed

6 files changed

+90
-31
lines changed

packages/client/src/schedule-client.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,6 @@ function assertRequiredScheduleOptions(
143143
if (action === 'CREATE' && !(opts as ScheduleOptions).scheduleId) {
144144
throw new TypeError(`Missing ${structureName}.scheduleId`);
145145
}
146-
if (!(opts.spec.calendars?.length || opts.spec.intervals?.length || opts.spec.cronExpressions?.length)) {
147-
throw new TypeError(`At least one ${structureName}.spec.calendars, .intervals or .cronExpressions is required`);
148-
}
149146
switch (opts.action.type) {
150147
case 'startWorkflow':
151148
if (!opts.action.taskQueue) {
@@ -377,24 +374,22 @@ export class ScheduleClient extends BaseClient {
377374
);
378375

379376
for (const raw of response.schedules ?? []) {
380-
if (!raw.info?.spec) continue;
381-
382377
yield <ScheduleSummary>{
383378
scheduleId: raw.scheduleId,
384379

385-
spec: decodeScheduleSpec(raw.info.spec),
386-
action: {
380+
spec: decodeScheduleSpec(raw.info?.spec ?? {}),
381+
action: raw.info?.workflowType?.name && {
387382
type: 'startWorkflow',
388-
workflowType: raw.info.workflowType?.name,
383+
workflowType: raw.info.workflowType.name,
389384
},
390385
memo: await decodeMapFromPayloads(this.dataConverter, raw.memo?.fields),
391386
searchAttributes: decodeSearchAttributes(raw.searchAttributes),
392387
state: {
393-
paused: raw.info.paused === true,
394-
note: raw.info.notes ?? undefined,
388+
paused: raw.info?.paused === true,
389+
note: raw.info?.notes ?? undefined,
395390
},
396391
info: {
397-
recentActions: decodeScheduleRecentActions(raw.info.recentActions),
392+
recentActions: decodeScheduleRecentActions(raw.info?.recentActions),
398393
nextActionTimes: raw.info?.futureActionTimes?.map(tsToDate) ?? [],
399394
},
400395
};

packages/client/src/schedule-helpers.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
optionalTsToMs,
2525
tsToDate,
2626
} from '@temporalio/common/lib/time';
27-
import { RequireAtLeastOne } from '@temporalio/common/src/type-helpers';
2827
import {
2928
CalendarSpec,
3029
CalendarSpecDescription,
@@ -300,9 +299,7 @@ export function encodeScheduleState(state?: ScheduleOptions['state']): temporal.
300299
};
301300
}
302301

303-
export function decodeScheduleSpec(
304-
pb: temporal.api.schedule.v1.IScheduleSpec
305-
): RequireAtLeastOne<ScheduleSpecDescription, 'calendars' | 'intervals'> {
302+
export function decodeScheduleSpec(pb: temporal.api.schedule.v1.IScheduleSpec): ScheduleSpecDescription {
306303
// Note: the server will have compiled calendar and cron_string fields into
307304
// structured_calendar (and maybe interval and timezone_name), so at this
308305
// point, we'll see only structured_calendar, interval, etc.

packages/client/src/schedule-types.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { checkExtends, Replace, RequireAtLeastOne } from '@temporalio/common/lib/type-helpers';
1+
import { checkExtends, Replace } from '@temporalio/common/lib/type-helpers';
22
import { SearchAttributes, Workflow } from '@temporalio/common';
33
import type { temporal } from '@temporalio/proto';
44
import { WorkflowStartOptions } from './workflow-options';
@@ -19,7 +19,7 @@ export interface ScheduleOptions<A extends ScheduleOptionsAction = ScheduleOptio
1919
/**
2020
* When Actions should be taken
2121
*/
22-
spec: RequireAtLeastOne<ScheduleSpec, 'calendars' | 'intervals' | 'cronExpressions'>;
22+
spec: ScheduleSpec;
2323

2424
/**
2525
* Which Action to take
@@ -157,6 +157,9 @@ export type CompiledScheduleUpdateOptions = Replace<
157157
/**
158158
* A summary description of an existing Schedule, as returned by {@link ScheduleClient.list}.
159159
*
160+
* Note that schedule listing is eventual consistent; some returned properties may therefore
161+
* be undefined or incorrect for some time after creating or modifying a schedule.
162+
*
160163
* @experimental
161164
*/
162165
export interface ScheduleSummary {
@@ -168,12 +171,12 @@ export interface ScheduleSummary {
168171
/**
169172
* When will Actions be taken.
170173
*/
171-
spec: RequireAtLeastOne<ScheduleSpecDescription, 'calendars' | 'intervals'>;
174+
spec?: ScheduleSpecDescription;
172175

173176
/**
174177
* The Action that will be taken.
175178
*/
176-
action: ScheduleSummaryAction;
179+
action?: ScheduleSummaryAction;
177180

178181
/**
179182
* Additional non-indexed information attached to the Schedule.
@@ -187,7 +190,7 @@ export interface ScheduleSummary {
187190
*
188191
* Values are always converted using {@link JsonPayloadConverter}, even when a custom Data Converter is provided.
189192
*/
190-
searchAttributes: SearchAttributes;
193+
searchAttributes?: SearchAttributes;
191194

192195
state: {
193196
/**
@@ -249,7 +252,17 @@ export interface ScheduleExecutionStartWorkflowActionResult {
249252
*
250253
* @experimental
251254
*/
252-
export type ScheduleDescription = ScheduleSummary & {
255+
export type ScheduleDescription = {
256+
/**
257+
* The Schedule Id. We recommend using a meaningful business identifier.
258+
*/
259+
scheduleId: string;
260+
261+
/**
262+
* When will Actions be taken.
263+
*/
264+
spec: ScheduleSpecDescription;
265+
253266
/**
254267
* The Action that will be taken.
255268
*/
@@ -282,7 +295,32 @@ export type ScheduleDescription = ScheduleSummary & {
282295
pauseOnFailure: boolean;
283296
};
284297

285-
state: ScheduleSummary['state'] & {
298+
/**
299+
* Additional non-indexed information attached to the Schedule.
300+
* The values can be anything that is serializable by the {@link DataConverter}.
301+
*/
302+
memo?: Record<string, unknown>;
303+
304+
/**
305+
* Additional indexed information attached to the Schedule.
306+
* More info: https://docs.temporal.io/docs/typescript/search-attributes
307+
*
308+
* Values are always converted using {@link JsonPayloadConverter}, even when a custom Data Converter is provided.
309+
*/
310+
searchAttributes: SearchAttributes;
311+
312+
state: {
313+
/**
314+
* Whether Schedule is currently paused.
315+
*/
316+
paused: boolean;
317+
318+
/**
319+
* Informative human-readable message with contextual notes, e.g. the reason a Schedule is paused.
320+
* The system may overwrite this message on certain conditions, e.g. when pause-on-failure happens.
321+
*/
322+
note?: string;
323+
286324
/**
287325
* The Actions remaining in this Schedule.
288326
* Once this number hits `0`, no further Actions are taken (unless {@link ScheduleHandle.trigger} is called).
@@ -292,7 +330,17 @@ export type ScheduleDescription = ScheduleSummary & {
292330
remainingActions?: number;
293331
};
294332

295-
info: ScheduleSummary['info'] & {
333+
info: {
334+
/**
335+
* Most recent 10 Actions started (including manual triggers), sorted from older start time to newer.
336+
*/
337+
recentActions: ScheduleExecutionResult[];
338+
339+
/**
340+
* Scheduled time of the next 10 executions of this Schedule
341+
*/
342+
nextActionTimes: Date[];
343+
296344
/**
297345
* Number of Actions taken so far.
298346
*/
@@ -322,6 +370,9 @@ export type ScheduleDescription = ScheduleSummary & {
322370
raw: temporal.api.workflowservice.v1.IDescribeScheduleResponse;
323371
};
324372

373+
// Invariant: ScheduleDescription contains at least the same fields as ScheduleSummary
374+
checkExtends<ScheduleSummary, ScheduleDescription>();
375+
325376
// Invariant: An existing ScheduleDescription can be used as template to create a new Schedule
326377
checkExtends<ScheduleOptions, ScheduleDescription>();
327378

packages/common/src/type-helpers.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,6 @@ export function checkExtends<_Orig, _Copy extends _Orig>(): void {
1212

1313
export type Replace<Base, New> = Omit<Base, keyof New> & New;
1414

15-
export type RequireAtLeastOne<Base, Keys extends keyof Base> = Omit<Base, Keys> &
16-
{
17-
[K in Keys]-?: Required<Pick<Base, K>> & Partial<Pick<Base, Exclude<Keys, K>>>;
18-
}[Keys];
19-
2015
export function isRecord(value: unknown): value is Record<string, unknown> {
2116
return typeof value === 'object' && value !== null;
2217
}

packages/test/src/test-schedules.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,29 @@ if (RUN_INTEGRATION_TESTS) {
122122
}
123123
});
124124

125+
test('Can create schedule without any spec', async (t) => {
126+
const { client } = t.context;
127+
const scheduleId = `can-create-schedule-without-any-spec-${randomUUID()}`;
128+
const handle = await client.schedule.create({
129+
scheduleId,
130+
spec: {},
131+
action: {
132+
type: 'startWorkflow',
133+
workflowType: dummyWorkflow,
134+
taskQueue,
135+
},
136+
});
137+
138+
try {
139+
const describedSchedule = await handle.describe();
140+
t.deepEqual(describedSchedule.spec.calendars, []);
141+
t.deepEqual(describedSchedule.spec.intervals, []);
142+
t.deepEqual(describedSchedule.spec.skip, []);
143+
} finally {
144+
await handle.delete();
145+
}
146+
});
147+
125148
test('Can create schedule with startWorkflow action (no arg)', async (t) => {
126149
const { client } = t.context;
127150
const scheduleId = `can-create-schedule-with-startWorkflow-action-${randomUUID()}`;

packages/worker/src/worker-options.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,7 @@ export type WorkerOptionsWithDefaults = WorkerOptions &
345345
/**
346346
* Controls the number of Worker threads the Worker should create.
347347
*
348-
* Threads are used to create {@link https://nodejs.org/api/vm.html | vm }s for the
349-
*
350-
* isolated Workflow environment.
348+
* Threads are used to create {@link https://nodejs.org/api/vm.html | vm }s for the isolated Workflow environment.
351349
*
352350
* New Workflows are created on this pool in a round-robin fashion.
353351
*

0 commit comments

Comments
 (0)