@notionhq/apps 0.0.17 → 0.0.18
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/AGENTS.md +27 -0
- package/README.md +60 -35
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/notion-as-code/database.d.ts +88 -48
- package/dist/notion-as-code/database.d.ts.map +1 -1
- package/dist/notion-as-code/database.js +132 -45
- package/dist/notion-as-code/database.test.d.ts +2 -0
- package/dist/notion-as-code/database.test.d.ts.map +1 -0
- package/dist/notion-as-code/index.d.ts +2 -2
- package/dist/notion-as-code/index.d.ts.map +1 -1
- package/dist/notion-as-code/intents.d.ts +34 -2
- package/dist/notion-as-code/intents.d.ts.map +1 -1
- package/dist/notion-as-code/schema.d.ts +8 -4
- package/dist/notion-as-code/schema.d.ts.map +1 -1
- package/dist/notion-as-code/views.d.ts +29 -10
- package/dist/notion-as-code/views.d.ts.map +1 -1
- package/dist/sync.d.ts +33 -15
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +12 -0
- package/dist/workflow-state.d.ts +13 -0
- package/dist/workflow-state.d.ts.map +1 -0
- package/dist/workflow-state.js +107 -0
- package/dist/workflow.d.ts +48 -3
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +111 -4
- package/docs/BUILD.md +96 -0
- package/package.json +1 -1
- package/skills/notion-as-code/SKILL.md +81 -49
- package/skills/sync/SKILL.md +21 -18
- package/skills/workflow/SKILL.md +52 -3
- package/src/cli/build.test.ts +124 -64
- package/src/index.ts +1 -0
- package/src/notion-as-code/database.test.ts +661 -0
- package/src/notion-as-code/database.ts +344 -127
- package/src/notion-as-code/index.ts +16 -1
- package/src/notion-as-code/intents.ts +39 -2
- package/src/notion-as-code/schema.ts +11 -3
- package/src/notion-as-code/views.ts +29 -10
- package/src/sync.test.ts +295 -0
- package/src/sync.ts +85 -21
- package/src/workflow-state.ts +152 -0
- package/src/workflow.test.ts +329 -1
- package/src/workflow.ts +180 -6
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { RunMetadata } from "./runtime-metadata.js";
|
|
2
|
+
|
|
3
|
+
export type WorkflowStateValue =
|
|
4
|
+
| null
|
|
5
|
+
| boolean
|
|
6
|
+
| number
|
|
7
|
+
| string
|
|
8
|
+
| ReadonlyArray<WorkflowStateValue>
|
|
9
|
+
| { readonly [key: string]: WorkflowStateValue };
|
|
10
|
+
|
|
11
|
+
export type WorkflowState = {
|
|
12
|
+
get(key: string): Promise<WorkflowStateValue | undefined>;
|
|
13
|
+
set(key: string, value: WorkflowStateValue | undefined): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type WorkflowStateMutation =
|
|
17
|
+
| { operation: "set"; key: string; value: WorkflowStateValue }
|
|
18
|
+
| { operation: "delete"; key: string };
|
|
19
|
+
|
|
20
|
+
type WorkflowStepScope = {
|
|
21
|
+
id: string;
|
|
22
|
+
active: boolean;
|
|
23
|
+
mutations: Map<string, WorkflowStateMutation>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MAX_STATE_KEY_BYTES = 256;
|
|
27
|
+
const WORKFLOW_CAPABILITY_KEY_ENV = "NOTION_WORKFLOW_CAPABILITY_KEY";
|
|
28
|
+
|
|
29
|
+
export function createWorkflowStepState(
|
|
30
|
+
metadata: Pick<RunMetadata, "workerId">,
|
|
31
|
+
stepId: string,
|
|
32
|
+
): {
|
|
33
|
+
state: WorkflowState;
|
|
34
|
+
run<T>(fn: () => T | Promise<T>): Promise<T>;
|
|
35
|
+
} {
|
|
36
|
+
const scope: WorkflowStepScope = {
|
|
37
|
+
id: stepId,
|
|
38
|
+
active: true,
|
|
39
|
+
mutations: new Map(),
|
|
40
|
+
};
|
|
41
|
+
const state: WorkflowState = {
|
|
42
|
+
async get(key: string): Promise<WorkflowStateValue | undefined> {
|
|
43
|
+
requireActiveStep(scope, "get");
|
|
44
|
+
validateStateKey(key);
|
|
45
|
+
const pending = scope.mutations.get(key);
|
|
46
|
+
if (pending?.operation === "set") {
|
|
47
|
+
return pending.value;
|
|
48
|
+
}
|
|
49
|
+
if (pending?.operation === "delete") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const response = await requestWorkflowState(metadata, {
|
|
54
|
+
operation: "get",
|
|
55
|
+
key,
|
|
56
|
+
});
|
|
57
|
+
const body: { exists: boolean; value?: WorkflowStateValue } = JSON.parse(
|
|
58
|
+
await response.text(),
|
|
59
|
+
);
|
|
60
|
+
if (!body.exists) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return body.value;
|
|
64
|
+
},
|
|
65
|
+
set(key: string, value: WorkflowStateValue | undefined): void {
|
|
66
|
+
requireActiveStep(scope, "set");
|
|
67
|
+
validateStateKey(key);
|
|
68
|
+
if (value === undefined) {
|
|
69
|
+
recordMutation(scope, { operation: "delete", key });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
recordMutation(scope, { operation: "set", key, value });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
state,
|
|
78
|
+
async run<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
79
|
+
try {
|
|
80
|
+
const result = await fn();
|
|
81
|
+
if (scope.mutations.size > 0) {
|
|
82
|
+
await requestWorkflowState(metadata, {
|
|
83
|
+
operation: "commit",
|
|
84
|
+
operationId: scope.id,
|
|
85
|
+
mutations: [...scope.mutations.values()],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
} finally {
|
|
90
|
+
scope.active = false;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireActiveStep(expectedScope: WorkflowStepScope, operation: keyof WorkflowState): void {
|
|
97
|
+
if (!expectedScope.active) {
|
|
98
|
+
throw new Error(`state.${operation}() must be called inside context.step().`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function recordMutation(scope: WorkflowStepScope, mutation: WorkflowStateMutation): void {
|
|
103
|
+
scope.mutations.set(mutation.key, mutation);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function requestWorkflowState(
|
|
107
|
+
metadata: Pick<RunMetadata, "workerId">,
|
|
108
|
+
request:
|
|
109
|
+
| { operation: "get"; key: string }
|
|
110
|
+
| {
|
|
111
|
+
operation: "commit";
|
|
112
|
+
operationId: string;
|
|
113
|
+
mutations: WorkflowStateMutation[];
|
|
114
|
+
},
|
|
115
|
+
): Promise<Response> {
|
|
116
|
+
const baseUrl = process.env.NOTION_API_BASE_URL;
|
|
117
|
+
const token = process.env.NOTION_API_TOKEN;
|
|
118
|
+
const capabilityKey = process.env[WORKFLOW_CAPABILITY_KEY_ENV];
|
|
119
|
+
if (!baseUrl || !token || !capabilityKey) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Workflow state is only available when the workflow runtime provides NOTION_API_BASE_URL, NOTION_API_TOKEN, and ${WORKFLOW_CAPABILITY_KEY_ENV}.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v3/workersWorkflowState`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
authorization: `Bearer ${token}`,
|
|
129
|
+
"content-type": "application/json",
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
...request,
|
|
133
|
+
workerId: metadata.workerId,
|
|
134
|
+
capabilityKey,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Workflow state ${request.operation} failed with status ${response.status}.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return response;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateStateKey(key: string): void {
|
|
146
|
+
if (key.length === 0) {
|
|
147
|
+
throw new Error("Workflow state keys must not be empty.");
|
|
148
|
+
}
|
|
149
|
+
if (Buffer.byteLength(key) > MAX_STATE_KEY_BYTES) {
|
|
150
|
+
throw new Error(`Workflow state keys must be at most ${MAX_STATE_KEY_BYTES} UTF-8 bytes.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/workflow.test.ts
CHANGED
|
@@ -8,7 +8,13 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi, type Moc
|
|
|
8
8
|
import { ExecutionError } from "./error.js";
|
|
9
9
|
import type { RunMetadata } from "./runtime-metadata.js";
|
|
10
10
|
import { triggers, type NotionPageCreatedEvent } from "./triggers.generated.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
connections,
|
|
13
|
+
workflow,
|
|
14
|
+
type StepContext,
|
|
15
|
+
type WorkflowContext,
|
|
16
|
+
type WorkflowWaitDuration,
|
|
17
|
+
} from "./workflow.js";
|
|
12
18
|
|
|
13
19
|
let stdoutSpy: Mock<typeof process.stdout.write>;
|
|
14
20
|
let checkpointDirectory: string | undefined;
|
|
@@ -17,6 +23,7 @@ let metadataDirectory: string | undefined;
|
|
|
17
23
|
beforeEach(async () => {
|
|
18
24
|
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
19
25
|
delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
|
|
26
|
+
process.env.NOTION_WORKFLOW_CAPABILITY_KEY = "workflow-key";
|
|
20
27
|
checkpointDirectory = undefined;
|
|
21
28
|
metadataDirectory = await mkdtemp(join(tmpdir(), "notion-workflow-metadata-"));
|
|
22
29
|
await writeRunMetadata();
|
|
@@ -25,8 +32,12 @@ beforeEach(async () => {
|
|
|
25
32
|
afterEach(async () => {
|
|
26
33
|
stdoutSpy.mockRestore();
|
|
27
34
|
delete process.env.NOTION_WORKFLOW_STEP_DIRECTORY;
|
|
35
|
+
delete process.env.NOTION_WORKFLOW_CAPABILITY_KEY;
|
|
28
36
|
delete process.env.NOTION_INPUT_PAYLOAD_PATH;
|
|
29
37
|
delete process.env.NOTION_RUN_METADATA_PATH;
|
|
38
|
+
delete process.env.NOTION_API_BASE_URL;
|
|
39
|
+
delete process.env.NOTION_API_TOKEN;
|
|
40
|
+
vi.unstubAllGlobals();
|
|
30
41
|
if (checkpointDirectory) {
|
|
31
42
|
await rm(checkpointDirectory, { recursive: true, force: true });
|
|
32
43
|
}
|
|
@@ -80,6 +91,167 @@ function emittedStepEvents(): Array<{
|
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
describe("step", () => {
|
|
94
|
+
it("exposes workflow state only while a step callback is running", async () => {
|
|
95
|
+
const context = await createWorkflowContext();
|
|
96
|
+
let escapedState: StepContext["state"] | undefined;
|
|
97
|
+
|
|
98
|
+
await context.step("Capture state", ({ state }) => {
|
|
99
|
+
escapedState = state;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await expect(escapedState?.get("cursor")).rejects.toThrow(
|
|
103
|
+
"state.get() must be called inside context.step().",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reads and writes durable workflow state inside a step", async () => {
|
|
108
|
+
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
109
|
+
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
110
|
+
const fetchMock = vi
|
|
111
|
+
.fn<typeof fetch>()
|
|
112
|
+
.mockResolvedValueOnce(
|
|
113
|
+
new Response(JSON.stringify({ exists: true, value: "cursor-1" }), {
|
|
114
|
+
status: 200,
|
|
115
|
+
headers: { "content-type": "application/json" },
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
.mockResolvedValueOnce(
|
|
119
|
+
new Response(JSON.stringify({}), {
|
|
120
|
+
status: 200,
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
125
|
+
const context = await createWorkflowContext();
|
|
126
|
+
|
|
127
|
+
const cursor = await context.step("Update cursor", async ({ state }) => {
|
|
128
|
+
const current = await state.get("cursor");
|
|
129
|
+
await state.set("cursor", "cursor-2");
|
|
130
|
+
return current;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(cursor).toBe("cursor-1");
|
|
134
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
135
|
+
1,
|
|
136
|
+
"https://api.example.com/api/v3/workersWorkflowState",
|
|
137
|
+
expect.objectContaining({
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: expect.objectContaining({
|
|
140
|
+
authorization: "Bearer workflow-token",
|
|
141
|
+
}),
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
operation: "get",
|
|
144
|
+
key: "cursor",
|
|
145
|
+
workerId: "worker-id",
|
|
146
|
+
capabilityKey: "workflow-key",
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
151
|
+
2,
|
|
152
|
+
"https://api.example.com/api/v3/workersWorkflowState",
|
|
153
|
+
expect.objectContaining({
|
|
154
|
+
method: "POST",
|
|
155
|
+
body: expect.stringMatching(
|
|
156
|
+
/^\{"operation":"commit","operationId":"run-group-id:[^"]+","mutations":\[\{"operation":"set","key":"cursor","value":"cursor-2"\}\],"workerId":"worker-id","capabilityKey":"workflow-key"\}$/,
|
|
157
|
+
),
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("discards buffered state when the step callback throws", async () => {
|
|
163
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
164
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
165
|
+
const context = await createWorkflowContext();
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
context.step("Do something", ({ state }) => {
|
|
169
|
+
state.set("foo", 42);
|
|
170
|
+
throw new Error("crash");
|
|
171
|
+
}),
|
|
172
|
+
).rejects.toThrow("crash");
|
|
173
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("reads buffered state before the step commits", async () => {
|
|
177
|
+
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
178
|
+
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
179
|
+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
|
180
|
+
new Response(JSON.stringify({ success: true }), {
|
|
181
|
+
status: 200,
|
|
182
|
+
headers: { "content-type": "application/json" },
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
186
|
+
const context = await createWorkflowContext();
|
|
187
|
+
|
|
188
|
+
const value = await context.step("Write then read", async ({ state }) => {
|
|
189
|
+
state.set("foo", 42);
|
|
190
|
+
return state.get("foo");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(value).toBe(42);
|
|
194
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("coalesces repeated mutations of a key within the step transaction", async () => {
|
|
198
|
+
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
199
|
+
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
200
|
+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
|
201
|
+
new Response(JSON.stringify({ success: true }), {
|
|
202
|
+
status: 200,
|
|
203
|
+
headers: { "content-type": "application/json" },
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
207
|
+
const context = await createWorkflowContext();
|
|
208
|
+
|
|
209
|
+
const value = await context.step("Mutate repeatedly", async ({ state }) => {
|
|
210
|
+
state.set("cursor", "first");
|
|
211
|
+
state.set("cursor", undefined);
|
|
212
|
+
state.set("cursor", "final");
|
|
213
|
+
return state.get("cursor");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(value).toBe("final");
|
|
217
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
218
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
219
|
+
"https://api.example.com/api/v3/workersWorkflowState",
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
body: expect.stringContaining(
|
|
222
|
+
'"mutations":[{"operation":"set","key":"cursor","value":"final"}]',
|
|
223
|
+
),
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("deletes a key by setting it to undefined", async () => {
|
|
229
|
+
process.env.NOTION_API_BASE_URL = "https://api.example.com";
|
|
230
|
+
process.env.NOTION_API_TOKEN = "workflow-token";
|
|
231
|
+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
|
|
232
|
+
new Response(JSON.stringify({ success: true }), {
|
|
233
|
+
status: 200,
|
|
234
|
+
headers: { "content-type": "application/json" },
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
238
|
+
const context = await createWorkflowContext();
|
|
239
|
+
|
|
240
|
+
const isDeleted = await context.step("Delete state", async ({ state }) => {
|
|
241
|
+
state.set("foo", 42);
|
|
242
|
+
state.set("foo", undefined);
|
|
243
|
+
return (await state.get("foo")) === undefined;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(isDeleted).toBe(true);
|
|
247
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
248
|
+
"https://api.example.com/api/v3/workersWorkflowState",
|
|
249
|
+
expect.objectContaining({
|
|
250
|
+
body: expect.stringContaining('"mutations":[{"operation":"delete","key":"foo"}]'),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
83
255
|
it("provides required run metadata in the workflow context", async () => {
|
|
84
256
|
const context = await createWorkflowContext();
|
|
85
257
|
|
|
@@ -683,6 +855,162 @@ describe("step", () => {
|
|
|
683
855
|
});
|
|
684
856
|
});
|
|
685
857
|
|
|
858
|
+
describe("wait.until", () => {
|
|
859
|
+
it("stops the workflow and returns its absolute resume time", async () => {
|
|
860
|
+
const now = Date.parse("2026-09-17T09:00:00.000Z");
|
|
861
|
+
const resumeAt = new Date(now + 60_000);
|
|
862
|
+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
|
|
863
|
+
let continued = false;
|
|
864
|
+
const definition = workflow({
|
|
865
|
+
name: "Wait for launch",
|
|
866
|
+
description: "Waits until launch time",
|
|
867
|
+
triggers: [triggers.notionPageCreated()],
|
|
868
|
+
handler: async (_event, context) => {
|
|
869
|
+
await context.wait.until("Launch time", { at: resumeAt });
|
|
870
|
+
continued = true;
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
|
|
876
|
+
status: "waiting",
|
|
877
|
+
wait: {
|
|
878
|
+
type: "until",
|
|
879
|
+
name: "Launch time",
|
|
880
|
+
resumeAtMs: resumeAt.getTime(),
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
expect(continued).toBe(false);
|
|
884
|
+
} finally {
|
|
885
|
+
dateNowSpy.mockRestore();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("anchors a relative wait in its durable step checkpoint", async () => {
|
|
890
|
+
const directory = await createCheckpointDirectory();
|
|
891
|
+
const initialNow = Date.parse("2026-09-17T09:00:00.000Z");
|
|
892
|
+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(initialNow);
|
|
893
|
+
let continued = false;
|
|
894
|
+
const definition = workflow({
|
|
895
|
+
name: "Wait before following up",
|
|
896
|
+
description: "Waits before continuing",
|
|
897
|
+
triggers: [triggers.notionPageCreated()],
|
|
898
|
+
handler: async (_event, context) => {
|
|
899
|
+
await context.wait.until("Follow-up delay", {
|
|
900
|
+
after: { minutes: 30, seconds: 15 },
|
|
901
|
+
});
|
|
902
|
+
continued = true;
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
try {
|
|
907
|
+
const firstResult = await definition.handler(event, { concreteOutput: true });
|
|
908
|
+
expect(firstResult).toMatchObject({
|
|
909
|
+
status: "waiting",
|
|
910
|
+
wait: { resumeAtMs: initialNow + 1_815_000 },
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const successEvent = emittedStepEvents().find(({ event }) => event === "success");
|
|
914
|
+
const stepKey = successEvent?.value.key;
|
|
915
|
+
const resumeAtMs = successEvent?.value.value;
|
|
916
|
+
if (typeof stepKey !== "string" || typeof resumeAtMs !== "number") {
|
|
917
|
+
throw new Error("Wait checkpoint event was not emitted");
|
|
918
|
+
}
|
|
919
|
+
await writeFile(
|
|
920
|
+
checkpointPath(directory, stepKey),
|
|
921
|
+
JSON.stringify({
|
|
922
|
+
version: 0,
|
|
923
|
+
key: stepKey,
|
|
924
|
+
value: resumeAtMs,
|
|
925
|
+
sourceRunId: "first-run",
|
|
926
|
+
sourceStepId: "wait-step",
|
|
927
|
+
}),
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
dateNowSpy.mockReturnValue(initialNow + 7_200_000);
|
|
931
|
+
await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
|
|
932
|
+
status: "success",
|
|
933
|
+
});
|
|
934
|
+
expect(continued).toBe(true);
|
|
935
|
+
} finally {
|
|
936
|
+
dateNowSpy.mockRestore();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("uses composite keys to distinguish waits in a loop", async () => {
|
|
941
|
+
const now = Date.parse("2026-09-17T09:00:00.000Z");
|
|
942
|
+
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
|
|
943
|
+
const itemIds = ["first", "second"];
|
|
944
|
+
const definition = workflow({
|
|
945
|
+
name: "Wait between items",
|
|
946
|
+
description: "Uses one stable display name for repeated waits",
|
|
947
|
+
triggers: [triggers.notionPageCreated()],
|
|
948
|
+
handler: async (_event, context) => {
|
|
949
|
+
for (const itemId of itemIds) {
|
|
950
|
+
await context.wait.until("Item delay", {
|
|
951
|
+
key: ["item-delay", itemId],
|
|
952
|
+
at: new Date(now),
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
await expect(definition.handler(event, { concreteOutput: true })).resolves.toEqual({
|
|
960
|
+
status: "success",
|
|
961
|
+
});
|
|
962
|
+
expect(
|
|
963
|
+
emittedStepEvents()
|
|
964
|
+
.filter(({ event }) => event === "success")
|
|
965
|
+
.map(({ value }) => value.key),
|
|
966
|
+
).toEqual([
|
|
967
|
+
workflowStepKey(["wait", "until", "item-delay", "first"]),
|
|
968
|
+
workflowStepKey(["wait", "until", "item-delay", "second"]),
|
|
969
|
+
]);
|
|
970
|
+
} finally {
|
|
971
|
+
dateNowSpy.mockRestore();
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("rejects waits that can outlive their durable checkpoint", async () => {
|
|
976
|
+
const definition = workflow({
|
|
977
|
+
name: "Wait too long",
|
|
978
|
+
description: "Rejects an unsupported wait",
|
|
979
|
+
triggers: [triggers.notionPageCreated()],
|
|
980
|
+
handler: async (_event, context) => {
|
|
981
|
+
await context.wait.until("Beyond retention", {
|
|
982
|
+
after: { days: 7 },
|
|
983
|
+
});
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
|
|
988
|
+
"Workflow waits must be shorter than 7 days.",
|
|
989
|
+
);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it.each([
|
|
993
|
+
[{ hours: 0 }, "positive finite integers"],
|
|
994
|
+
[{ minutes: 1.5 }, "positive finite integers"],
|
|
995
|
+
[{}, "at least one unit"],
|
|
996
|
+
])("rejects invalid relative durations", async (after, expectedMessage) => {
|
|
997
|
+
const definition = workflow({
|
|
998
|
+
name: "Wait for a valid duration",
|
|
999
|
+
description: "Rejects an invalid duration",
|
|
1000
|
+
triggers: [triggers.notionPageCreated()],
|
|
1001
|
+
handler: async (_event, context) => {
|
|
1002
|
+
await context.wait.until("Invalid duration", {
|
|
1003
|
+
after: after as WorkflowWaitDuration,
|
|
1004
|
+
});
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
await expect(definition.handler(event, { concreteOutput: true })).rejects.toThrow(
|
|
1009
|
+
expectedMessage,
|
|
1010
|
+
);
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
686
1014
|
async function createCheckpointDirectory(): Promise<string> {
|
|
687
1015
|
checkpointDirectory = await mkdtemp(join(tmpdir(), "apps-step-checkpoints-"));
|
|
688
1016
|
process.env.NOTION_WORKFLOW_STEP_DIRECTORY = checkpointDirectory;
|