@notionhq/apps 0.0.36 → 0.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,13 @@ describe("discoverCapabilities", () => {
27
27
  key: "onPageCreated",
28
28
  sourcePath: path.join("src", "workflows", "onPageCreated.ts"),
29
29
  },
30
+ {
31
+ type: "workflow",
32
+ tag: "workflow",
33
+ runtime: "worker",
34
+ key: "prepareReleases",
35
+ sourcePath: path.join("src", "workflows", "prepareReleases.ts"),
36
+ },
30
37
  ]);
31
38
  });
32
39
 
@@ -2,6 +2,8 @@
2
2
  // Regenerate with: pnpm run generate
3
3
 
4
4
  import type { WorkflowConnectionDeclarations, WorkflowConnectionKeys } from "./connections.js";
5
+ import { compileWorkflowInputSchema } from "./workflow-schema.js";
6
+ import { j, type SchemaBuilder } from "./schema-builder.js";
5
7
  import { scheduled, notionPageCreated, notionPageUpdated } from "./configured-events.js";
6
8
  import type { NotionPageCreatedTrigger, NotionPageUpdatedTrigger, PageUpdatedOptions, RecurrenceTrigger } from "./configured-events.js";
7
9
  import type { DataSourceHandle } from "./notion-as-code/database.js";
@@ -82,6 +84,15 @@ export type {
82
84
  export type { NotionPageCreatedTrigger, NotionPageUpdatedTrigger, PageUpdatedOptions, RecurrenceTrigger } from "./configured-events.js";
83
85
  export type { RecurrenceSchedule } from "./notion-as-code/recurrence.js";
84
86
 
87
+ declare const manualWorkflowInputSchema: unique symbol;
88
+ const manualInputValidators = new WeakMap<object, (input: unknown) => void>();
89
+
90
+ export type ManualWorkflowTrigger<TInput extends Record<string, unknown> = Record<string, unknown>> = {
91
+ type: "workflow.manual";
92
+ inputJsonSchema: Record<string, unknown>;
93
+ readonly [manualWorkflowInputSchema]?: TInput;
94
+ };
95
+
85
96
  /** Triggers immediately after a new message is posted in Slack (includes new threads and replies) */
86
97
  export type SlackMessageTrigger<TKey extends string = string> = {
87
98
  type: "slack.message";
@@ -194,8 +205,27 @@ export type GoogleDriveOauthFilesChangedTrigger<TKey extends string = string> =
194
205
  connectionKey?: TKey;
195
206
  };
196
207
 
208
+ function manual(): ManualWorkflowTrigger<Record<string, never>>;
209
+ function manual<TInput extends Record<string, unknown>>(options: {
210
+ inputSchema: SchemaBuilder<TInput, "object">;
211
+ }): ManualWorkflowTrigger<TInput>;
212
+ function manual(options?: {
213
+ inputSchema: SchemaBuilder<Record<string, unknown>, "object">;
214
+ }): ManualWorkflowTrigger {
215
+ const { jsonSchema, validate } = compileWorkflowInputSchema(
216
+ options === undefined ? j.object({}) : options.inputSchema,
217
+ );
218
+ const trigger: ManualWorkflowTrigger = {
219
+ type: "workflow.manual",
220
+ inputJsonSchema: jsonSchema,
221
+ };
222
+ manualInputValidators.set(trigger, validate);
223
+ return trigger;
224
+ }
225
+
197
226
  /** Creators for each event that can trigger a workflow. */
198
227
  export const events = {
228
+ manual,
199
229
  /**
200
230
  * Declare that a workflow can run on `slack.message` events.
201
231
  *
@@ -414,6 +444,8 @@ export const events = {
414
444
 
415
445
  /** Trigger creators restricted to the workflow's declared provider keys. */
416
446
  export type WorkflowEventCreators<TConnections extends WorkflowConnectionDeclarations> = {
447
+ manual(): ManualWorkflowTrigger<Record<string, never>>;
448
+ manual<TInput extends Record<string, unknown>>(options: { inputSchema: SchemaBuilder<TInput, "object"> }): ManualWorkflowTrigger<TInput>;
417
449
  slackMessage(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "slack"> }): SlackMessageTrigger<WorkflowConnectionKeys<TConnections, "slack">>;
418
450
  slackReactionAdded(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "slack"> }): SlackReactionAddedTrigger<WorkflowConnectionKeys<TConnections, "slack">>;
419
451
  slackAppMention(options?: { connectionKey: WorkflowConnectionKeys<TConnections, "slack"> }): SlackAppMentionTrigger<WorkflowConnectionKeys<TConnections, "slack">>;
@@ -439,6 +471,7 @@ export type WorkflowEventCreators<TConnections extends WorkflowConnectionDeclara
439
471
 
440
472
  /** Triggers whose connection keys belong to the declared provider. */
441
473
  export type WorkflowTriggerForConnections<TConnections extends WorkflowConnectionDeclarations> =
474
+ | ManualWorkflowTrigger
442
475
  | SlackMessageTrigger<WorkflowConnectionKeys<TConnections, "slack">>
443
476
  | SlackReactionAddedTrigger<WorkflowConnectionKeys<TConnections, "slack">>
444
477
  | SlackAppMentionTrigger<WorkflowConnectionKeys<TConnections, "slack">>
@@ -468,6 +501,7 @@ export function createWorkflowEvents<TConnections extends WorkflowConnectionDecl
468
501
 
469
502
  /** Event that can trigger a workflow. */
470
503
  export type WorkflowTrigger =
504
+ | ManualWorkflowTrigger
471
505
  | SlackMessageTrigger
472
506
  | SlackReactionAddedTrigger
473
507
  | SlackAppMentionTrigger
@@ -492,6 +526,7 @@ export type WorkflowTrigger =
492
526
 
493
527
  /** All workflow trigger type strings. */
494
528
  export const WORKFLOW_TRIGGER_TYPES = [
529
+ "workflow.manual",
495
530
  "slack.message",
496
531
  "slack.reaction.added",
497
532
  "slack.app.mention",
@@ -542,3 +577,15 @@ export type WorkflowEventMap = {
542
577
  "googleDriveOauth.filesChanged": GoogleDriveOauthFilesChangedEvent;
543
578
  recurrence: RecurrenceEvent;
544
579
  };
580
+
581
+ /** @internal */
582
+ export function validateManualWorkflowInput(
583
+ trigger: ManualWorkflowTrigger,
584
+ input: unknown,
585
+ ): void {
586
+ const validate = manualInputValidators.get(trigger);
587
+ if (validate === undefined) {
588
+ throw new Error("Manual workflow trigger is missing its input schema");
589
+ }
590
+ validate(input);
591
+ }
@@ -0,0 +1,149 @@
1
+ /** Fluent JSON Schema builders following the classic Workers schema-builder API. */
2
+ const SCHEMA: unique symbol = Symbol("schema");
3
+ const KIND: unique symbol = Symbol("kind");
4
+ declare const VALUE: unique symbol;
5
+
6
+ export type StringFormat =
7
+ | "date-time"
8
+ | "date"
9
+ | "time"
10
+ | "duration"
11
+ | "email"
12
+ | "hostname"
13
+ | "ipv4"
14
+ | "ipv6"
15
+ | "uuid";
16
+
17
+ type Kind = "object" | "value";
18
+ type JsonSchema = {
19
+ title?: string;
20
+ description?: string;
21
+ } & (
22
+ | {
23
+ type: "string" | "number" | "integer" | "boolean" | "null";
24
+ enum?: readonly (string | number)[];
25
+ format?: StringFormat;
26
+ }
27
+ | { type: "array"; items: JsonSchema; minItems?: 0 | 1 }
28
+ | {
29
+ type: "object";
30
+ properties: Record<string, JsonSchema>;
31
+ required: string[];
32
+ additionalProperties: false;
33
+ }
34
+ | { anyOf: JsonSchema[] }
35
+ );
36
+
37
+ export interface SchemaBuilder<T, K extends Kind = Kind> {
38
+ readonly [SCHEMA]: JsonSchema;
39
+ readonly [KIND]: K;
40
+ readonly [VALUE]?: T;
41
+ describe(text: string): SchemaBuilder<T, K>;
42
+ label(text: string): SchemaBuilder<T, K>;
43
+ nullable(): SchemaBuilder<T | null, "value">;
44
+ }
45
+
46
+ export type Infer<S> = S extends SchemaBuilder<infer T> ? T : never;
47
+
48
+ /** Returns a copy so callers cannot mutate the builder's validation contract. */
49
+ export function getSchema(builder: SchemaBuilder<unknown>): JsonSchema {
50
+ if (typeof builder !== "object" || builder === null || !(SCHEMA in builder)) {
51
+ throw new Error("Expected a schema builder; use j.object() for manual inputs");
52
+ }
53
+ return structuredClone(builder[SCHEMA]);
54
+ }
55
+
56
+ function makeBuilder<T, K extends Kind>(schema: JsonSchema, kind: K): SchemaBuilder<T, K> {
57
+ return {
58
+ [SCHEMA]: schema,
59
+ [KIND]: kind,
60
+ describe(description) {
61
+ return makeBuilder<T, K>({ ...schema, description }, kind);
62
+ },
63
+ label(title) {
64
+ return makeBuilder<T, K>({ ...schema, title }, kind);
65
+ },
66
+ nullable() {
67
+ const { title, description, ...inner } = schema;
68
+ return makeBuilder<T | null, "value">(
69
+ {
70
+ anyOf: [inner, { type: "null" }],
71
+ ...(title === undefined ? {} : { title }),
72
+ ...(description === undefined ? {} : { description }),
73
+ },
74
+ "value",
75
+ );
76
+ },
77
+ };
78
+ }
79
+
80
+ function string(): SchemaBuilder<string, "value"> {
81
+ return makeBuilder({ type: "string" }, "value");
82
+ }
83
+ function formattedString(format: StringFormat): SchemaBuilder<string, "value"> {
84
+ return makeBuilder({ type: "string", format }, "value");
85
+ }
86
+
87
+ function number(): SchemaBuilder<number, "value"> {
88
+ return makeBuilder({ type: "number" }, "value");
89
+ }
90
+ function integer(): SchemaBuilder<number, "value"> {
91
+ return makeBuilder({ type: "integer" }, "value");
92
+ }
93
+ function boolean(): SchemaBuilder<boolean, "value"> {
94
+ return makeBuilder({ type: "boolean" }, "value");
95
+ }
96
+
97
+ function enumBuilder<const T extends string>(first: T, ...rest: T[]): SchemaBuilder<T, "value">;
98
+ function enumBuilder<const T extends number>(first: T, ...rest: T[]): SchemaBuilder<T, "value">;
99
+ function enumBuilder(
100
+ first: string | number,
101
+ ...rest: (string | number)[]
102
+ ): SchemaBuilder<string | number, "value"> {
103
+ return makeBuilder(
104
+ { type: typeof first === "string" ? "string" : "number", enum: [first, ...rest] },
105
+ "value",
106
+ );
107
+ }
108
+
109
+ function array<T>(
110
+ items: SchemaBuilder<T>,
111
+ options?: { minItems?: 0 | 1 },
112
+ ): SchemaBuilder<T[], "value"> {
113
+ return makeBuilder({ type: "array", items: getSchema(items), ...options }, "value");
114
+ }
115
+
116
+ function object<P extends Record<string, SchemaBuilder<unknown>>>(
117
+ properties: P,
118
+ ): SchemaBuilder<{ -readonly [K in keyof P]: Infer<P[K]> }, "object"> {
119
+ return makeBuilder(
120
+ {
121
+ type: "object",
122
+ properties: Object.fromEntries(
123
+ Object.entries(properties).map(([key, value]) => [key, getSchema(value)]),
124
+ ),
125
+ required: Object.keys(properties),
126
+ additionalProperties: false,
127
+ },
128
+ "object",
129
+ );
130
+ }
131
+
132
+ export const j = {
133
+ string,
134
+ number,
135
+ integer,
136
+ boolean,
137
+ enum: enumBuilder,
138
+ array,
139
+ object,
140
+ datetime: () => formattedString("date-time"),
141
+ date: () => formattedString("date"),
142
+ time: () => formattedString("time"),
143
+ duration: () => formattedString("duration"),
144
+ email: () => formattedString("email"),
145
+ hostname: () => formattedString("hostname"),
146
+ ipv4: () => formattedString("ipv4"),
147
+ ipv6: () => formattedString("ipv6"),
148
+ uuid: () => formattedString("uuid"),
149
+ };
@@ -0,0 +1,160 @@
1
+ import { describe, expect, expectTypeOf, it } from "vitest";
2
+ import { getSchema, j, type Infer } from "./schema-builder.js";
3
+ import { events } from "./events.generated.js";
4
+ import { compileWorkflowInputSchema } from "./workflow-schema.js";
5
+ import type { WorkflowEventForTrigger } from "./workflow.js";
6
+
7
+ const releaseSchema = j.object({
8
+ releases: j.array(j.object({ version: j.string(), dryRun: j.boolean().nullable() })),
9
+ });
10
+
11
+ describe("manual input schema builders", () => {
12
+ it("infers nested nullable fields without optional keys or readonly output", () => {
13
+ const trigger = events.manual({ inputSchema: releaseSchema });
14
+ type Input = WorkflowEventForTrigger<typeof trigger>["input"];
15
+ expectTypeOf<Input>().toEqualTypeOf<{
16
+ releases: { version: string; dryRun: boolean | null }[];
17
+ }>();
18
+ expectTypeOf<Input>().toEqualTypeOf<Infer<typeof releaseSchema>>();
19
+ });
20
+
21
+ it("serializes a builder as standard JSON Schema with all keys required", () => {
22
+ const trigger = events.manual({ inputSchema: releaseSchema });
23
+ expect(JSON.parse(JSON.stringify(trigger))).toEqual({
24
+ type: "workflow.manual",
25
+ inputJsonSchema: {
26
+ type: "object",
27
+ required: ["releases"],
28
+ additionalProperties: false,
29
+ properties: {
30
+ releases: {
31
+ type: "array",
32
+ items: {
33
+ type: "object",
34
+ required: ["version", "dryRun"],
35
+ additionalProperties: false,
36
+ properties: {
37
+ version: { type: "string" },
38
+ dryRun: { anyOf: [{ type: "boolean" }, { type: "null" }] },
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ });
45
+ });
46
+
47
+ it("accepts explicit null and rejects missing, invalid, and extra fields without coercion", () => {
48
+ const { validate } = compileWorkflowInputSchema(releaseSchema);
49
+ for (const dryRun of [true, false, null]) {
50
+ expect(() => validate({ releases: [{ version: "1", dryRun }] })).not.toThrow();
51
+ }
52
+ for (const input of [
53
+ {},
54
+ { releases: [{}] },
55
+ { releases: [{ version: "1" }] },
56
+ { releases: [{ version: "1", dryRun: "false" }] },
57
+ { releases: null },
58
+ { releases: [], extra: true },
59
+ ]) {
60
+ const original = structuredClone(input);
61
+ expect(() => validate(input)).toThrow("Invalid manual workflow input");
62
+ expect(input).toEqual(original);
63
+ }
64
+ });
65
+
66
+ it("supports primitive, enum, nullable object, and nullable array inference and validation", () => {
67
+ const inputSchema = j.object({
68
+ count: j.integer(),
69
+ ratio: j.number(),
70
+ stage: j.enum("draft", "live"),
71
+ config: j.object({ label: j.string() }).nullable(),
72
+ tags: j.array(j.string(), { minItems: 1 }).nullable(),
73
+ });
74
+ const trigger = events.manual({ inputSchema });
75
+ type Input = WorkflowEventForTrigger<typeof trigger>["input"];
76
+ expectTypeOf<Input>().toEqualTypeOf<{
77
+ count: number;
78
+ ratio: number;
79
+ stage: "draft" | "live";
80
+ config: { label: string } | null;
81
+ tags: string[] | null;
82
+ }>();
83
+ const { validate } = compileWorkflowInputSchema(inputSchema);
84
+ const input = { count: 1, ratio: 0.5, stage: "draft", config: null, tags: null };
85
+ expect(() => validate(input)).not.toThrow();
86
+ for (const change of [{ count: 0.5 }, { stage: "unknown" }, { config: {} }, { tags: [] }]) {
87
+ expect(() => validate({ ...input, ...change })).toThrow();
88
+ }
89
+ });
90
+
91
+ it("keeps metadata outside nullable unions and builders immutable", () => {
92
+ const name = j.string().label("Name").describe("A name");
93
+ const nullable = name.nullable();
94
+ expect(getSchema(nullable)).toEqual({
95
+ title: "Name",
96
+ description: "A name",
97
+ anyOf: [{ type: "string" }, { type: "null" }],
98
+ });
99
+ const copy = getSchema(name);
100
+ copy.description = "Changed";
101
+ expect(getSchema(name)).toEqual({ type: "string", title: "Name", description: "A name" });
102
+ expect(getSchema(j.object({ z: name, a: name }).describe("Root"))).toMatchObject({
103
+ required: ["z", "a"],
104
+ description: "Root",
105
+ });
106
+ });
107
+
108
+ it("rejects plain JSON schemas and non-object roots from untyped callers", () => {
109
+ for (const inputSchema of [
110
+ JSON.parse('{"type":"object","properties":{}}'),
111
+ JSON.parse("null"),
112
+ JSON.parse('"string"'),
113
+ ]) {
114
+ expect(() => events.manual({ inputSchema })).toThrow();
115
+ }
116
+ });
117
+ });
118
+
119
+ describe("formatted manual inputs", () => {
120
+ it.each([
121
+ {
122
+ builder: j.datetime,
123
+ format: "date-time",
124
+ valid: "2026-09-24T10:30:00Z",
125
+ invalid: "2026-09-24",
126
+ },
127
+ { builder: j.date, format: "date", valid: "2024-02-29", invalid: "2025-02-29" },
128
+ { builder: j.time, format: "time", valid: "10:30:00Z", invalid: "25:00:00Z" },
129
+ { builder: j.duration, format: "duration", valid: "P1DT2H", invalid: "tomorrow" },
130
+ { builder: j.email, format: "email", valid: "user@example.com", invalid: "not-an-email" },
131
+ { builder: j.hostname, format: "hostname", valid: "example.com", invalid: "bad host" },
132
+ { builder: j.ipv4, format: "ipv4", valid: "192.0.2.1", invalid: "256.0.0.1" },
133
+ { builder: j.ipv6, format: "ipv6", valid: "2001:db8::1", invalid: "2001:db8::xyz" },
134
+ {
135
+ builder: j.uuid,
136
+ format: "uuid",
137
+ valid: "74877cbb-93f9-41fb-9143-c1a11bf88f6b",
138
+ invalid: "not-a-uuid",
139
+ },
140
+ ])(
141
+ "serializes and validates $format, including nested nullable inputs",
142
+ ({ builder, format, valid, invalid }) => {
143
+ const field = builder().label("Input").describe("Help").nullable();
144
+ expectTypeOf<Infer<typeof field>>().toEqualTypeOf<string | null>();
145
+ expect(getSchema(field)).toEqual({
146
+ title: "Input",
147
+ description: "Help",
148
+ anyOf: [{ type: "string", format }, { type: "null" }],
149
+ });
150
+ const schema = j.object({ values: j.array(j.object({ value: field })) });
151
+ const { validate } = compileWorkflowInputSchema(schema);
152
+ for (const value of [valid, null])
153
+ expect(() => validate({ values: [{ value }] })).not.toThrow();
154
+ expect(() => validate({ values: [{ value: invalid }] })).toThrow(
155
+ "Invalid manual workflow input",
156
+ );
157
+ expect(() => validate({ values: [{}] })).toThrow();
158
+ },
159
+ );
160
+ });
@@ -0,0 +1,42 @@
1
+ import { Ajv } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import { getSchema, type SchemaBuilder } from "./schema-builder.js";
4
+
5
+ /** @internal */
6
+ export function compileWorkflowInputSchema(
7
+ schema: SchemaBuilder<Record<string, unknown>, "object">,
8
+ ): {
9
+ jsonSchema: Record<string, unknown>;
10
+ validate: (input: unknown) => void;
11
+ } {
12
+ const jsonSchema = getSchema(schema);
13
+ if (!("type" in jsonSchema) || jsonSchema.type !== "object") {
14
+ throw new Error("workflow manual inputSchema: must be a j.object() schema");
15
+ }
16
+ const ajv = new Ajv({ allErrors: true, strict: true });
17
+ addFormats.default(ajv, {
18
+ formats: [
19
+ "date-time",
20
+ "date",
21
+ "time",
22
+ "duration",
23
+ "email",
24
+ "hostname",
25
+ "ipv4",
26
+ "ipv6",
27
+ "uuid",
28
+ ],
29
+ keywords: false,
30
+ });
31
+ const validate = ajv.compile(jsonSchema);
32
+ return {
33
+ jsonSchema,
34
+ validate(input) {
35
+ if (!validate(input)) {
36
+ throw new Error(
37
+ `Invalid manual workflow input: ${ajv.errorsText(validate.errors)}`,
38
+ );
39
+ }
40
+ },
41
+ };
42
+ }
@@ -4,6 +4,70 @@ import { expect, it } from "vitest";
4
4
 
5
5
  it("checks provider keys on callback arguments and returned triggers", () => {
6
6
  const cases = [
7
+ {
8
+ name: "manual",
9
+ valid: true,
10
+ connections: undefined,
11
+ trigger:
12
+ "events.manual({ inputSchema: j.object({version: j.string(), dryRun: j.boolean()}) })",
13
+ handler:
14
+ "(event) => { const version: string = event.input.version; const dryRun: boolean = event.input.dryRun; void version; void dryRun; }",
15
+ },
16
+ {
17
+ name: "manual_input_mismatch",
18
+ valid: false,
19
+ connections: undefined,
20
+ trigger: "events.manual({ inputSchema: j.object({version: j.string()}) })",
21
+ handler: "(event) => { const version: number = event.input.version; void version; }",
22
+ },
23
+ {
24
+ name: "manual_requires_object_schema",
25
+ valid: false,
26
+ connections: undefined,
27
+ trigger: "events.manual({ inputSchema: j.string() })",
28
+ handler: "() => {}",
29
+ },
30
+ {
31
+ name: "manual_nested",
32
+ valid: true,
33
+ connections: undefined,
34
+ trigger:
35
+ "events.manual({ inputSchema: j.object({releases: j.array(j.object({version: j.string(), dryRun: j.boolean().nullable()}))}) })",
36
+ handler:
37
+ '(event) => { const releases: { version: string; dryRun: boolean | null }[] = event.input.releases; event.input.releases = [{ version: "1.0", dryRun: null }]; void releases; }',
38
+ },
39
+ {
40
+ name: "manual_nullable_not_boolean",
41
+ valid: false,
42
+ connections: undefined,
43
+ trigger: "events.manual({ inputSchema: j.object({dryRun: j.boolean().nullable()}) })",
44
+ handler: "(event) => { const dryRun: boolean = event.input.dryRun; void dryRun; }",
45
+ },
46
+ {
47
+ name: "manual_unknown_property",
48
+ valid: false,
49
+ connections: undefined,
50
+ trigger: "globalTriggers.manual({ inputSchema: j.object({}) })",
51
+ handler: "(event) => { void event.input.unknown; }",
52
+ },
53
+ {
54
+ name: "manual_rejects_plain_schema",
55
+ valid: false,
56
+ connections: undefined,
57
+ trigger:
58
+ 'events.manual({ inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } })',
59
+ handler: "() => {}",
60
+ },
61
+
62
+ {
63
+ name: "manual_open_object",
64
+ valid: false,
65
+ connections: undefined,
66
+ trigger:
67
+ 'events.manual({ inputSchema: { type: "object", properties: {}, additionalProperties: true } })',
68
+ handler: "() => {}",
69
+ },
70
+
7
71
  {
8
72
  name: "valid",
9
73
  valid: true,
@@ -166,6 +230,7 @@ it("checks provider keys on callback arguments and returned triggers", () => {
166
230
  resolve("src", `__workflow_types_${testCase.name}.ts`),
167
231
  `import { connections, workflow } from "./workflow.js";
168
232
  import { events as globalTriggers } from "./events.generated.js";
233
+ import { j } from "./schema-builder.js";
169
234
  const support = connections.slack();
170
235
  const oauthConfig = { authorizationEndpoint: "https://example.com/authorize", tokenEndpoint: "https://example.com/token", clientId: "client", clientSecretEnv: "CLIENT_SECRET", scope: "read" };
171
236
  workflow({
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Mock } from "vitest";
7
7
 
8
+ import { j } from "./schema-builder.js";
8
9
  import { ExecutionError } from "./error.js";
9
10
  import type { RunMetadata } from "./runtime-metadata.js";
10
11
  import { events, type NotionPageCreatedEvent, type WebhookEvent } from "./events.generated.js";
@@ -57,6 +58,72 @@ afterEach(async () => {
57
58
 
58
59
  const event = { type: "notion.page.created" } as NotionPageCreatedEvent;
59
60
 
61
+ describe("manual workflow triggers", () => {
62
+ it("runs a manual trigger without arguments and rejects undeclared input", async () => {
63
+ const handler = vi.fn();
64
+ const definition = workflow({
65
+ name: "No inputs",
66
+ description: "Runs without form fields",
67
+ triggers: [events.manual()],
68
+ handler: async (event) => {
69
+ handler(event.input);
70
+ },
71
+ });
72
+ await definition.handler({ type: "workflow.manual", input: {} });
73
+ expect(handler).toHaveBeenCalledWith({});
74
+ await expect(
75
+ definition.handler(JSON.parse('{"type":"workflow.manual","input":{"extra":true}}')),
76
+ ).rejects.toBeInstanceOf(ExecutionError);
77
+ expect(handler).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("infers and validates the manual event input", async () => {
81
+ const handler = vi.fn();
82
+ const definition = workflow({
83
+ name: "Publish release",
84
+ description: "Publishes a release from manually entered values.",
85
+ triggers: [
86
+ events.manual({
87
+ inputSchema: j.object({ version: j.string(), dryRun: j.boolean() }),
88
+ }),
89
+ ],
90
+ handler: async (manualEvent) => {
91
+ handler(manualEvent.input);
92
+ },
93
+ });
94
+
95
+ await definition.handler({
96
+ type: "workflow.manual",
97
+ input: { version: "1.2.3", dryRun: true },
98
+ });
99
+
100
+ expect(handler).toHaveBeenCalledWith({
101
+ version: "1.2.3",
102
+ dryRun: true,
103
+ });
104
+ });
105
+
106
+ it("rejects invalid manual input before invoking the handler", async () => {
107
+ const handler = vi.fn();
108
+ const definition = workflow({
109
+ name: "Publish release",
110
+ description: "Publishes a release from manually entered values.",
111
+ triggers: [
112
+ events.manual({
113
+ inputSchema: j.object({ version: j.string() }),
114
+ }),
115
+ ],
116
+ handler,
117
+ });
118
+
119
+ await expect(
120
+ definition.handler(JSON.parse('{"type":"workflow.manual","input":{"version":42}}')),
121
+ ).rejects.toBeInstanceOf(ExecutionError);
122
+
123
+ expect(handler).not.toHaveBeenCalled();
124
+ });
125
+ });
126
+
60
127
  async function createWorkflowContext(): Promise<WorkflowContext<Record<never, never>>> {
61
128
  let context: WorkflowContext<Record<never, never>> | undefined;
62
129
  const definition = workflow({
package/src/workflow.ts CHANGED
@@ -23,9 +23,10 @@ import { ExecutionError } from "./error.js";
23
23
  import { writeOutput } from "./output.js";
24
24
  import { resolveRuntimeInput } from "./runtime-input.js";
25
25
  import { readRunMetadata, type RunMetadata } from "./runtime-metadata.js";
26
- import { createWorkflowEvents } from "./events.generated.js";
26
+ import { createWorkflowEvents, validateManualWorkflowInput } from "./events.generated.js";
27
27
  import { createWorkflowStepState, type WorkflowState } from "./workflow-state.js";
28
28
  import type {
29
+ ManualWorkflowTrigger,
29
30
  WebhookTrigger,
30
31
  WorkflowEventMap,
31
32
  WorkflowTrigger,
@@ -204,7 +205,15 @@ export type {
204
205
  export { access } from "./workflow-access.js";
205
206
  export type WorkflowEvent = WorkflowEventMap[keyof WorkflowEventMap];
206
207
 
207
- export type WorkflowEventForTrigger<T extends WorkflowTrigger> = WorkflowEventMap[T["type"]];
208
+ export type WorkflowEventForTrigger<T extends WorkflowTrigger> =
209
+ T extends ManualWorkflowTrigger<infer TInput>
210
+ ? {
211
+ type: "workflow.manual";
212
+ input: TInput;
213
+ }
214
+ : T extends { type: keyof WorkflowEventMap }
215
+ ? WorkflowEventMap[T["type"]]
216
+ : never;
208
217
 
209
218
  export type WorkflowEventForTriggers<T extends readonly WorkflowTrigger[]> =
210
219
  WorkflowEventForTrigger<T[number]>;
@@ -459,6 +468,12 @@ export function workflow<
459
468
  ? configuration.triggers({ events: createWorkflowEvents<TConnections>() })
460
469
  : configuration.triggers;
461
470
  validateTriggerConnections(triggers, requirements);
471
+ const manualTriggers = triggers.filter(
472
+ (trigger): trigger is ManualWorkflowTrigger => trigger.type === "workflow.manual",
473
+ );
474
+ if (manualTriggers.length > 1) {
475
+ throw new Error("Workflows support at most one manual trigger");
476
+ }
462
477
 
463
478
  if (
464
479
  configuration.verify !== undefined &&
@@ -522,6 +537,15 @@ export function workflow<
522
537
  step,
523
538
  wait: createWait(step),
524
539
  };
540
+ if (event.type === "workflow.manual") {
541
+ const manualTrigger = manualTriggers[0];
542
+ if (manualTrigger === undefined) {
543
+ throw new Error(
544
+ "Received workflow.manual for a workflow without a manual trigger",
545
+ );
546
+ }
547
+ validateManualWorkflowInput(manualTrigger, event.input);
548
+ }
525
549
  await configuration.handler(event, capabilityContext);
526
550
 
527
551
  if (options?.concreteOutput) {