@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.
- package/README.md +11 -0
- package/dist/custom-block.d.ts +1 -1
- package/dist/events.generated.d.ts +19 -3
- package/dist/events.generated.d.ts.map +1 -1
- package/dist/events.generated.js +25 -1
- package/dist/schema-builder.d.ts +68 -0
- package/dist/schema-builder.d.ts.map +1 -0
- package/dist/schema-builder.js +90 -0
- package/dist/workflow-schema.d.ts +2 -0
- package/dist/workflow-schema.d.ts.map +1 -0
- package/dist/workflow-schema.js +38 -0
- package/dist/workflow-schema.test.d.ts +2 -0
- package/dist/workflow-schema.test.d.ts.map +1 -0
- package/dist/workflow.d.ts +7 -2
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +16 -1
- package/package.json +7 -2
- package/skills/workflow/SKILL.md +33 -0
- package/src/cli/build.test.ts +38 -0
- package/src/cli/discover.test.ts +7 -0
- package/src/events.generated.ts +47 -0
- package/src/schema-builder.ts +149 -0
- package/src/workflow-schema.test.ts +160 -0
- package/src/workflow-schema.ts +42 -0
- package/src/workflow-types.test.ts +65 -0
- package/src/workflow.test.ts +67 -0
- package/src/workflow.ts +26 -2
package/src/cli/discover.test.ts
CHANGED
|
@@ -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
|
|
package/src/events.generated.ts
CHANGED
|
@@ -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({
|
package/src/workflow.test.ts
CHANGED
|
@@ -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> =
|
|
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) {
|