@notionhq/workers 0.3.0 → 0.5.0
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/block.d.ts +1 -1
- package/dist/block.d.ts.map +1 -1
- package/dist/builder.d.ts +7 -1
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +13 -0
- package/dist/capabilities/ai_connector.js +1 -1
- package/dist/capabilities/automation.js +1 -1
- package/dist/capabilities/context.d.ts +2 -1
- package/dist/capabilities/context.d.ts.map +1 -1
- package/dist/capabilities/context.js +21 -3
- package/dist/capabilities/stateful-capability.js +1 -1
- package/dist/capabilities/sync.d.ts +11 -1
- package/dist/capabilities/sync.d.ts.map +1 -1
- package/dist/capabilities/sync.js +25 -5
- package/dist/capabilities/tool.d.ts +21 -0
- package/dist/capabilities/tool.d.ts.map +1 -1
- package/dist/capabilities/tool.js +3 -2
- package/dist/capabilities/webhook.js +1 -1
- package/dist/capabilities/workflow.d.ts +50 -0
- package/dist/capabilities/workflow.d.ts.map +1 -0
- package/dist/capabilities/workflow.js +40 -0
- package/dist/capabilities/workflow.test.d.ts +2 -0
- package/dist/capabilities/workflow.test.d.ts.map +1 -0
- package/dist/error.d.ts +36 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +14 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/schema-builder.d.ts.map +1 -1
- package/dist/triggers.d.ts +365 -0
- package/dist/triggers.d.ts.map +1 -0
- package/dist/triggers.generated.d.ts +246 -0
- package/dist/triggers.generated.d.ts.map +1 -0
- package/dist/triggers.generated.js +239 -0
- package/dist/triggers.js +0 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/worker.d.ts +33 -2
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +34 -0
- package/package.json +9 -2
- package/src/block.ts +40 -40
- package/src/builder.ts +20 -0
- package/src/capabilities/ai_connector.ts +1 -1
- package/src/capabilities/automation.ts +1 -1
- package/src/capabilities/context.ts +45 -3
- package/src/capabilities/stateful-capability.ts +1 -1
- package/src/capabilities/sync.test.ts +172 -1
- package/src/capabilities/sync.ts +46 -5
- package/src/capabilities/tool.test.ts +63 -0
- package/src/capabilities/tool.ts +23 -1
- package/src/capabilities/webhook.ts +1 -1
- package/src/capabilities/workflow.test.ts +184 -0
- package/src/capabilities/workflow.ts +119 -0
- package/src/error.ts +40 -0
- package/src/index.ts +22 -2
- package/src/schema-builder.ts +1 -0
- package/src/triggers.generated.ts +489 -0
- package/src/triggers.ts +504 -0
- package/src/types.ts +20 -0
- package/src/worker.ts +101 -8
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
vi,
|
|
9
9
|
} from "vitest";
|
|
10
10
|
import * as Builder from "../builder.js";
|
|
11
|
-
import { ExecutionError } from "../error.js";
|
|
11
|
+
import { ExecutionError, RateLimitError } from "../error.js";
|
|
12
12
|
import { getPacerState } from "../pacer_internal.js";
|
|
13
13
|
import * as Schema from "../schema.js";
|
|
14
14
|
import { Worker } from "../worker.js";
|
|
@@ -59,6 +59,34 @@ describe("Worker.sync", () => {
|
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
describe("Builder.imageCover", () => {
|
|
63
|
+
it("creates an image cover with the default position", () => {
|
|
64
|
+
const value = Builder.imageCover("https://example.com/cover.jpg");
|
|
65
|
+
|
|
66
|
+
expect(value).toEqual({
|
|
67
|
+
type: "image",
|
|
68
|
+
url: "https://example.com/cover.jpg",
|
|
69
|
+
position: 0.5,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("creates an image cover with a custom position", () => {
|
|
74
|
+
const value = Builder.imageCover("https://example.com/cover.jpg", 0.25);
|
|
75
|
+
|
|
76
|
+
expect(value).toEqual({
|
|
77
|
+
type: "image",
|
|
78
|
+
url: "https://example.com/cover.jpg",
|
|
79
|
+
position: 0.25,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects cover positions outside the supported range", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
Builder.imageCover("https://example.com/cover.jpg", 1.5),
|
|
86
|
+
).toThrow("Expected a number between 0 and 1");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
62
90
|
describe("SyncedObject with relation properties", () => {
|
|
63
91
|
it("allows relation values in synced objects", async () => {
|
|
64
92
|
const worker = new Worker();
|
|
@@ -107,6 +135,42 @@ describe("Worker.sync", () => {
|
|
|
107
135
|
});
|
|
108
136
|
});
|
|
109
137
|
|
|
138
|
+
describe("SyncedObject with page covers", () => {
|
|
139
|
+
it("allows cover values in synced objects", async () => {
|
|
140
|
+
const worker = new Worker();
|
|
141
|
+
const tasks = worker.database("tasks", {
|
|
142
|
+
type: "managed",
|
|
143
|
+
initialTitle: "Tasks",
|
|
144
|
+
primaryKeyProperty: "Task ID",
|
|
145
|
+
schema: {
|
|
146
|
+
properties: {
|
|
147
|
+
"Task ID": Schema.title(),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const capability = createSyncCapability("tasksSync", {
|
|
153
|
+
database: tasks,
|
|
154
|
+
execute: async () => ({
|
|
155
|
+
changes: [
|
|
156
|
+
{
|
|
157
|
+
type: "upsert",
|
|
158
|
+
key: "task-1",
|
|
159
|
+
properties: {
|
|
160
|
+
"Task ID": Builder.title("TASK-001"),
|
|
161
|
+
},
|
|
162
|
+
cover: Builder.imageCover("https://example.com/cover.jpg"),
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
hasMore: false,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(capability._tag).toBe("sync");
|
|
170
|
+
expect(capability.config.databaseKey).toBe("tasks");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
110
174
|
describe("pacer local fallback", () => {
|
|
111
175
|
it("seeds pacer state from declarations when runtime context has no pacers", async () => {
|
|
112
176
|
const worker = new Worker();
|
|
@@ -251,6 +315,113 @@ describe("Worker.sync", () => {
|
|
|
251
315
|
);
|
|
252
316
|
});
|
|
253
317
|
|
|
318
|
+
it("wraps RateLimitError in error envelope with _tag rate_limit", async () => {
|
|
319
|
+
const worker = new Worker();
|
|
320
|
+
const db = worker.database("test", {
|
|
321
|
+
type: "managed",
|
|
322
|
+
initialTitle: "Test",
|
|
323
|
+
primaryKeyProperty: "ID",
|
|
324
|
+
schema: {
|
|
325
|
+
properties: {
|
|
326
|
+
ID: Schema.title(),
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
332
|
+
database: db,
|
|
333
|
+
execute: async () => {
|
|
334
|
+
throw new RateLimitError();
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
339
|
+
|
|
340
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
341
|
+
expect.stringContaining(`"_tag":"rate_limit"`),
|
|
342
|
+
);
|
|
343
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
344
|
+
expect.stringContaining(`"name":"RateLimitError"`),
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("includes retryAfter in rate_limit error envelope when provided", async () => {
|
|
349
|
+
const worker = new Worker();
|
|
350
|
+
const db = worker.database("test", {
|
|
351
|
+
type: "managed",
|
|
352
|
+
initialTitle: "Test",
|
|
353
|
+
primaryKeyProperty: "ID",
|
|
354
|
+
schema: {
|
|
355
|
+
properties: {
|
|
356
|
+
ID: Schema.title(),
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
362
|
+
database: db,
|
|
363
|
+
execute: async () => {
|
|
364
|
+
throw new RateLimitError({ retryAfter: 30 });
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
369
|
+
|
|
370
|
+
const output = stdoutSpy.mock.calls
|
|
371
|
+
.map((call) => String(call[0]))
|
|
372
|
+
.find((s) => s.includes(`"_tag":"rate_limit"`)) as string;
|
|
373
|
+
const parsed = JSON.parse(
|
|
374
|
+
output.replace(
|
|
375
|
+
/\n<__notion_output__>(.*)<\/__notion_output__>\n/,
|
|
376
|
+
"$1",
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
expect(parsed).toEqual({
|
|
380
|
+
_tag: "error",
|
|
381
|
+
error: {
|
|
382
|
+
_tag: "rate_limit",
|
|
383
|
+
name: "RateLimitError",
|
|
384
|
+
message:
|
|
385
|
+
"Error during worker execution: Rate limited by external API",
|
|
386
|
+
retryAfter: 30,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("wraps RateLimitError without retryAfter correctly", async () => {
|
|
392
|
+
const worker = new Worker();
|
|
393
|
+
const db = worker.database("test", {
|
|
394
|
+
type: "managed",
|
|
395
|
+
initialTitle: "Test",
|
|
396
|
+
primaryKeyProperty: "ID",
|
|
397
|
+
schema: {
|
|
398
|
+
properties: {
|
|
399
|
+
ID: Schema.title(),
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
405
|
+
database: db,
|
|
406
|
+
execute: async () => {
|
|
407
|
+
throw new RateLimitError();
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
412
|
+
|
|
413
|
+
const output = stdoutSpy.mock.calls
|
|
414
|
+
.map((call) => String(call[0]))
|
|
415
|
+
.find((s) => s.includes(`"_tag":"rate_limit"`)) as string;
|
|
416
|
+
const parsed = JSON.parse(
|
|
417
|
+
output.replace(
|
|
418
|
+
/\n<__notion_output__>(.*)<\/__notion_output__>\n/,
|
|
419
|
+
"$1",
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
expect(parsed.error.retryAfter).toBeUndefined();
|
|
423
|
+
});
|
|
424
|
+
|
|
254
425
|
it("emits sync output with key and default targetDatabaseKey", async () => {
|
|
255
426
|
const worker = new Worker();
|
|
256
427
|
const tasks = worker.database("tasks", {
|
package/src/capabilities/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ExecutionError } from "../error.js";
|
|
1
|
+
import { ExecutionError, RateLimitError } from "../error.js";
|
|
2
2
|
import {
|
|
3
3
|
getPacerState,
|
|
4
4
|
initPacerState,
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
Schema,
|
|
11
11
|
} from "../schema.js";
|
|
12
12
|
import type {
|
|
13
|
+
Cover,
|
|
13
14
|
HandlerOptions,
|
|
14
15
|
Icon,
|
|
15
16
|
PeopleValue,
|
|
@@ -28,6 +29,18 @@ import {
|
|
|
28
29
|
type RuntimeContext as StatefulRuntimeContext,
|
|
29
30
|
} from "./stateful-capability.js";
|
|
30
31
|
|
|
32
|
+
type SyncErrorEnvelope =
|
|
33
|
+
| { _tag: "error"; error: { _tag: "generic"; name: string; message: string } }
|
|
34
|
+
| {
|
|
35
|
+
_tag: "error";
|
|
36
|
+
error: {
|
|
37
|
+
_tag: "rate_limit";
|
|
38
|
+
name: string;
|
|
39
|
+
message: string;
|
|
40
|
+
retryAfter?: number;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Maps a property configuration to its corresponding value type.
|
|
33
46
|
*/
|
|
@@ -86,6 +99,11 @@ export type SyncChangeUpsert<
|
|
|
86
99
|
* Use the `Builder.emojiIcon()`, `Builder.notionIcon()`, or `Builder.imageIcon()` helpers.
|
|
87
100
|
*/
|
|
88
101
|
icon?: Icon;
|
|
102
|
+
/**
|
|
103
|
+
* Optional cover to use as the cover for this row's page.
|
|
104
|
+
* Use the `Builder.imageCover()` helper.
|
|
105
|
+
*/
|
|
106
|
+
cover?: Cover;
|
|
89
107
|
/**
|
|
90
108
|
* Optional markdown content to add to the page body.
|
|
91
109
|
* This will be converted to Notion blocks and added as page content.
|
|
@@ -239,7 +257,7 @@ export function createSyncCapability<
|
|
|
239
257
|
runtimeContext?: StatefulRuntimeContext<Context>,
|
|
240
258
|
options?: HandlerOptions,
|
|
241
259
|
) {
|
|
242
|
-
const capabilityContext = createCapabilityContext();
|
|
260
|
+
const capabilityContext = createCapabilityContext("sync");
|
|
243
261
|
const state = runtimeContext?.state ?? runtimeContext?.userContext;
|
|
244
262
|
|
|
245
263
|
initPacerState(runtimeContext?.pacers, pacerDeclarations);
|
|
@@ -251,12 +269,35 @@ export function createSyncCapability<
|
|
|
251
269
|
capabilityContext,
|
|
252
270
|
);
|
|
253
271
|
} catch (err) {
|
|
272
|
+
if (err instanceof RateLimitError) {
|
|
273
|
+
if (!options?.concreteOutput) {
|
|
274
|
+
const envelope: SyncErrorEnvelope = {
|
|
275
|
+
_tag: "error",
|
|
276
|
+
error: {
|
|
277
|
+
_tag: "rate_limit",
|
|
278
|
+
name: err.name,
|
|
279
|
+
message: err.message,
|
|
280
|
+
...(err.retryAfter !== undefined
|
|
281
|
+
? { retryAfter: err.retryAfter }
|
|
282
|
+
: {}),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
writeOutput(envelope);
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
|
|
254
290
|
const error = new ExecutionError(err);
|
|
255
291
|
if (!options?.concreteOutput) {
|
|
256
|
-
|
|
292
|
+
const envelope: SyncErrorEnvelope = {
|
|
257
293
|
_tag: "error",
|
|
258
|
-
error: {
|
|
259
|
-
|
|
294
|
+
error: {
|
|
295
|
+
_tag: "generic",
|
|
296
|
+
name: error.name,
|
|
297
|
+
message: error.message,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
writeOutput(envelope);
|
|
260
301
|
}
|
|
261
302
|
throw error;
|
|
262
303
|
}
|
|
@@ -222,6 +222,69 @@ describe("Worker.tool", () => {
|
|
|
222
222
|
expect(capability.config.schema).toBeDefined();
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
describe("hints", () => {
|
|
226
|
+
it("omits hints from config when not specified (existing tools unchanged)", () => {
|
|
227
|
+
const capability = createToolCapability<{ name: string }, string>(
|
|
228
|
+
"noHints",
|
|
229
|
+
{
|
|
230
|
+
title: "No Hints",
|
|
231
|
+
description: "A tool without hints",
|
|
232
|
+
schema: j.object({ name: j.string() }),
|
|
233
|
+
execute: ({ name }) => `Hello, ${name}`,
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(capability.config.hints).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("flows readOnlyHint=true through to config", () => {
|
|
241
|
+
const capability = createToolCapability<{ query: string }, string>(
|
|
242
|
+
"readOnly",
|
|
243
|
+
{
|
|
244
|
+
title: "Read Only",
|
|
245
|
+
description: "A read-only tool",
|
|
246
|
+
schema: j.object({ query: j.string() }),
|
|
247
|
+
hints: { readOnlyHint: true },
|
|
248
|
+
execute: ({ query }) => query,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(capability.config.hints).toEqual({ readOnlyHint: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("flows readOnlyHint=false through to config", () => {
|
|
256
|
+
const capability = createToolCapability<{ pageId: string }, string>(
|
|
257
|
+
"writeTool",
|
|
258
|
+
{
|
|
259
|
+
title: "Write Tool",
|
|
260
|
+
description: "A write tool",
|
|
261
|
+
schema: j.object({ pageId: j.string() }),
|
|
262
|
+
hints: { readOnlyHint: false },
|
|
263
|
+
execute: ({ pageId }) => pageId,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(capability.config.hints).toEqual({ readOnlyHint: false });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("does not affect execution behavior", async () => {
|
|
271
|
+
const capability = createToolCapability<{ x: number }, number>(
|
|
272
|
+
"doubleReadOnly",
|
|
273
|
+
{
|
|
274
|
+
title: "Double",
|
|
275
|
+
description: "Doubles a number",
|
|
276
|
+
schema: j.object({ x: j.number() }),
|
|
277
|
+
hints: { readOnlyHint: true },
|
|
278
|
+
execute: ({ x }) => x * 2,
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const result = await capability.handler({ x: 7 });
|
|
283
|
+
|
|
284
|
+
expect(result).toEqual({ _tag: "success", value: 14 });
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
225
288
|
it("invalid output with custom output schema", async () => {
|
|
226
289
|
const capability = createToolCapability<
|
|
227
290
|
{ value: number },
|
package/src/capabilities/tool.ts
CHANGED
|
@@ -70,6 +70,26 @@ function validateRequiredProperties(
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Hints describing a tool's behavior. Advisory metadata read by the platform
|
|
75
|
+
* (not the model) to decide whether a call is safe to auto-execute or
|
|
76
|
+
* requires user confirmation.
|
|
77
|
+
*
|
|
78
|
+
* Field names mirror the MCP tool annotation shape
|
|
79
|
+
* (`McpServerToolAnnotations`) so worker tools flow through the same
|
|
80
|
+
* confirmation / auto-execute machinery as MCP tools.
|
|
81
|
+
*
|
|
82
|
+
* Only hints with active consumers in the Notion client are included.
|
|
83
|
+
*/
|
|
84
|
+
export interface ToolHints {
|
|
85
|
+
/**
|
|
86
|
+
* The tool only reads state and has no side effects. Safe to call
|
|
87
|
+
* repeatedly; safe to auto-execute under default policy. Tools without
|
|
88
|
+
* this hint are treated as write tools and prompt for confirmation.
|
|
89
|
+
*/
|
|
90
|
+
readOnlyHint?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
export interface ToolConfiguration<
|
|
74
94
|
I extends JSONValue,
|
|
75
95
|
O extends JSONValue = JSONValue,
|
|
@@ -78,6 +98,7 @@ export interface ToolConfiguration<
|
|
|
78
98
|
description: string;
|
|
79
99
|
schema: SchemaBuilder<I>;
|
|
80
100
|
outputSchema?: SchemaBuilder<O>;
|
|
101
|
+
hints?: ToolHints;
|
|
81
102
|
execute: (input: I, context: CapabilityContext) => O | Promise<O>;
|
|
82
103
|
}
|
|
83
104
|
|
|
@@ -218,7 +239,7 @@ export function createToolCapability<
|
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
try {
|
|
221
|
-
const capabilityContext = createCapabilityContext();
|
|
242
|
+
const capabilityContext = createCapabilityContext("tool");
|
|
222
243
|
const result = await config.execute(input, capabilityContext);
|
|
223
244
|
if (validateOutput && !validateOutput(result)) {
|
|
224
245
|
const error = new InvalidToolOutputError(
|
|
@@ -281,6 +302,7 @@ export function createToolCapability<
|
|
|
281
302
|
description: config.description,
|
|
282
303
|
schema: inputSchema,
|
|
283
304
|
outputSchema: outputSchema,
|
|
305
|
+
hints: config.hints,
|
|
284
306
|
},
|
|
285
307
|
handler,
|
|
286
308
|
};
|
|
@@ -118,7 +118,7 @@ export function createWebhookCapability(
|
|
|
118
118
|
options?: HandlerOptions,
|
|
119
119
|
): Promise<{ status: "success" } | undefined> {
|
|
120
120
|
try {
|
|
121
|
-
const capabilityContext = createCapabilityContext();
|
|
121
|
+
const capabilityContext = createCapabilityContext("webhook");
|
|
122
122
|
await config.execute(events, capabilityContext);
|
|
123
123
|
|
|
124
124
|
if (options?.concreteOutput) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
expectTypeOf,
|
|
7
|
+
it,
|
|
8
|
+
type Mock,
|
|
9
|
+
vi,
|
|
10
|
+
} from "vitest";
|
|
11
|
+
import { ExecutionError } from "../error.js";
|
|
12
|
+
import {
|
|
13
|
+
type CapabilityContext,
|
|
14
|
+
Worker,
|
|
15
|
+
type WorkflowConfiguration,
|
|
16
|
+
} from "../index.js";
|
|
17
|
+
import {
|
|
18
|
+
type NotionPageCreatedEvent,
|
|
19
|
+
type NotionPageCreatedTrigger,
|
|
20
|
+
triggers,
|
|
21
|
+
} from "../triggers.generated.js";
|
|
22
|
+
import { createWorkflowCapability } from "./workflow.js";
|
|
23
|
+
|
|
24
|
+
const pageCreatedEvent: NotionPageCreatedEvent = {
|
|
25
|
+
type: "notion.page.created",
|
|
26
|
+
vendor: "notion",
|
|
27
|
+
message: "Page created",
|
|
28
|
+
actor: {
|
|
29
|
+
id: "user-1",
|
|
30
|
+
displayName: "Ada Lovelace",
|
|
31
|
+
email: "ada@example.com",
|
|
32
|
+
vendorActor: {
|
|
33
|
+
vendor: "notion",
|
|
34
|
+
id: "user-1",
|
|
35
|
+
displayName: "Ada Lovelace",
|
|
36
|
+
handle: null,
|
|
37
|
+
type: "user",
|
|
38
|
+
url: null,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
url: "https://www.notion.so/page-1",
|
|
42
|
+
timestamp: "2026-06-26T12:00:00.000Z",
|
|
43
|
+
page: {
|
|
44
|
+
id: "page-1",
|
|
45
|
+
Name: "Welcome",
|
|
46
|
+
Status: "New",
|
|
47
|
+
},
|
|
48
|
+
content: "# Welcome\n\nHello, world!",
|
|
49
|
+
edits: [
|
|
50
|
+
{
|
|
51
|
+
before: null,
|
|
52
|
+
after: {
|
|
53
|
+
id: "page-1",
|
|
54
|
+
Name: "Welcome",
|
|
55
|
+
Status: "New",
|
|
56
|
+
},
|
|
57
|
+
afterContent: "# Welcome\n\nHello, world!",
|
|
58
|
+
contentChanged: true,
|
|
59
|
+
timestamp: 1_782_477_600_000,
|
|
60
|
+
editedBy: null,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("createWorkflowCapability", () => {
|
|
66
|
+
let stdoutSpy: Mock<typeof process.stdout.write>;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
stdoutSpy = vi
|
|
70
|
+
.spyOn(process.stdout, "write")
|
|
71
|
+
.mockImplementation(() => true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.restoreAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("declares a page-created trigger without a database", () => {
|
|
79
|
+
expect(triggers.notionPageCreated()).toEqual({
|
|
80
|
+
type: "notion.page.created",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("requires trigger declarations in workflow configuration", () => {
|
|
85
|
+
type PageAddedConfiguration = WorkflowConfiguration<
|
|
86
|
+
readonly [NotionPageCreatedTrigger]
|
|
87
|
+
>;
|
|
88
|
+
type ConfigurationWithoutTriggers = Omit<
|
|
89
|
+
PageAddedConfiguration,
|
|
90
|
+
"triggers"
|
|
91
|
+
>;
|
|
92
|
+
|
|
93
|
+
expectTypeOf<ConfigurationWithoutTriggers>().not.toMatchTypeOf<PageAddedConfiguration>();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("infers the canonical page-created event and capability context", () => {
|
|
97
|
+
createWorkflowCapability("pageAdded", {
|
|
98
|
+
title: "Page Added",
|
|
99
|
+
description: "Runs when a page is added",
|
|
100
|
+
triggers: [triggers.notionPageCreated()],
|
|
101
|
+
execute: (event, context) => {
|
|
102
|
+
expectTypeOf(event).toEqualTypeOf<NotionPageCreatedEvent>();
|
|
103
|
+
expectTypeOf(event.type).toEqualTypeOf<"notion.page.created">();
|
|
104
|
+
expectTypeOf(context).toEqualTypeOf<CapabilityContext>();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("includes triggers in capability config and the worker manifest", () => {
|
|
110
|
+
const worker = new Worker();
|
|
111
|
+
worker.workflow("pageAdded", {
|
|
112
|
+
title: "Page Added",
|
|
113
|
+
description: "Runs when a page is added",
|
|
114
|
+
triggers: [triggers.notionPageCreated()],
|
|
115
|
+
execute: () => {},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(worker.manifest.capabilities).toEqual([
|
|
119
|
+
{
|
|
120
|
+
_tag: "workflow",
|
|
121
|
+
key: "pageAdded",
|
|
122
|
+
config: {
|
|
123
|
+
title: "Page Added",
|
|
124
|
+
description: "Runs when a page is added",
|
|
125
|
+
triggers: [{ type: "notion.page.created" }],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("executes with the public event and capability context", async () => {
|
|
132
|
+
const execute = vi.fn();
|
|
133
|
+
const capability = createWorkflowCapability("pageAdded", {
|
|
134
|
+
title: "Page Added",
|
|
135
|
+
description: "Runs when a page is added",
|
|
136
|
+
triggers: [triggers.notionPageCreated()],
|
|
137
|
+
execute,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await expect(
|
|
141
|
+
capability.handler(pageCreatedEvent, { concreteOutput: true }),
|
|
142
|
+
).resolves.toEqual({ status: "success" });
|
|
143
|
+
expect(execute).toHaveBeenCalledWith(
|
|
144
|
+
pageCreatedEvent,
|
|
145
|
+
expect.objectContaining({ notion: expect.any(Object) }),
|
|
146
|
+
);
|
|
147
|
+
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("writes the success envelope by default", async () => {
|
|
151
|
+
const capability = createWorkflowCapability("pageAdded", {
|
|
152
|
+
title: "Page Added",
|
|
153
|
+
description: "Runs when a page is added",
|
|
154
|
+
triggers: [triggers.notionPageCreated()],
|
|
155
|
+
execute: () => {},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await capability.handler(pageCreatedEvent);
|
|
159
|
+
|
|
160
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
161
|
+
'\n<__notion_output__>{"_tag":"success","value":{"status":"success"}}</__notion_output__>\n',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("writes and throws an execution error", async () => {
|
|
166
|
+
const capability = createWorkflowCapability("pageAdded", {
|
|
167
|
+
title: "Page Added",
|
|
168
|
+
description: "Runs when a page is added",
|
|
169
|
+
triggers: [triggers.notionPageCreated()],
|
|
170
|
+
execute: () => {
|
|
171
|
+
throw new Error("Something went wrong");
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await expect(capability.handler(pageCreatedEvent)).rejects.toThrow(
|
|
176
|
+
ExecutionError,
|
|
177
|
+
);
|
|
178
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
179
|
+
expect.stringContaining(
|
|
180
|
+
'"_tag":"error","error":{"name":"ExecutionError","message":"Error during worker execution: Error: Something went wrong"',
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|