@notionhq/apps 0.0.35 → 0.0.37

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/dist/workflow.js CHANGED
@@ -15,8 +15,9 @@ import { ExecutionError } from "./error.js";
15
15
  import { writeOutput } from "./output.js";
16
16
  import { resolveRuntimeInput } from "./runtime-input.js";
17
17
  import { readRunMetadata } from "./runtime-metadata.js";
18
- import { createWorkflowEvents } from "./events.generated.js";
18
+ import { createWorkflowEvents, validateManualWorkflowInput } from "./events.generated.js";
19
19
  import { createWorkflowStepState } from "./workflow-state.js";
20
+ const WEBHOOK_TRIGGER_TYPE = "webhooks.webhook";
20
21
  const input = {
21
22
  text(options) {
22
23
  return { type: "text", ...options };
@@ -56,18 +57,46 @@ function workflow(configuration) {
56
57
  const connectionDeclarations = configuration.connections === void 0 ? void 0 : structuredClone(configuration.connections);
57
58
  const triggers = typeof configuration.triggers === "function" ? configuration.triggers({ events: createWorkflowEvents() }) : configuration.triggers;
58
59
  validateTriggerConnections(triggers, requirements);
60
+ const manualTriggers = triggers.filter(
61
+ (trigger) => trigger.type === "workflow.manual"
62
+ );
63
+ if (manualTriggers.length > 1) {
64
+ throw new Error("Workflows support at most one manual trigger");
65
+ }
66
+ if (configuration.verify !== void 0 && !triggers.some((trigger) => trigger.type === WEBHOOK_TRIGGER_TYPE)) {
67
+ throw new Error(
68
+ `Workflow "${configuration.name}" declares a verify handler but has no webhook trigger. Add events.webhook() or remove verify.`
69
+ );
70
+ }
59
71
  return {
60
72
  _tag: "workflow",
61
73
  config: {
62
74
  name: configuration.name,
63
75
  description: configuration.description,
64
- triggers,
76
+ triggers: manifestTriggers(triggers, configuration.verify !== void 0),
65
77
  ...configuration.connections === void 0 ? {} : { connections: requirements },
66
78
  ...configuration.access === void 0 ? {} : { access: accessRequirements }
67
79
  },
68
- async handler(event, options) {
80
+ async handler(input2, options) {
69
81
  try {
70
- event = await resolveRuntimeInput(event);
82
+ input2 = await resolveRuntimeInput(input2);
83
+ if (isVerifyInvocation(input2)) {
84
+ if (configuration.verify === void 0) {
85
+ throw new Error(
86
+ `Workflow "${configuration.name}" received a verify request but does not declare a verify handler`
87
+ );
88
+ }
89
+ const response = await configuration.verify(
90
+ input2.request,
91
+ createCapabilityContext()
92
+ );
93
+ if (options?.concreteOutput) {
94
+ return response;
95
+ }
96
+ writeOutput({ _tag: "success", value: response });
97
+ return void 0;
98
+ }
99
+ const event = input2;
71
100
  const runMetadata = readRunMetadata();
72
101
  const baseContext = createCapabilityContext();
73
102
  const step = createStep(runMetadata);
@@ -82,6 +111,15 @@ function workflow(configuration) {
82
111
  step,
83
112
  wait: createWait(step)
84
113
  };
114
+ if (event.type === "workflow.manual") {
115
+ const manualTrigger = manualTriggers[0];
116
+ if (manualTrigger === void 0) {
117
+ throw new Error(
118
+ "Received workflow.manual for a workflow without a manual trigger"
119
+ );
120
+ }
121
+ validateManualWorkflowInput(manualTrigger, event.input);
122
+ }
85
123
  await configuration.handler(event, capabilityContext);
86
124
  if (options?.concreteOutput) {
87
125
  return { status: "success" };
@@ -114,6 +152,14 @@ function workflow(configuration) {
114
152
  }
115
153
  };
116
154
  }
155
+ function manifestTriggers(triggers, hasVerify) {
156
+ return triggers.map(
157
+ (trigger) => hasVerify && trigger.type === WEBHOOK_TRIGGER_TYPE ? { ...trigger, hasVerify: true } : trigger
158
+ );
159
+ }
160
+ function isVerifyInvocation(input2) {
161
+ return typeof input2 === "object" && input2 !== null && "request" in input2;
162
+ }
117
163
  const WORKFLOW_STEP_DIRECTORY_ENV_KEY = "NOTION_WORKFLOW_STEP_DIRECTORY";
118
164
  const MAX_WORKFLOW_STEP_KEY_LENGTH = 43;
119
165
  const WORKFLOW_STEP_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/apps",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "An SDK for building workflow apps for Notion",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -83,7 +83,11 @@
83
83
  "types": "./dist/react.d.ts",
84
84
  "default": "./dist/react.js"
85
85
  },
86
- "./nds.css": "./dist/nds.css"
86
+ "./nds.css": "./dist/nds.css",
87
+ "./schema-builder": {
88
+ "types": "./dist/schema-builder.d.ts",
89
+ "default": "./dist/schema-builder.js"
90
+ }
87
91
  },
88
92
  "publishConfig": {
89
93
  "access": "public"
@@ -108,6 +112,7 @@
108
112
  "dependencies": {
109
113
  "@notionhq/custom-blocks": "latest",
110
114
  "ajv": "^8.17.1",
115
+ "ajv-formats": "^3.0.1",
111
116
  "rolldown": "^1.1.5"
112
117
  },
113
118
  "devDependencies": {
@@ -108,6 +108,39 @@ placeholders to `.env.example` when configuration is required. Return only
108
108
  JSON-serializable step values, throw on failed requests and missing required
109
109
  configuration, and do not log secrets or private payloads.
110
110
 
111
+ ## Manual workflow inputs
112
+
113
+ Provide up to one `events.manual` to allow triggering the workflow manually from the UI or CLI for testing purposes.
114
+
115
+ Specify the inputs accepted using `@notionhq/apps/schema-builder`.
116
+
117
+ ```ts
118
+ import { workflow } from "@notionhq/apps/workflow";
119
+ import { events } from "@notionhq/apps/events";
120
+ import { j } from "@notionhq/apps/schema-builder";
121
+
122
+ export default workflow({
123
+ name: "Prepare releases",
124
+ description: "Prepares the supplied releases.",
125
+ triggers: [
126
+ events.manual({
127
+ inputSchema: j.object({
128
+ releases: j.array(
129
+ j.object({
130
+ version: j.string(),
131
+ dryRun: j.boolean().nullable().describe("Whether to preview the release"),
132
+ }),
133
+ ),
134
+ }),
135
+ }),
136
+ ],
137
+ handler: async (event, context) => {
138
+ // event.input: { releases: { version: string; dryRun: boolean | null }[] }
139
+ await context.step("Log releases", () => console.log(event.input.releases));
140
+ },
141
+ });
142
+ ```
143
+
111
144
  ## Resources created with the App
112
145
 
113
146
  Use [Notion as Code](../notion-as-code/SKILL.md) for pages, databases, and custom agents
@@ -480,6 +480,7 @@ describe("buildApp", () => {
480
480
  const { manifest } = await buildApp(root);
481
481
  expect(manifest.capabilities).toEqual([
482
482
  expect.objectContaining({ type: "workflow", key: "onPageCreated" }),
483
+ expect.objectContaining({ type: "workflow", key: "prepareReleases" }),
483
484
  ]);
484
485
  await expect(fs.promises.readFile(provisioningPath)).rejects.toMatchObject({
485
486
  code: "ENOENT",
@@ -538,12 +539,67 @@ describe("buildApp", () => {
538
539
  triggers: [{ type: "notion.page.created" }],
539
540
  },
540
541
  },
542
+ {
543
+ type: "workflow",
544
+ key: "prepareReleases",
545
+ config: {
546
+ name: "Prepare releases",
547
+ description: "Prepares the supplied releases.",
548
+ triggers: [
549
+ {
550
+ type: "workflow.manual",
551
+ inputJsonSchema: {
552
+ type: "object",
553
+ additionalProperties: false,
554
+ required: ["releases"],
555
+ properties: {
556
+ releases: {
557
+ type: "array",
558
+ items: {
559
+ type: "object",
560
+ additionalProperties: false,
561
+ required: ["version", "dryRun"],
562
+ properties: {
563
+ version: { type: "string" },
564
+ dryRun: {
565
+ anyOf: [
566
+ { type: "boolean" },
567
+ { type: "null" },
568
+ ],
569
+ },
570
+ },
571
+ },
572
+ },
573
+ },
574
+ },
575
+ },
576
+ ],
577
+ },
578
+ },
541
579
  ],
542
580
  });
543
581
  expect(path.basename(bundlePath)).toBe("worker.js");
544
582
  expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toEqual(manifest);
545
583
  });
546
584
 
585
+ it("records a workflow's verify handler in the manifest", async () => {
586
+ const { manifestPath } = await buildApp(fixture("webhook-verify"));
587
+
588
+ expect(JSON.parse(await fs.promises.readFile(manifestPath, "utf8"))).toMatchObject({
589
+ capabilities: [
590
+ {
591
+ type: "workflow",
592
+ key: "onIncoming",
593
+ config: {
594
+ name: "Incoming Webhook",
595
+ description: "Verifies and processes an incoming webhook",
596
+ triggers: [{ type: "webhooks.webhook", hasVerify: true }],
597
+ },
598
+ },
599
+ ],
600
+ });
601
+ });
602
+
547
603
  it("collects relation databases and a shared pacer once", async () => {
548
604
  const { manifest } = await buildApp(fixture("shared-primary-key"));
549
605
 
@@ -20,6 +20,20 @@ describe("generateEntry", () => {
20
20
  expect(entry).toContain("export async function run(");
21
21
  });
22
22
 
23
+ it("resolves the webhook verify invocation type to the workflow registry", () => {
24
+ const entry = generateEntry([
25
+ {
26
+ type: "workflow",
27
+ tag: "workflow",
28
+ key: "onIncoming",
29
+ sourcePath: "src/workflows/onIncoming.ts",
30
+ runtime: "worker",
31
+ },
32
+ ]);
33
+
34
+ expect(entry).toContain(`workflowWebhookVerify: "workflow",`);
35
+ });
36
+
23
37
  it("registers syncs in their own capability group", () => {
24
38
  const entry = generateEntry([
25
39
  {
@@ -37,16 +37,24 @@ export const capabilities: Record<string, Record<string, { handler: Handler }>>
37
37
  ${registry}
38
38
  };
39
39
 
40
+ // The platform dispatches a workflow's synchronous webhook verify run under its
41
+ // own invocation type. It resolves to the same capability, which tells a verify
42
+ // invocation from a trigger event by the shape of its input.
43
+ const invocationTypes: Record<string, string | undefined> = {
44
+ workflowWebhookVerify: "workflow",
45
+ };
46
+
40
47
  export async function run(
41
48
  type: string,
42
49
  key: string,
43
50
  context?: unknown,
44
51
  options?: { concreteOutput?: true },
45
52
  ): Promise<unknown> {
46
- const capability = capabilities[type]?.[key];
53
+ const capabilityType = invocationTypes[type] ?? type;
54
+ const capability = capabilities[capabilityType]?.[key];
47
55
 
48
56
  if (!capability) {
49
- throw new Error(\`Capability "\${type}/\${key}" not found\`);
57
+ throw new Error(\`Capability "\${capabilityType}/\${key}" not found\`);
50
58
  }
51
59
 
52
60
  return capability.handler(context, options);
@@ -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
+ };