@notionhq/workers 0.2.0 → 0.4.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/capabilities/ai_connector.d.ts +97 -0
- package/dist/capabilities/ai_connector.d.ts.map +1 -0
- package/dist/capabilities/ai_connector.js +54 -0
- package/dist/capabilities/ai_connector.test.d.ts +2 -0
- package/dist/capabilities/ai_connector.test.d.ts.map +1 -0
- package/dist/capabilities/automation.d.ts.map +1 -1
- package/dist/capabilities/automation.js +11 -11
- 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/output.d.ts +5 -0
- package/dist/capabilities/output.d.ts.map +1 -0
- package/dist/capabilities/output.js +10 -0
- package/dist/capabilities/stateful-capability.d.ts +30 -0
- package/dist/capabilities/stateful-capability.d.ts.map +1 -0
- package/dist/capabilities/stateful-capability.js +50 -0
- package/dist/capabilities/stateful-capability.test.d.ts +2 -0
- package/dist/capabilities/stateful-capability.test.d.ts.map +1 -0
- package/dist/capabilities/sync.d.ts +9 -19
- package/dist/capabilities/sync.d.ts.map +1 -1
- package/dist/capabilities/sync.js +34 -61
- package/dist/capabilities/tool.d.ts +21 -0
- package/dist/capabilities/tool.d.ts.map +1 -1
- package/dist/capabilities/tool.js +8 -22
- package/dist/capabilities/webhook.d.ts +101 -0
- package/dist/capabilities/webhook.d.ts.map +1 -0
- package/dist/capabilities/webhook.js +47 -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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/pacer_internal.d.ts +7 -0
- package/dist/pacer_internal.d.ts.map +1 -1
- package/dist/pacer_internal.js +17 -0
- package/dist/schema.d.ts +28 -11
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -2
- package/dist/worker.d.ts +92 -9
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +111 -1
- package/package.json +2 -2
- package/src/capabilities/ai_connector.test.ts +341 -0
- package/src/capabilities/ai_connector.ts +194 -0
- package/src/capabilities/automation.ts +11 -7
- package/src/capabilities/context.ts +45 -3
- package/src/capabilities/output.ts +8 -0
- package/src/capabilities/stateful-capability.test.ts +25 -0
- package/src/capabilities/stateful-capability.ts +87 -0
- package/src/capabilities/sync.test.ts +197 -4
- package/src/capabilities/sync.ts +58 -87
- package/src/capabilities/tool.test.ts +63 -0
- package/src/capabilities/tool.ts +28 -13
- package/src/capabilities/webhook.ts +148 -0
- package/src/error.ts +40 -0
- package/src/index.ts +28 -1
- package/src/pacer_internal.ts +34 -0
- package/src/schema.ts +29 -12
- package/src/worker.ts +139 -10
|
@@ -2,6 +2,7 @@ import { ExecutionError } from "../error.js";
|
|
|
2
2
|
import type { HandlerOptions } from "../types.js";
|
|
3
3
|
import type { CapabilityContext } from "./context.js";
|
|
4
4
|
import { createCapabilityContext } from "./context.js";
|
|
5
|
+
import { writeOutput } from "./output.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Event provided to automation execute functions
|
|
@@ -104,23 +105,26 @@ export function createAutomationCapability(
|
|
|
104
105
|
options?: HandlerOptions,
|
|
105
106
|
): Promise<{ status: "success" } | undefined> {
|
|
106
107
|
try {
|
|
107
|
-
const capabilityContext = createCapabilityContext();
|
|
108
|
+
const capabilityContext = createCapabilityContext("automation");
|
|
108
109
|
await config.execute(event, capabilityContext);
|
|
109
110
|
|
|
110
111
|
if (options?.concreteOutput) {
|
|
111
112
|
return { status: "success" };
|
|
112
113
|
} else {
|
|
113
|
-
|
|
114
|
-
`\n<__notion_output__>${JSON.stringify({ _tag: "success", value: { status: "success" } })}</__notion_output__>\n`,
|
|
115
|
-
);
|
|
114
|
+
writeOutput({ _tag: "success", value: { status: "success" } });
|
|
116
115
|
}
|
|
117
116
|
} catch (err) {
|
|
118
117
|
const error = new ExecutionError(err);
|
|
119
118
|
|
|
120
119
|
if (!options?.concreteOutput) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
writeOutput({
|
|
121
|
+
_tag: "error",
|
|
122
|
+
error: {
|
|
123
|
+
name: error.name,
|
|
124
|
+
message: error.message,
|
|
125
|
+
trace: error.stack,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
throw error;
|
|
@@ -1,22 +1,64 @@
|
|
|
1
1
|
import { Client } from "@notionhq/client";
|
|
2
2
|
import type { ClientOptions } from "@notionhq/client/build/src/Client.js";
|
|
3
|
+
import type { CapabilityType } from "../worker.js";
|
|
3
4
|
|
|
4
5
|
export type CapabilityContext = {
|
|
5
6
|
/** Notion API SDK client for this execution. */
|
|
6
7
|
notion: Client;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const FIX_STEPS =
|
|
11
|
+
"\n\nTo fix this:\n" +
|
|
12
|
+
"1. Create an internal integration at https://www.notion.so/profile/integrations/internal\n" +
|
|
13
|
+
"2. Give the integration access to the relevant pages/databases\n" +
|
|
14
|
+
"3. Add NOTION_API_TOKEN=<your-token> to your .env file\n" +
|
|
15
|
+
"4. For deployed workers, run: ntn workers env push";
|
|
16
|
+
|
|
17
|
+
function missingTokenMessage(capabilityType: CapabilityType): string {
|
|
18
|
+
if (capabilityType === "tool") {
|
|
19
|
+
return (
|
|
20
|
+
"NOTION_API_TOKEN is not set. " +
|
|
21
|
+
"When tools are called through a Custom Agent, the platform sets NOTION_API_TOKEN automatically, " +
|
|
22
|
+
"using the permissions of the Custom Agent. " +
|
|
23
|
+
"Outside of that context (e.g. ntn workers exec), you must set it yourself." +
|
|
24
|
+
FIX_STEPS
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
"NOTION_API_TOKEN is not set. " +
|
|
30
|
+
`context.notion requires an API token to make requests in ${capabilityType} capabilities to Notion.` +
|
|
31
|
+
FIX_STEPS
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createUnauthenticatedNotionProxy(
|
|
36
|
+
capabilityType: CapabilityType,
|
|
37
|
+
): Client {
|
|
38
|
+
return new Proxy({} as Client, {
|
|
39
|
+
get(_target, prop) {
|
|
40
|
+
if (prop === "then" || typeof prop === "symbol") {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(missingTokenMessage(capabilityType));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createCapabilityContext(
|
|
49
|
+
capabilityType: CapabilityType,
|
|
50
|
+
): CapabilityContext {
|
|
10
51
|
const options: ClientOptions = {};
|
|
11
52
|
|
|
12
53
|
if (process.env.NOTION_API_BASE_URL) {
|
|
13
54
|
options.baseUrl = process.env.NOTION_API_BASE_URL;
|
|
14
55
|
}
|
|
15
56
|
|
|
16
|
-
if (process.env.NOTION_API_TOKEN) {
|
|
17
|
-
|
|
57
|
+
if (!process.env.NOTION_API_TOKEN) {
|
|
58
|
+
return { notion: createUnauthenticatedNotionProxy(capabilityType) };
|
|
18
59
|
}
|
|
19
60
|
|
|
61
|
+
options.auth = process.env.NOTION_API_TOKEN;
|
|
20
62
|
const notion = new Client(options);
|
|
21
63
|
|
|
22
64
|
return { notion };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseSchedule } from "./stateful-capability.js";
|
|
3
|
+
|
|
4
|
+
describe("stateful capability shared primitives", () => {
|
|
5
|
+
it("parses interval schedules into normalized backend config", () => {
|
|
6
|
+
expect(parseSchedule("15m")).toEqual({
|
|
7
|
+
type: "interval",
|
|
8
|
+
intervalMs: 15 * 60 * 1000,
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("parses continuous schedules directly", () => {
|
|
13
|
+
expect(parseSchedule("continuous")).toEqual({ type: "continuous" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses manual schedules directly", () => {
|
|
17
|
+
expect(parseSchedule("manual")).toEqual({ type: "manual" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects intervals longer than the supported maximum", () => {
|
|
21
|
+
expect(() => parseSchedule("8d")).toThrow(
|
|
22
|
+
'Schedule interval must be at most 7 days. Got: "8d"',
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { unreachable } from "../error.js";
|
|
2
|
+
import type { PacerEntry } from "../pacer_internal.js";
|
|
3
|
+
import type { Schedule, SyncSchedule, TimeUnit } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared lifecycle mode used by sync-like capabilities.
|
|
7
|
+
*/
|
|
8
|
+
export type StatefulCapabilityMode = "replace" | "incremental";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base runtime context shared by sync-like capabilities.
|
|
12
|
+
*/
|
|
13
|
+
export type RuntimeContext<UserContext = unknown> = {
|
|
14
|
+
/** The user-defined/-controlled state (cursor, pagination state, etc.) */
|
|
15
|
+
state?: UserContext;
|
|
16
|
+
/** Legacy field for user-defined/-controlled state. */
|
|
17
|
+
userContext?: UserContext;
|
|
18
|
+
/** Pacer state from the server for rate limiting. */
|
|
19
|
+
pacers?: Record<string, PacerEntry>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared execution result returned from stateful capability execute functions.
|
|
24
|
+
*/
|
|
25
|
+
export type StatefulExecutionResult<Change, State = unknown> = {
|
|
26
|
+
changes: Change[];
|
|
27
|
+
hasMore: boolean;
|
|
28
|
+
nextState?: State;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MS_PER_MINUTE = 60 * 1000;
|
|
32
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
33
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
34
|
+
|
|
35
|
+
const MIN_INTERVAL_MS = MS_PER_MINUTE; // 1m
|
|
36
|
+
const MAX_INTERVAL_MS = 7 * MS_PER_DAY; // 7d
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parses a user-friendly schedule string into the normalized backend format.
|
|
40
|
+
*/
|
|
41
|
+
export function parseSchedule(schedule: Schedule): SyncSchedule {
|
|
42
|
+
if (schedule === "continuous") {
|
|
43
|
+
return { type: "continuous" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (schedule === "manual") {
|
|
47
|
+
return { type: "manual" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const match = schedule.match(/^(\d+)(m|h|d)$/);
|
|
51
|
+
if (!match || !match[1] || !match[2]) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid schedule format: "${schedule}". Use "continuous" or an interval like "30m", "1h", "1d".`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = parseInt(match[1], 10);
|
|
58
|
+
const unit = match[2] as TimeUnit;
|
|
59
|
+
|
|
60
|
+
let intervalMs: number;
|
|
61
|
+
switch (unit) {
|
|
62
|
+
case "m":
|
|
63
|
+
intervalMs = value * MS_PER_MINUTE;
|
|
64
|
+
break;
|
|
65
|
+
case "h":
|
|
66
|
+
intervalMs = value * MS_PER_HOUR;
|
|
67
|
+
break;
|
|
68
|
+
case "d":
|
|
69
|
+
intervalMs = value * MS_PER_DAY;
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
unreachable(unit);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (intervalMs < MIN_INTERVAL_MS) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Schedule interval must be at least 1 minute. Got: "${schedule}"`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (intervalMs > MAX_INTERVAL_MS) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Schedule interval must be at most 7 days. Got: "${schedule}"`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { type: "interval", intervalMs };
|
|
87
|
+
}
|
|
@@ -8,7 +8,8 @@ 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
|
+
import { getPacerState } from "../pacer_internal.js";
|
|
12
13
|
import * as Schema from "../schema.js";
|
|
13
14
|
import { Worker } from "../worker.js";
|
|
14
15
|
import { createSyncCapability } from "./sync.js";
|
|
@@ -28,11 +29,11 @@ describe("Worker.sync", () => {
|
|
|
28
29
|
|
|
29
30
|
describe("Schema.relation", () => {
|
|
30
31
|
it("creates a relation property configuration", () => {
|
|
31
|
-
const relationProp = Schema.relation("
|
|
32
|
+
const relationProp = Schema.relation("projects");
|
|
32
33
|
|
|
33
34
|
expect(relationProp).toEqual({
|
|
34
35
|
type: "relation",
|
|
35
|
-
|
|
36
|
+
relatedDatabaseKey: "projects",
|
|
36
37
|
config: { twoWay: false },
|
|
37
38
|
});
|
|
38
39
|
});
|
|
@@ -68,7 +69,7 @@ describe("Worker.sync", () => {
|
|
|
68
69
|
schema: {
|
|
69
70
|
properties: {
|
|
70
71
|
"Task ID": Schema.title(),
|
|
71
|
-
Project: Schema.relation("
|
|
72
|
+
Project: Schema.relation("projects"),
|
|
72
73
|
},
|
|
73
74
|
},
|
|
74
75
|
});
|
|
@@ -106,6 +107,91 @@ describe("Worker.sync", () => {
|
|
|
106
107
|
});
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
describe("pacer local fallback", () => {
|
|
111
|
+
it("seeds pacer state from declarations when runtime context has no pacers", async () => {
|
|
112
|
+
const worker = new Worker();
|
|
113
|
+
const db = worker.database("test", {
|
|
114
|
+
type: "managed",
|
|
115
|
+
initialTitle: "Test",
|
|
116
|
+
primaryKeyProperty: "ID",
|
|
117
|
+
schema: { properties: { ID: Schema.title() } },
|
|
118
|
+
});
|
|
119
|
+
const pacer = worker.pacer("myApi", {
|
|
120
|
+
allowedRequests: 10,
|
|
121
|
+
intervalMs: 1000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let pacerWaitCalled = false;
|
|
125
|
+
const capability = createSyncCapability(
|
|
126
|
+
"testSync",
|
|
127
|
+
{
|
|
128
|
+
database: db,
|
|
129
|
+
execute: async () => {
|
|
130
|
+
await pacer.wait();
|
|
131
|
+
pacerWaitCalled = true;
|
|
132
|
+
return { changes: [], hasMore: false };
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
[{ key: "myApi", config: { allowedRequests: 10, intervalMs: 1000 } }],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// No runtimeContext → no pacers from server → should fall back to declarations
|
|
139
|
+
await capability.handler(undefined, { concreteOutput: true });
|
|
140
|
+
|
|
141
|
+
expect(pacerWaitCalled).toBe(true);
|
|
142
|
+
const state = getPacerState();
|
|
143
|
+
expect(state.pacers.myApi).toBeDefined();
|
|
144
|
+
expect(state.pacers.myApi?.allowedRequests).toBe(10);
|
|
145
|
+
expect(state.pacers.myApi?.intervalMs).toBe(1000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("uses server-provided pacers when available", async () => {
|
|
149
|
+
const worker = new Worker();
|
|
150
|
+
const db = worker.database("test", {
|
|
151
|
+
type: "managed",
|
|
152
|
+
initialTitle: "Test",
|
|
153
|
+
primaryKeyProperty: "ID",
|
|
154
|
+
schema: { properties: { ID: Schema.title() } },
|
|
155
|
+
});
|
|
156
|
+
const pacer = worker.pacer("myApi", {
|
|
157
|
+
allowedRequests: 10,
|
|
158
|
+
intervalMs: 1000,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let pacerWaitCalled = false;
|
|
162
|
+
const capability = createSyncCapability(
|
|
163
|
+
"testSync",
|
|
164
|
+
{
|
|
165
|
+
database: db,
|
|
166
|
+
execute: async () => {
|
|
167
|
+
await pacer.wait();
|
|
168
|
+
pacerWaitCalled = true;
|
|
169
|
+
return { changes: [], hasMore: false };
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
[{ key: "myApi", config: { allowedRequests: 10, intervalMs: 1000 } }],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Server provides apportioned budget (e.g., 5 instead of 10)
|
|
176
|
+
await capability.handler(
|
|
177
|
+
{
|
|
178
|
+
pacers: {
|
|
179
|
+
myApi: {
|
|
180
|
+
lastScheduledAtMs: 0,
|
|
181
|
+
allowedRequests: 5,
|
|
182
|
+
intervalMs: 1000,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{ concreteOutput: true },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(pacerWaitCalled).toBe(true);
|
|
190
|
+
// Should use server-provided value, not the declaration
|
|
191
|
+
expect(getPacerState().pacers.myApi?.allowedRequests).toBe(5);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
109
195
|
describe("handler output envelope", () => {
|
|
110
196
|
it("wraps successful result in success envelope", async () => {
|
|
111
197
|
const worker = new Worker();
|
|
@@ -165,6 +251,113 @@ describe("Worker.sync", () => {
|
|
|
165
251
|
);
|
|
166
252
|
});
|
|
167
253
|
|
|
254
|
+
it("wraps RateLimitError in error envelope with _tag rate_limit", async () => {
|
|
255
|
+
const worker = new Worker();
|
|
256
|
+
const db = worker.database("test", {
|
|
257
|
+
type: "managed",
|
|
258
|
+
initialTitle: "Test",
|
|
259
|
+
primaryKeyProperty: "ID",
|
|
260
|
+
schema: {
|
|
261
|
+
properties: {
|
|
262
|
+
ID: Schema.title(),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
268
|
+
database: db,
|
|
269
|
+
execute: async () => {
|
|
270
|
+
throw new RateLimitError();
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
275
|
+
|
|
276
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringContaining(`"_tag":"rate_limit"`),
|
|
278
|
+
);
|
|
279
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
280
|
+
expect.stringContaining(`"name":"RateLimitError"`),
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("includes retryAfter in rate_limit error envelope when provided", async () => {
|
|
285
|
+
const worker = new Worker();
|
|
286
|
+
const db = worker.database("test", {
|
|
287
|
+
type: "managed",
|
|
288
|
+
initialTitle: "Test",
|
|
289
|
+
primaryKeyProperty: "ID",
|
|
290
|
+
schema: {
|
|
291
|
+
properties: {
|
|
292
|
+
ID: Schema.title(),
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
298
|
+
database: db,
|
|
299
|
+
execute: async () => {
|
|
300
|
+
throw new RateLimitError({ retryAfter: 30 });
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
305
|
+
|
|
306
|
+
const output = stdoutSpy.mock.calls
|
|
307
|
+
.map((call) => String(call[0]))
|
|
308
|
+
.find((s) => s.includes(`"_tag":"rate_limit"`)) as string;
|
|
309
|
+
const parsed = JSON.parse(
|
|
310
|
+
output.replace(
|
|
311
|
+
/\n<__notion_output__>(.*)<\/__notion_output__>\n/,
|
|
312
|
+
"$1",
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
expect(parsed).toEqual({
|
|
316
|
+
_tag: "error",
|
|
317
|
+
error: {
|
|
318
|
+
_tag: "rate_limit",
|
|
319
|
+
name: "RateLimitError",
|
|
320
|
+
message:
|
|
321
|
+
"Error during worker execution: Rate limited by external API",
|
|
322
|
+
retryAfter: 30,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("wraps RateLimitError without retryAfter correctly", async () => {
|
|
328
|
+
const worker = new Worker();
|
|
329
|
+
const db = worker.database("test", {
|
|
330
|
+
type: "managed",
|
|
331
|
+
initialTitle: "Test",
|
|
332
|
+
primaryKeyProperty: "ID",
|
|
333
|
+
schema: {
|
|
334
|
+
properties: {
|
|
335
|
+
ID: Schema.title(),
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const capability = createSyncCapability("rateLimitSync", {
|
|
341
|
+
database: db,
|
|
342
|
+
execute: async () => {
|
|
343
|
+
throw new RateLimitError();
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await expect(capability.handler()).rejects.toThrow(RateLimitError);
|
|
348
|
+
|
|
349
|
+
const output = stdoutSpy.mock.calls
|
|
350
|
+
.map((call) => String(call[0]))
|
|
351
|
+
.find((s) => s.includes(`"_tag":"rate_limit"`)) as string;
|
|
352
|
+
const parsed = JSON.parse(
|
|
353
|
+
output.replace(
|
|
354
|
+
/\n<__notion_output__>(.*)<\/__notion_output__>\n/,
|
|
355
|
+
"$1",
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
expect(parsed.error.retryAfter).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
|
|
168
361
|
it("emits sync output with key and default targetDatabaseKey", async () => {
|
|
169
362
|
const worker = new Worker();
|
|
170
363
|
const tasks = worker.database("tasks", {
|
package/src/capabilities/sync.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ExecutionError,
|
|
1
|
+
import { ExecutionError, RateLimitError } from "../error.js";
|
|
2
2
|
import {
|
|
3
3
|
getPacerState,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
initPacerState,
|
|
5
|
+
type PacerDeclaration,
|
|
6
6
|
} from "../pacer_internal.js";
|
|
7
7
|
import type {
|
|
8
8
|
PropertyConfiguration,
|
|
@@ -16,13 +16,29 @@ import type {
|
|
|
16
16
|
PlaceValue,
|
|
17
17
|
RelationValue,
|
|
18
18
|
Schedule,
|
|
19
|
-
SyncSchedule,
|
|
20
19
|
TextValue,
|
|
21
|
-
TimeUnit,
|
|
22
20
|
} from "../types.js";
|
|
23
21
|
import type { DatabaseHandle } from "../worker.js";
|
|
24
22
|
import type { CapabilityContext } from "./context.js";
|
|
25
23
|
import { createCapabilityContext } from "./context.js";
|
|
24
|
+
import { writeOutput } from "./output.js";
|
|
25
|
+
import {
|
|
26
|
+
parseSchedule,
|
|
27
|
+
type StatefulCapabilityMode,
|
|
28
|
+
type RuntimeContext as StatefulRuntimeContext,
|
|
29
|
+
} from "./stateful-capability.js";
|
|
30
|
+
|
|
31
|
+
type SyncErrorEnvelope =
|
|
32
|
+
| { _tag: "error"; error: { _tag: "generic"; name: string; message: string } }
|
|
33
|
+
| {
|
|
34
|
+
_tag: "error";
|
|
35
|
+
error: {
|
|
36
|
+
_tag: "rate_limit";
|
|
37
|
+
name: string;
|
|
38
|
+
message: string;
|
|
39
|
+
retryAfter?: number;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
26
42
|
|
|
27
43
|
/**
|
|
28
44
|
* Maps a property configuration to its corresponding value type.
|
|
@@ -40,7 +56,7 @@ type PropertyValueType<T extends PropertyConfiguration> = T extends {
|
|
|
40
56
|
/**
|
|
41
57
|
* Sync mode determines how the sync handles data lifecycle.
|
|
42
58
|
*/
|
|
43
|
-
export type SyncMode =
|
|
59
|
+
export type SyncMode = StatefulCapabilityMode;
|
|
44
60
|
|
|
45
61
|
/**
|
|
46
62
|
* A change representing a record to be created or updated.
|
|
@@ -204,18 +220,6 @@ export type SyncConfiguration<
|
|
|
204
220
|
|
|
205
221
|
export type SyncCapability = ReturnType<typeof createSyncCapability>;
|
|
206
222
|
|
|
207
|
-
/**
|
|
208
|
-
* Runtime context object passed from the runtime to sync capability handlers.
|
|
209
|
-
*/
|
|
210
|
-
type RuntimeContext<UserContext = unknown> = {
|
|
211
|
-
/** The user-defined/-controlled state (cursor, pagination state, etc.) */
|
|
212
|
-
state?: UserContext;
|
|
213
|
-
/** Legacy field for user-defined/-controlled state. */
|
|
214
|
-
userContext?: UserContext;
|
|
215
|
-
/** Pacer state from the server for rate limiting. */
|
|
216
|
-
pacers?: Record<string, PacerEntry>;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
223
|
/**
|
|
220
224
|
* Creates a special handler for syncing third-party data to a database.
|
|
221
225
|
*
|
|
@@ -227,7 +231,11 @@ export function createSyncCapability<
|
|
|
227
231
|
PK extends string,
|
|
228
232
|
S extends Schema<PK>,
|
|
229
233
|
Context = unknown,
|
|
230
|
-
>(
|
|
234
|
+
>(
|
|
235
|
+
key: string,
|
|
236
|
+
syncConfiguration: SyncConfiguration<PK, S, Context>,
|
|
237
|
+
pacerDeclarations?: readonly PacerDeclaration[],
|
|
238
|
+
) {
|
|
231
239
|
return {
|
|
232
240
|
_tag: "sync" as const,
|
|
233
241
|
key,
|
|
@@ -240,14 +248,13 @@ export function createSyncCapability<
|
|
|
240
248
|
: undefined,
|
|
241
249
|
},
|
|
242
250
|
async handler(
|
|
243
|
-
runtimeContext?:
|
|
251
|
+
runtimeContext?: StatefulRuntimeContext<Context>,
|
|
244
252
|
options?: HandlerOptions,
|
|
245
253
|
) {
|
|
246
|
-
const capabilityContext = createCapabilityContext();
|
|
254
|
+
const capabilityContext = createCapabilityContext("sync");
|
|
247
255
|
const state = runtimeContext?.state ?? runtimeContext?.userContext;
|
|
248
256
|
|
|
249
|
-
|
|
250
|
-
setPacerState({ pacers: runtimeContext?.pacers ?? {} });
|
|
257
|
+
initPacerState(runtimeContext?.pacers, pacerDeclarations);
|
|
251
258
|
|
|
252
259
|
let executionResult: SyncExecutionResult<PK, Context>;
|
|
253
260
|
try {
|
|
@@ -256,11 +263,35 @@ export function createSyncCapability<
|
|
|
256
263
|
capabilityContext,
|
|
257
264
|
);
|
|
258
265
|
} catch (err) {
|
|
266
|
+
if (err instanceof RateLimitError) {
|
|
267
|
+
if (!options?.concreteOutput) {
|
|
268
|
+
const envelope: SyncErrorEnvelope = {
|
|
269
|
+
_tag: "error",
|
|
270
|
+
error: {
|
|
271
|
+
_tag: "rate_limit",
|
|
272
|
+
name: err.name,
|
|
273
|
+
message: err.message,
|
|
274
|
+
...(err.retryAfter !== undefined
|
|
275
|
+
? { retryAfter: err.retryAfter }
|
|
276
|
+
: {}),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
writeOutput(envelope);
|
|
280
|
+
}
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
|
|
259
284
|
const error = new ExecutionError(err);
|
|
260
285
|
if (!options?.concreteOutput) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
286
|
+
const envelope: SyncErrorEnvelope = {
|
|
287
|
+
_tag: "error",
|
|
288
|
+
error: {
|
|
289
|
+
_tag: "generic",
|
|
290
|
+
name: error.name,
|
|
291
|
+
message: error.message,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
writeOutput(envelope);
|
|
264
295
|
}
|
|
265
296
|
throw error;
|
|
266
297
|
}
|
|
@@ -281,70 +312,10 @@ export function createSyncCapability<
|
|
|
281
312
|
if (options?.concreteOutput) {
|
|
282
313
|
return result;
|
|
283
314
|
} else {
|
|
284
|
-
|
|
285
|
-
`\n<__notion_output__>${JSON.stringify({ _tag: "success", value: result })}</__notion_output__>\n`,
|
|
286
|
-
);
|
|
315
|
+
writeOutput({ _tag: "success", value: result });
|
|
287
316
|
}
|
|
288
317
|
|
|
289
318
|
return result;
|
|
290
319
|
},
|
|
291
320
|
};
|
|
292
321
|
}
|
|
293
|
-
|
|
294
|
-
const MS_PER_MINUTE = 60 * 1000;
|
|
295
|
-
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
296
|
-
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
297
|
-
|
|
298
|
-
const MIN_INTERVAL_MS = MS_PER_MINUTE; // 1m
|
|
299
|
-
const MAX_INTERVAL_MS = 7 * MS_PER_DAY; // 7d
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Parses a user-friendly schedule string into the normalized backend format.
|
|
303
|
-
*/
|
|
304
|
-
function parseSchedule(schedule: Schedule): SyncSchedule {
|
|
305
|
-
if (schedule === "continuous") {
|
|
306
|
-
return { type: "continuous" };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (schedule === "manual") {
|
|
310
|
-
return { type: "manual" };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const match = schedule.match(/^(\d+)(m|h|d)$/);
|
|
314
|
-
if (!match || !match[1] || !match[2]) {
|
|
315
|
-
throw new Error(
|
|
316
|
-
`Invalid schedule format: "${schedule}". Use "continuous" or an interval like "30m", "1h", "1d".`,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const value = parseInt(match[1], 10);
|
|
321
|
-
const unit = match[2] as TimeUnit;
|
|
322
|
-
|
|
323
|
-
let intervalMs: number;
|
|
324
|
-
switch (unit) {
|
|
325
|
-
case "m":
|
|
326
|
-
intervalMs = value * MS_PER_MINUTE;
|
|
327
|
-
break;
|
|
328
|
-
case "h":
|
|
329
|
-
intervalMs = value * MS_PER_HOUR;
|
|
330
|
-
break;
|
|
331
|
-
case "d":
|
|
332
|
-
intervalMs = value * MS_PER_DAY;
|
|
333
|
-
break;
|
|
334
|
-
default:
|
|
335
|
-
unreachable(unit);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (intervalMs < MIN_INTERVAL_MS) {
|
|
339
|
-
throw new Error(
|
|
340
|
-
`Schedule interval must be at least 1 minute. Got: "${schedule}"`,
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
if (intervalMs > MAX_INTERVAL_MS) {
|
|
344
|
-
throw new Error(
|
|
345
|
-
`Schedule interval must be at most 7 days. Got: "${schedule}"`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
return { type: "interval", intervalMs };
|
|
350
|
-
}
|