Skip to content

Commit fc3cbaa

Browse files
authored
[workers-utils,miniflare,wrangler] Accept workflow entries in the exports config (#15779)
1 parent cd60c9c commit fc3cbaa

31 files changed

Lines changed: 1803 additions & 320 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@cloudflare/workers-utils": minor
3+
"@cloudflare/deploy-helpers": minor
4+
"@cloudflare/config": minor
5+
"miniflare": minor
6+
"wrangler": minor
7+
---
8+
9+
Support `workflow` entries in the `exports` configuration map
10+
11+
A Worker can now declare the Workflows it defines in `exports`, keyed by the `WorkflowEntrypoint` class name:
12+
13+
```jsonc
14+
{
15+
"exports": {
16+
"MyWorkflow": {
17+
"type": "workflow",
18+
"name": "my-workflow",
19+
"limits": { "steps": 100 },
20+
"schedules": "0 * * * *",
21+
},
22+
},
23+
}
24+
```
25+
26+
A `workflow` export accepts the same settings as a `workflows` binding: `limits`, `concurrency`, `schedules`, and `default_retention`. `wrangler deploy` and `wrangler versions upload` send these entries to the upload API by name, and `wrangler deploy` and `wrangler triggers deploy` provision the Workflow with its settings, just as they do for `workflows` bindings owned by the Worker. A Workflow may be declared both as a binding and as an export, as long as both declarations use the same class and do not set the same setting to different values. A binding to another Worker's Workflow cannot share a name with an export. `@cloudflare/config` adds the matching `exports.workflow()` helper. Local development does not yet act on these entries.

‎packages/config/src/__tests__/config-definition.test-d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ const projectConfig = defineConfig({
2323
env: { MESSAGE: bindings.text("hello") },
2424
exports: {
2525
Counter: workerExports.durableObject({ storage: "sqlite" }),
26+
Greeting: workerExports.workflow({ name: "greeting" }),
2627
},
2728
}),
2829
});
2930

31+
// @ts-expect-error a workflow export requires a name
32+
workerExports.workflow({ limits: { steps: 10 } });
33+
3034
defineConfig({ accountId: "account-id", complianceRegion: "public" });
3135
const plainConfig = {
3236
worker: {

‎packages/config/src/__tests__/convert.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,38 @@ describe("convertToWranglerConfig", () => {
10501050
});
10511051
});
10521052

1053+
it("passes workflow exports through", ({ expect }) => {
1054+
const result = convertToWranglerConfig({
1055+
worker: {
1056+
...baseWorker,
1057+
exports: {
1058+
GreetingWorkflow: { type: "workflow", name: "greeting" },
1059+
BatchWorkflow: {
1060+
type: "workflow",
1061+
name: "batch",
1062+
limits: { steps: 10 },
1063+
concurrency: { limit: 2 },
1064+
schedules: "0 * * * *",
1065+
default_retention: { success_retention: "3 days" },
1066+
},
1067+
},
1068+
},
1069+
containers: [],
1070+
});
1071+
1072+
expect((result as { exports?: unknown }).exports).toEqual({
1073+
GreetingWorkflow: { type: "workflow", name: "greeting" },
1074+
BatchWorkflow: {
1075+
type: "workflow",
1076+
name: "batch",
1077+
limits: { steps: 10 },
1078+
concurrency: { limit: 2 },
1079+
schedules: "0 * * * *",
1080+
default_retention: { success_retention: "3 days" },
1081+
},
1082+
});
1083+
});
1084+
10531085
it("emits no exports key when the map is empty", ({ expect }) => {
10541086
const result = convertToWranglerConfig({
10551087
worker: {
@@ -1065,13 +1097,13 @@ describe("convertToWranglerConfig", () => {
10651097
const config = {
10661098
...baseWorker,
10671099
exports: {
1068-
FutureExport: { type: "workflow" },
1100+
FutureExport: { type: "future" },
10691101
},
10701102
} as unknown as NonNullable<ParsedInputConfig["worker"]>;
10711103

10721104
expect(() =>
10731105
convertToWranglerConfig({ worker: config, containers: [] })
1074-
).toThrow(/Unknown export types found: - FutureExport : workflow/);
1106+
).toThrow(/Unknown export types found: - FutureExport : future/);
10751107
});
10761108
});
10771109

‎packages/config/src/__tests__/schema.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,92 @@ describe("ExportSchema", () => {
14951495
expect(result.success).toBe(true);
14961496
});
14971497

1498+
it("accepts a workflow export", ({ expect }) => {
1499+
const result = parseExports({
1500+
GreetingWorkflow: exportConfig.workflow({ name: "greeting" }),
1501+
BatchWorkflow: exportConfig.workflow({
1502+
name: "batch",
1503+
limits: { steps: 10 },
1504+
}),
1505+
});
1506+
1507+
expect(result.success).toBe(true);
1508+
expect(result.data?.exports).toEqual({
1509+
GreetingWorkflow: { type: "workflow", name: "greeting" },
1510+
BatchWorkflow: { type: "workflow", name: "batch", limits: { steps: 10 } },
1511+
});
1512+
});
1513+
1514+
it("accepts every Workflow setting on a workflow export", ({ expect }) => {
1515+
const scheduled = exportConfig.workflow({
1516+
name: "scheduled",
1517+
limits: { steps: 10 },
1518+
concurrency: { limit: 2 },
1519+
schedules: ["0 * * * *"],
1520+
default_retention: {
1521+
success_retention: "3 days",
1522+
error_retention: 86_400_000,
1523+
},
1524+
});
1525+
const result = parseExports({ ScheduledWorkflow: scheduled });
1526+
1527+
expect(result.success).toBe(true);
1528+
expect(result.data?.exports).toEqual({ ScheduledWorkflow: scheduled });
1529+
});
1530+
1531+
it("rejects invalid Workflow settings on a workflow export", ({ expect }) => {
1532+
for (const settings of [
1533+
{ schedules: "" },
1534+
{ schedules: [] },
1535+
{ schedules: [""] },
1536+
{ concurrency: { limit: 0 } },
1537+
{ default_retention: { success_retention: -1 } },
1538+
{ default_retention: { error_retention: "" } },
1539+
]) {
1540+
const result = parseExports({
1541+
GreetingWorkflow: { type: "workflow", name: "greeting", ...settings },
1542+
});
1543+
1544+
expect(result.success).toBe(false);
1545+
}
1546+
});
1547+
1548+
it("rejects a workflow export without a name", ({ expect }) => {
1549+
const result = parseExports({
1550+
GreetingWorkflow: { type: "workflow" },
1551+
});
1552+
1553+
expect(result.success).toBe(false);
1554+
});
1555+
1556+
it("rejects a workflow step limit that is not a positive integer", ({
1557+
expect,
1558+
}) => {
1559+
for (const steps of [0, -1, 1.5]) {
1560+
const result = parseExports({
1561+
GreetingWorkflow: {
1562+
type: "workflow",
1563+
name: "greeting",
1564+
limits: { steps },
1565+
},
1566+
});
1567+
1568+
expect(result.success).toBe(false);
1569+
}
1570+
});
1571+
1572+
it("rejects Durable Object fields on a workflow export", ({ expect }) => {
1573+
const result = parseExports({
1574+
GreetingWorkflow: {
1575+
type: "workflow",
1576+
name: "greeting",
1577+
storage: "sqlite",
1578+
},
1579+
});
1580+
1581+
expect(result.success).toBe(false);
1582+
});
1583+
14981584
// Containers require the SQLite storage engine. The check below is the type
14991585
// half of that rule: `tsc` checks this body (it is never called), so a missing
15001586
// error fails `check:type` via the unused `@ts-expect-error` directives.

‎packages/config/src/convert.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,7 +860,7 @@ function convertExports(
860860
const converted: Exports = {};
861861
const unknownExports: typeof exports = {};
862862
for (const [exportName, value] of Object.entries(exports)) {
863-
if (value.type === "worker") {
863+
if (value.type === "worker" || value.type === "workflow") {
864864
converted[exportName] = value;
865865
continue;
866866
}

‎packages/config/src/exports.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,39 @@ export interface WorkerEntrypointExport extends WorkerEntrypointExportOptions {
161161
type: "worker";
162162
}
163163

164+
export interface WorkflowExportOptions {
165+
/**
166+
* The name of the Workflow. It identifies the Workflow's instances and must
167+
* be unique within the account.
168+
*/
169+
name: string;
170+
limits?: {
171+
/** Maximum number of steps a single Workflow instance may run. */
172+
steps?: number;
173+
};
174+
concurrency?: {
175+
/** Maximum number of Workflow instances that can run concurrently. */
176+
limit?: number;
177+
};
178+
/** Cron schedule(s) that automatically trigger Workflow instances. */
179+
schedules?: string | string[];
180+
/**
181+
* Default retention for instances of this Workflow, applied when an instance
182+
* does not set its own retention. Accepts milliseconds or a duration string
183+
* such as `"3 days"`.
184+
*/
185+
default_retention?: {
186+
/** How long to retain instances that completed successfully or were terminated. */
187+
success_retention?: number | string;
188+
/** How long to retain errored instances. */
189+
error_retention?: number | string;
190+
};
191+
}
192+
193+
export interface WorkflowExport extends WorkflowExportOptions {
194+
type: "workflow";
195+
}
196+
164197
/**
165198
* Configuration for named exports declared by the Worker. Each entry's
166199
* key is the exported class name; the value configures the export.
@@ -215,6 +248,15 @@ export interface Exports {
215248

216249
/** Declares a WorkerEntrypoint export defined by this Worker. */
217250
worker(options?: WorkerEntrypointExportOptions): WorkerEntrypointExport;
251+
252+
/**
253+
* Declares a Workflow defined by this Worker. The export's key must name a
254+
* class that extends `WorkflowEntrypoint`.
255+
*
256+
* For more information about Workflows, see the documentation at
257+
* https://developers.cloudflare.com/workflows/
258+
*/
259+
workflow(options: WorkflowExportOptions): WorkflowExport;
218260
}
219261

220262
function durableObject<
@@ -253,6 +295,10 @@ function worker(
253295
return { type: "worker", ...options };
254296
}
255297

298+
function workflow(options: WorkflowExportOptions): WorkflowExport {
299+
return { type: "workflow", ...options };
300+
}
301+
256302
/**
257303
* Exports builder for configuring Worker exports.
258304
*
@@ -273,6 +319,7 @@ function worker(
273319
* OldName: exports.durableObject({ state: "renamed", renamedTo: "NewName" }),
274320
* Outgoing: exports.durableObject({ state: "transferred", transferredTo: "target-worker" }),
275321
* Incoming: exports.durableObject({ state: "expecting-transfer", storage: "sqlite", transferFrom: "source-worker" }),
322+
* MyWorkflow: exports.workflow({ name: "my-workflow", limits: { steps: 100 }, schedules: "0 * * * *" }),
276323
* },
277324
* });
278325
*
@@ -282,4 +329,5 @@ function worker(
282329
export const exports: Exports = {
283330
durableObject,
284331
worker,
332+
workflow,
285333
};

‎packages/config/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
validateSingletonBindings,
3131
WorkerBindingSchema,
3232
WorkerEntrypointExportSchema,
33+
WorkflowExportSchema,
3334
} from "./schema";
3435
export { generateTypes } from "./generate";
3536
export { convertToWranglerConfig } from "./convert";

‎packages/config/src/public.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export type {
6666
DurableObjectExpectingTransferExport,
6767
WorkerEntrypointExport,
6868
WorkerEntrypointExportOptions,
69+
WorkflowExport,
70+
WorkflowExportOptions,
6971
} from "./exports";
7072
export { exports } from "./exports";
7173
export type {

‎packages/config/src/schema.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,31 @@ export const WorkerEntrypointExportSchema = z.strictObject({
588588
cache: z.strictObject({ enabled: z.boolean() }).optional(),
589589
});
590590

591+
const WorkflowRetentionSchema = z.union([
592+
z.number().int().min(1),
593+
z.string().min(1),
594+
]);
595+
596+
export const WorkflowExportSchema = z.strictObject({
597+
type: z.literal("workflow"),
598+
name: z.string(),
599+
limits: z
600+
.strictObject({ steps: z.number().int().min(1).optional() })
601+
.optional(),
602+
concurrency: z
603+
.strictObject({ limit: z.number().int().min(1).optional() })
604+
.optional(),
605+
schedules: z
606+
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
607+
.optional(),
608+
default_retention: z
609+
.strictObject({
610+
success_retention: WorkflowRetentionSchema.optional(),
611+
error_retention: WorkflowRetentionSchema.optional(),
612+
})
613+
.optional(),
614+
});
615+
591616
// Containers are only supported on the SQLite storage engine, so each live
592617
// variant enters the union split by `storage`: `container` exists on the
593618
// `sqlite` branch and is absent from the `legacy-kv` one. Splitting rather than
@@ -619,12 +644,7 @@ export const ExportSchema = z.union([
619644
DurableObjectExpectingTransferSqliteExportSchema,
620645
DurableObjectExpectingTransferLegacyKvExportSchema,
621646
WorkerEntrypointExportSchema,
622-
// TODO: support Workflows
623-
// z.strictObject({
624-
// type: z.literal("workflow"),
625-
// name: z.string(),
626-
// limits: z.strictObject({ steps: z.number().optional() }).optional(),
627-
// }),
647+
WorkflowExportSchema,
628648
]);
629649

630650
const LimitsSchema = z.strictObject({

‎packages/config/src/types.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type {
5353
DurableObjectRenamedExport,
5454
DurableObjectTransferredExport,
5555
WorkerEntrypointExport,
56+
WorkflowExport,
5657
} from "./exports";
5758
import type { WorkerModule } from "./inference";
5859
import type {
@@ -140,16 +141,17 @@ type Trigger =
140141
/**
141142
* Union of all export definitions accepted in `exports`. Worker entries
142143
* configure WorkerEntrypoint exports. Durable Object entries configure live
143-
* classes and tombstone lifecycle operations.
144+
* classes and tombstone lifecycle operations. Workflow entries declare the
145+
* Workflows defined by the Worker.
144146
*/
145147
type Export =
146148
| DurableObjectCreatedExport
147149
| DurableObjectDeletedExport
148150
| DurableObjectRenamedExport
149151
| DurableObjectTransferredExport
150152
| DurableObjectExpectingTransferExport
151-
| WorkerEntrypointExport;
152-
// TODO: support Workflows
153+
| WorkerEntrypointExport
154+
| WorkflowExport;
153155

154156
/** An image source accepted in an authored Container configuration. */
155157
type ContainerImage =
@@ -631,13 +633,16 @@ export interface WorkerConfig {
631633
* Configuration for named exports declared by the Worker. Each entry's
632634
* key is the exported class name; the value configures the export.
633635
*
634-
* Only one export kind is currently supported:
635-
*
636636
* - Construct entries with `exports.durableObject(...)`.
637637
* - Declares Durable Object classes exported from this Worker.
638638
* For more information about Durable Objects, see the documentation at
639639
* https://developers.cloudflare.com/workers/learning/using-durable-objects.
640640
* For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects.
641+
*
642+
* - Construct entries with `exports.workflow(...)`.
643+
* - Declares Workflows defined by this Worker.
644+
* For more information about Workflows, see the documentation at
645+
* https://developers.cloudflare.com/workflows/.
641646
*/
642647
exports?: Record<string, Export>;
643648
}

0 commit comments

Comments
 (0)