@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.
Files changed (60) hide show
  1. package/dist/capabilities/ai_connector.d.ts +97 -0
  2. package/dist/capabilities/ai_connector.d.ts.map +1 -0
  3. package/dist/capabilities/ai_connector.js +54 -0
  4. package/dist/capabilities/ai_connector.test.d.ts +2 -0
  5. package/dist/capabilities/ai_connector.test.d.ts.map +1 -0
  6. package/dist/capabilities/automation.d.ts.map +1 -1
  7. package/dist/capabilities/automation.js +11 -11
  8. package/dist/capabilities/context.d.ts +2 -1
  9. package/dist/capabilities/context.d.ts.map +1 -1
  10. package/dist/capabilities/context.js +21 -3
  11. package/dist/capabilities/output.d.ts +5 -0
  12. package/dist/capabilities/output.d.ts.map +1 -0
  13. package/dist/capabilities/output.js +10 -0
  14. package/dist/capabilities/stateful-capability.d.ts +30 -0
  15. package/dist/capabilities/stateful-capability.d.ts.map +1 -0
  16. package/dist/capabilities/stateful-capability.js +50 -0
  17. package/dist/capabilities/stateful-capability.test.d.ts +2 -0
  18. package/dist/capabilities/stateful-capability.test.d.ts.map +1 -0
  19. package/dist/capabilities/sync.d.ts +9 -19
  20. package/dist/capabilities/sync.d.ts.map +1 -1
  21. package/dist/capabilities/sync.js +34 -61
  22. package/dist/capabilities/tool.d.ts +21 -0
  23. package/dist/capabilities/tool.d.ts.map +1 -1
  24. package/dist/capabilities/tool.js +8 -22
  25. package/dist/capabilities/webhook.d.ts +101 -0
  26. package/dist/capabilities/webhook.d.ts.map +1 -0
  27. package/dist/capabilities/webhook.js +47 -0
  28. package/dist/error.d.ts +36 -0
  29. package/dist/error.d.ts.map +1 -1
  30. package/dist/error.js +14 -0
  31. package/dist/index.d.ts +5 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -0
  34. package/dist/pacer_internal.d.ts +7 -0
  35. package/dist/pacer_internal.d.ts.map +1 -1
  36. package/dist/pacer_internal.js +17 -0
  37. package/dist/schema.d.ts +28 -11
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +2 -2
  40. package/dist/worker.d.ts +92 -9
  41. package/dist/worker.d.ts.map +1 -1
  42. package/dist/worker.js +111 -1
  43. package/package.json +2 -2
  44. package/src/capabilities/ai_connector.test.ts +341 -0
  45. package/src/capabilities/ai_connector.ts +194 -0
  46. package/src/capabilities/automation.ts +11 -7
  47. package/src/capabilities/context.ts +45 -3
  48. package/src/capabilities/output.ts +8 -0
  49. package/src/capabilities/stateful-capability.test.ts +25 -0
  50. package/src/capabilities/stateful-capability.ts +87 -0
  51. package/src/capabilities/sync.test.ts +197 -4
  52. package/src/capabilities/sync.ts +58 -87
  53. package/src/capabilities/tool.test.ts +63 -0
  54. package/src/capabilities/tool.ts +28 -13
  55. package/src/capabilities/webhook.ts +148 -0
  56. package/src/error.ts +40 -0
  57. package/src/index.ts +28 -1
  58. package/src/pacer_internal.ts +34 -0
  59. package/src/schema.ts +29 -12
  60. 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
- process.stdout.write(
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
- process.stdout.write(
122
- `\n<__notion_output__>${JSON.stringify({ _tag: "error", error: { name: error.name, message: error.message, trace: error.stack } })}</__notion_output__>\n`,
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
- export function createCapabilityContext(): CapabilityContext {
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
- options.auth = process.env.NOTION_API_TOKEN;
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,8 @@
1
+ /**
2
+ * Writes a notion_output envelope to stdout using the shared wire format.
3
+ */
4
+ export function writeOutput(value: unknown): void {
5
+ process.stdout.write(
6
+ `\n<__notion_output__>${JSON.stringify(value)}</__notion_output__>\n`,
7
+ );
8
+ }
@@ -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("projectsSync");
32
+ const relationProp = Schema.relation("projects");
32
33
 
33
34
  expect(relationProp).toEqual({
34
35
  type: "relation",
35
- relatedSyncKey: "projectsSync",
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("projectsSync"),
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", {
@@ -1,8 +1,8 @@
1
- import { ExecutionError, unreachable } from "../error.js";
1
+ import { ExecutionError, RateLimitError } from "../error.js";
2
2
  import {
3
3
  getPacerState,
4
- type PacerEntry,
5
- setPacerState,
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 = "replace" | "incremental";
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
- >(key: string, syncConfiguration: SyncConfiguration<PK, S, Context>) {
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?: RuntimeContext<Context>,
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
- // Initialize pacer state from runtime context
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
- process.stdout.write(
262
- `\n<__notion_output__>${JSON.stringify({ _tag: "error", error: { name: error.name, message: error.message } })}</__notion_output__>\n`,
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
- process.stdout.write(
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
- }