@notionhq/workers 0.1.0 → 0.3.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 (67) hide show
  1. package/dist/ajv-formats.d.js +0 -0
  2. package/dist/capabilities/ai_connector.d.ts +97 -0
  3. package/dist/capabilities/ai_connector.d.ts.map +1 -0
  4. package/dist/capabilities/ai_connector.js +54 -0
  5. package/dist/capabilities/ai_connector.test.d.ts +2 -0
  6. package/dist/capabilities/ai_connector.test.d.ts.map +1 -0
  7. package/dist/capabilities/automation.d.ts.map +1 -1
  8. package/dist/capabilities/automation.js +10 -10
  9. package/dist/capabilities/output.d.ts +5 -0
  10. package/dist/capabilities/output.d.ts.map +1 -0
  11. package/dist/capabilities/output.js +10 -0
  12. package/dist/capabilities/stateful-capability.d.ts +30 -0
  13. package/dist/capabilities/stateful-capability.d.ts.map +1 -0
  14. package/dist/capabilities/stateful-capability.js +50 -0
  15. package/dist/capabilities/stateful-capability.test.d.ts +2 -0
  16. package/dist/capabilities/stateful-capability.test.d.ts.map +1 -0
  17. package/dist/capabilities/sync.d.ts +77 -30
  18. package/dist/capabilities/sync.d.ts.map +1 -1
  19. package/dist/capabilities/sync.js +26 -64
  20. package/dist/capabilities/tool.d.ts.map +1 -1
  21. package/dist/capabilities/tool.js +7 -14
  22. package/dist/capabilities/webhook.d.ts +101 -0
  23. package/dist/capabilities/webhook.d.ts.map +1 -0
  24. package/dist/capabilities/webhook.js +47 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/pacer.d.ts +24 -0
  29. package/dist/pacer.d.ts.map +1 -0
  30. package/dist/pacer.js +14 -0
  31. package/dist/pacer.test.d.ts +2 -0
  32. package/dist/pacer.test.d.ts.map +1 -0
  33. package/dist/pacer_internal.d.ts +13 -0
  34. package/dist/pacer_internal.d.ts.map +1 -0
  35. package/dist/pacer_internal.js +49 -0
  36. package/dist/schema.d.ts +28 -18
  37. package/dist/schema.d.ts.map +1 -1
  38. package/dist/schema.js +2 -2
  39. package/dist/types.d.ts +5 -2
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/worker.d.ts +222 -7
  42. package/dist/worker.d.ts.map +1 -1
  43. package/dist/worker.js +184 -8
  44. package/dist/worker.test.d.ts +2 -0
  45. package/dist/worker.test.d.ts.map +1 -0
  46. package/package.json +7 -2
  47. package/src/ajv-formats.d.ts +7 -0
  48. package/src/capabilities/ai_connector.test.ts +341 -0
  49. package/src/capabilities/ai_connector.ts +194 -0
  50. package/src/capabilities/automation.test.ts +2 -2
  51. package/src/capabilities/automation.ts +10 -6
  52. package/src/capabilities/output.ts +8 -0
  53. package/src/capabilities/stateful-capability.test.ts +25 -0
  54. package/src/capabilities/stateful-capability.ts +87 -0
  55. package/src/capabilities/sync.test.ts +178 -35
  56. package/src/capabilities/sync.ts +63 -100
  57. package/src/capabilities/tool.test.ts +47 -3
  58. package/src/capabilities/tool.ts +7 -6
  59. package/src/capabilities/webhook.ts +148 -0
  60. package/src/index.ts +31 -0
  61. package/src/pacer.test.ts +110 -0
  62. package/src/pacer.ts +25 -0
  63. package/src/pacer_internal.ts +94 -0
  64. package/src/schema.ts +29 -19
  65. package/src/types.ts +4 -2
  66. package/src/worker.test.ts +167 -0
  67. package/src/worker.ts +335 -10
@@ -9,7 +9,9 @@ import {
9
9
  } from "vitest";
10
10
  import * as Builder from "../builder.js";
11
11
  import { ExecutionError } from "../error.js";
12
+ import { getPacerState } from "../pacer_internal.js";
12
13
  import * as Schema from "../schema.js";
14
+ import { Worker } from "../worker.js";
13
15
  import { createSyncCapability } from "./sync.js";
14
16
 
15
17
  describe("Worker.sync", () => {
@@ -27,34 +29,11 @@ describe("Worker.sync", () => {
27
29
 
28
30
  describe("Schema.relation", () => {
29
31
  it("creates a relation property configuration", () => {
30
- const relationProp = Schema.relation("projectsSync");
32
+ const relationProp = Schema.relation("projects");
31
33
 
32
34
  expect(relationProp).toEqual({
33
35
  type: "relation",
34
- relatedSyncKey: "projectsSync",
35
- config: { twoWay: false },
36
- });
37
- });
38
-
39
- it("can be used in a schema definition", () => {
40
- const capability = createSyncCapability("tasksSync", {
41
- primaryKeyProperty: "Task ID",
42
- schema: {
43
- defaultName: "Tasks",
44
- properties: {
45
- "Task ID": Schema.title(),
46
- Project: Schema.relation("projectsSync"),
47
- },
48
- },
49
- execute: async () => ({
50
- changes: [],
51
- hasMore: false,
52
- }),
53
- });
54
-
55
- expect(capability.config.schema.properties.Project).toEqual({
56
- type: "relation",
57
- relatedSyncKey: "projectsSync",
36
+ relatedDatabaseKey: "projects",
58
37
  config: { twoWay: false },
59
38
  });
60
39
  });
@@ -82,15 +61,21 @@ describe("Worker.sync", () => {
82
61
 
83
62
  describe("SyncedObject with relation properties", () => {
84
63
  it("allows relation values in synced objects", async () => {
85
- const capability = createSyncCapability("tasksSync", {
64
+ const worker = new Worker();
65
+ const tasks = worker.database("tasks", {
66
+ type: "managed",
67
+ initialTitle: "Tasks",
86
68
  primaryKeyProperty: "Task ID",
87
69
  schema: {
88
- defaultName: "Tasks",
89
70
  properties: {
90
71
  "Task ID": Schema.title(),
91
- Project: Schema.relation("projectsSync"),
72
+ Project: Schema.relation("projects"),
92
73
  },
93
74
  },
75
+ });
76
+
77
+ const capability = createSyncCapability("tasksSync", {
78
+ database: tasks,
94
79
  execute: async () => ({
95
80
  changes: [
96
81
  {
@@ -117,22 +102,112 @@ describe("Worker.sync", () => {
117
102
  }),
118
103
  });
119
104
 
120
- // Verify the capability is properly configured
121
105
  expect(capability._tag).toBe("sync");
122
- expect(capability.config.primaryKeyProperty).toBe("Task ID");
106
+ expect(capability.config.databaseKey).toBe("tasks");
107
+ });
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);
123
192
  });
124
193
  });
125
194
 
126
195
  describe("handler output envelope", () => {
127
196
  it("wraps successful result in success envelope", async () => {
128
- const capability = createSyncCapability("testSync", {
197
+ const worker = new Worker();
198
+ const db = worker.database("test", {
199
+ type: "managed",
200
+ initialTitle: "Test",
129
201
  primaryKeyProperty: "ID",
130
202
  schema: {
131
- defaultName: "Test",
132
203
  properties: {
133
204
  ID: Schema.title(),
134
205
  },
135
206
  },
207
+ });
208
+
209
+ const capability = createSyncCapability("testSync", {
210
+ database: db,
136
211
  execute: async () => ({
137
212
  changes: [],
138
213
  hasMore: false,
@@ -142,19 +217,25 @@ describe("Worker.sync", () => {
142
217
  await capability.handler();
143
218
 
144
219
  expect(stdoutSpy).toHaveBeenCalledWith(
145
- `\n<output>${JSON.stringify({ _tag: "success", value: { changes: [], hasMore: false, nextUserContext: undefined } })}</output>\n`,
220
+ `\n<__notion_output__>${JSON.stringify({ _tag: "success", value: { changes: [], hasMore: false, nextUserContext: undefined, nextPacerStates: {} } })}</__notion_output__>\n`,
146
221
  );
147
222
  });
148
223
 
149
224
  it("wraps error in error envelope", async () => {
150
- const capability = createSyncCapability("errorSync", {
225
+ const worker = new Worker();
226
+ const db = worker.database("test", {
227
+ type: "managed",
228
+ initialTitle: "Test",
151
229
  primaryKeyProperty: "ID",
152
230
  schema: {
153
- defaultName: "Test",
154
231
  properties: {
155
232
  ID: Schema.title(),
156
233
  },
157
234
  },
235
+ });
236
+
237
+ const capability = createSyncCapability("errorSync", {
238
+ database: db,
158
239
  execute: async () => {
159
240
  throw new Error("sync failed");
160
241
  },
@@ -169,5 +250,67 @@ describe("Worker.sync", () => {
169
250
  expect.stringContaining(`"name":"ExecutionError"`),
170
251
  );
171
252
  });
253
+
254
+ it("emits sync output with key and default targetDatabaseKey", async () => {
255
+ const worker = new Worker();
256
+ const tasks = worker.database("tasks", {
257
+ type: "managed",
258
+ initialTitle: "Tasks",
259
+ primaryKeyProperty: "Name",
260
+ schema: {
261
+ properties: {
262
+ Name: Schema.title(),
263
+ },
264
+ },
265
+ });
266
+
267
+ const capability = createSyncCapability("tasksSync", {
268
+ database: tasks,
269
+ execute: async () => ({
270
+ changes: [
271
+ {
272
+ type: "upsert" as const,
273
+ key: "task-1",
274
+ properties: {
275
+ Name: Builder.title("Write docs"),
276
+ },
277
+ },
278
+ {
279
+ type: "delete" as const,
280
+ key: "task-2",
281
+ },
282
+ ],
283
+ hasMore: false,
284
+ }),
285
+ });
286
+
287
+ await capability.handler();
288
+
289
+ expect(stdoutSpy).toHaveBeenCalledWith(
290
+ `\n<__notion_output__>${JSON.stringify({
291
+ _tag: "success",
292
+ value: {
293
+ changes: [
294
+ {
295
+ type: "upsert",
296
+ key: "task-1",
297
+ properties: {
298
+ Name: Builder.title("Write docs"),
299
+ },
300
+ targetDatabaseKey: "tasks",
301
+ },
302
+ {
303
+ type: "delete",
304
+ key: "task-2",
305
+ targetDatabaseKey: "tasks",
306
+ },
307
+ ],
308
+ hasMore: false,
309
+ nextUserContext: undefined,
310
+ nextPacerStates: {},
311
+ },
312
+ })}</__notion_output__>\n`,
313
+ );
314
+ });
172
315
  });
173
316
  });
@@ -1,4 +1,9 @@
1
- import { ExecutionError, unreachable } from "../error.js";
1
+ import { ExecutionError } from "../error.js";
2
+ import {
3
+ getPacerState,
4
+ initPacerState,
5
+ type PacerDeclaration,
6
+ } from "../pacer_internal.js";
2
7
  import type {
3
8
  PropertyConfiguration,
4
9
  PropertySchema,
@@ -11,12 +16,17 @@ import type {
11
16
  PlaceValue,
12
17
  RelationValue,
13
18
  Schedule,
14
- SyncSchedule,
15
19
  TextValue,
16
- TimeUnit,
17
20
  } from "../types.js";
21
+ import type { DatabaseHandle } from "../worker.js";
18
22
  import type { CapabilityContext } from "./context.js";
19
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";
20
30
 
21
31
  /**
22
32
  * Maps a property configuration to its corresponding value type.
@@ -34,7 +44,7 @@ type PropertyValueType<T extends PropertyConfiguration> = T extends {
34
44
  /**
35
45
  * Sync mode determines how the sync handles data lifecycle.
36
46
  */
37
- export type SyncMode = "replace" | "incremental";
47
+ export type SyncMode = StatefulCapabilityMode;
38
48
 
39
49
  /**
40
50
  * A change representing a record to be created or updated.
@@ -49,9 +59,13 @@ export type SyncChangeUpsert<
49
59
  type: "upsert";
50
60
  /**
51
61
  * A unique identifier for this record, used to match against existing pages.
52
- * This value will be stored in the property specified by `primaryKeyProperty`.
53
62
  */
54
63
  key: string;
64
+ /**
65
+ * The key of the database to write to. If omitted, defaults to
66
+ * the database associated with the sync capability.
67
+ */
68
+ targetDatabaseKey?: string;
55
69
  /**
56
70
  * The property values for this record.
57
71
  * Keys must match the property names defined in the schema.
@@ -60,6 +74,13 @@ export type SyncChangeUpsert<
60
74
  properties: {
61
75
  [Property in keyof S]: PropertyValueType<S[Property]>;
62
76
  };
77
+ /**
78
+ * When this record was last updated in the upstream system.
79
+ * ISO 8601 string (e.g. `"2026-03-19T12:00:00Z"`).
80
+ * Used for conflict resolution when multiple syncs write to the same database.
81
+ * Recommended, but optional.
82
+ */
83
+ upstreamUpdatedAt?: string;
63
84
  /**
64
85
  * Optional icon to use as the icon for this row's page.
65
86
  * Use the `Builder.emojiIcon()`, `Builder.notionIcon()`, or `Builder.imageIcon()` helpers.
@@ -83,9 +104,13 @@ export type SyncChangeDelete = {
83
104
  type: "delete";
84
105
  /**
85
106
  * The unique identifier of the record to delete.
86
- * Must match the `key` of a previously upserted record.
87
107
  */
88
108
  key: string;
109
+ /**
110
+ * The key of the database to delete from. If omitted, defaults to
111
+ * the database associated with the sync capability.
112
+ */
113
+ targetDatabaseKey?: string;
89
114
  };
90
115
 
91
116
  /**
@@ -123,8 +148,7 @@ export type SyncExecutionResult<PK extends string, State = unknown> = {
123
148
  };
124
149
 
125
150
  /**
126
- * A configuration object that enables synchronization between a data
127
- * source and a third-party source.
151
+ * Configuration for a sync capability that syncs data to a database.
128
152
  */
129
153
  export type SyncConfiguration<
130
154
  PK extends string,
@@ -132,16 +156,9 @@ export type SyncConfiguration<
132
156
  State = unknown,
133
157
  > = {
134
158
  /**
135
- * The property of the data source that maps to a "primary key" in the
136
- * third-party data. This is used to match existing pages to
137
- * records in the third-party service. Must be a property defined in the schema.
159
+ * The database to sync data into.
138
160
  */
139
- primaryKeyProperty: PK;
140
-
141
- /**
142
- * The schema defining the structure of properties in the collection.
143
- */
144
- schema: S;
161
+ database: DatabaseHandle<PK, S>;
145
162
 
146
163
  /**
147
164
  * How the sync handles data lifecycle:
@@ -158,6 +175,7 @@ export type SyncConfiguration<
158
175
  /**
159
176
  * How often the sync should run.
160
177
  * - "continuous": Run as frequently as the system allows
178
+ * - "manual": Only run when explicitly triggered
161
179
  * - Interval string: Run at specified intervals, e.g. "1h", "30m", "1d"
162
180
  *
163
181
  * Minimum interval: 1 minute ("1m")
@@ -191,17 +209,7 @@ export type SyncConfiguration<
191
209
  export type SyncCapability = ReturnType<typeof createSyncCapability>;
192
210
 
193
211
  /**
194
- * Runtime context object passed from the runtime to sync capability handlers.
195
- */
196
- type RuntimeContext<UserContext = unknown> = {
197
- /** The user-defined/-controlled state (cursor, pagination state, etc.) */
198
- state?: UserContext;
199
- /** Legacy field for user-defined/-controlled state. */
200
- userContext?: UserContext;
201
- };
202
-
203
- /**
204
- * Creates a special handler for syncing third-party data to a collection.
212
+ * Creates a special handler for syncing third-party data to a database.
205
213
  *
206
214
  * @param syncConfiguration - The configuration for the sync.
207
215
  * @returns A handler function that executes the sync function, and passes data
@@ -211,22 +219,31 @@ export function createSyncCapability<
211
219
  PK extends string,
212
220
  S extends Schema<PK>,
213
221
  Context = unknown,
214
- >(key: string, syncConfiguration: SyncConfiguration<PK, S, Context>) {
222
+ >(
223
+ key: string,
224
+ syncConfiguration: SyncConfiguration<PK, S, Context>,
225
+ pacerDeclarations?: readonly PacerDeclaration[],
226
+ ) {
215
227
  return {
216
228
  _tag: "sync" as const,
217
229
  key,
218
230
  config: {
219
- primaryKeyProperty: syncConfiguration.primaryKeyProperty,
220
- schema: syncConfiguration.schema,
231
+ databaseKey: syncConfiguration.database.key,
232
+ primaryKeyProperty: syncConfiguration.database.config.primaryKeyProperty,
221
233
  mode: syncConfiguration.mode,
222
- schedule: parseSchedule(syncConfiguration.schedule),
234
+ schedule: syncConfiguration.schedule
235
+ ? parseSchedule(syncConfiguration.schedule)
236
+ : undefined,
223
237
  },
224
238
  async handler(
225
- runtimeContext?: RuntimeContext<Context>,
239
+ runtimeContext?: StatefulRuntimeContext<Context>,
226
240
  options?: HandlerOptions,
227
241
  ) {
228
242
  const capabilityContext = createCapabilityContext();
229
243
  const state = runtimeContext?.state ?? runtimeContext?.userContext;
244
+
245
+ initPacerState(runtimeContext?.pacers, pacerDeclarations);
246
+
230
247
  let executionResult: SyncExecutionResult<PK, Context>;
231
248
  try {
232
249
  executionResult = await syncConfiguration.execute(
@@ -236,88 +253,34 @@ export function createSyncCapability<
236
253
  } catch (err) {
237
254
  const error = new ExecutionError(err);
238
255
  if (!options?.concreteOutput) {
239
- process.stdout.write(
240
- `\n<output>${JSON.stringify({ _tag: "error", error: { name: error.name, message: error.message } })}</output>\n`,
241
- );
256
+ writeOutput({
257
+ _tag: "error",
258
+ error: { name: error.name, message: error.message },
259
+ });
242
260
  }
243
261
  throw error;
244
262
  }
245
263
 
264
+ const changes = executionResult.changes.map((change) => ({
265
+ ...change,
266
+ targetDatabaseKey:
267
+ change.targetDatabaseKey ?? syncConfiguration.database.key,
268
+ }));
269
+
246
270
  const result = {
247
- changes: executionResult.changes,
271
+ changes,
248
272
  hasMore: executionResult.hasMore,
249
273
  nextUserContext: executionResult.nextState,
274
+ nextPacerStates: getPacerState().pacers,
250
275
  };
251
276
 
252
277
  if (options?.concreteOutput) {
253
278
  return result;
254
279
  } else {
255
- process.stdout.write(
256
- `\n<output>${JSON.stringify({ _tag: "success", value: result })}</output>\n`,
257
- );
280
+ writeOutput({ _tag: "success", value: result });
258
281
  }
259
282
 
260
283
  return result;
261
284
  },
262
285
  };
263
286
  }
264
-
265
- const MS_PER_MINUTE = 60 * 1000;
266
- const MS_PER_HOUR = 60 * MS_PER_MINUTE;
267
- const MS_PER_DAY = 24 * MS_PER_HOUR;
268
-
269
- const MIN_INTERVAL_MS = MS_PER_MINUTE; // 1m
270
- const MAX_INTERVAL_MS = 7 * MS_PER_DAY; // 7d
271
-
272
- const DEFAULT_INTERVAL_MS = 30 * MS_PER_MINUTE; // 30m
273
-
274
- /**
275
- * Parses a user-friendly schedule string into the normalized backend format.
276
- */
277
- function parseSchedule(schedule: Schedule | undefined): SyncSchedule {
278
- if (schedule === "continuous") {
279
- return { type: "continuous" };
280
- }
281
-
282
- if (!schedule) {
283
- return { type: "interval", intervalMs: DEFAULT_INTERVAL_MS };
284
- }
285
-
286
- const match = schedule.match(/^(\d+)(m|h|d)$/);
287
- if (!match || !match[1] || !match[2]) {
288
- throw new Error(
289
- `Invalid schedule format: "${schedule}". Use "continuous" or an interval like "30m", "1h", "1d".`,
290
- );
291
- }
292
-
293
- const value = parseInt(match[1], 10);
294
- const unit = match[2] as TimeUnit;
295
-
296
- let intervalMs: number;
297
- switch (unit) {
298
- case "m":
299
- intervalMs = value * MS_PER_MINUTE;
300
- break;
301
- case "h":
302
- intervalMs = value * MS_PER_HOUR;
303
- break;
304
- case "d":
305
- intervalMs = value * MS_PER_DAY;
306
- break;
307
- default:
308
- unreachable(unit);
309
- }
310
-
311
- if (intervalMs < MIN_INTERVAL_MS) {
312
- throw new Error(
313
- `Schedule interval must be at least 1 minute. Got: "${schedule}"`,
314
- );
315
- }
316
- if (intervalMs > MAX_INTERVAL_MS) {
317
- throw new Error(
318
- `Schedule interval must be at most 7 days. Got: "${schedule}"`,
319
- );
320
- }
321
-
322
- return { type: "interval", intervalMs };
323
- }
@@ -41,7 +41,7 @@ describe("Worker.tool", () => {
41
41
  expect(result).toEqual({ _tag: "success", value: "Hello, Alice!" });
42
42
 
43
43
  expect(stdoutSpy).toHaveBeenCalledWith(
44
- `\n<output>{"_tag":"success","value":"Hello, Alice!"}</output>\n`,
44
+ `\n<__notion_output__>{"_tag":"success","value":"Hello, Alice!"}</__notion_output__>\n`,
45
45
  );
46
46
  });
47
47
 
@@ -68,7 +68,7 @@ describe("Worker.tool", () => {
68
68
  });
69
69
 
70
70
  expect(stdoutSpy).toHaveBeenCalledWith(
71
- `\n<output>{"_tag":"success","value":{"data":"Data for ID 42"}}</output>\n`,
71
+ `\n<__notion_output__>{"_tag":"success","value":{"data":"Data for ID 42"}}</__notion_output__>\n`,
72
72
  );
73
73
  });
74
74
 
@@ -125,7 +125,7 @@ describe("Worker.tool", () => {
125
125
 
126
126
  expect(stdoutSpy).toHaveBeenCalledWith(
127
127
  expect.stringContaining(
128
- `\n<output>{"_tag":"error","error":{"name":"InvalidToolInputError"`,
128
+ `\n<__notion_output__>{"_tag":"error","error":{"name":"InvalidToolInputError"`,
129
129
  ),
130
130
  );
131
131
  });
@@ -161,6 +161,50 @@ describe("Worker.tool", () => {
161
161
  );
162
162
  });
163
163
 
164
+ it("accepts schema with format string types (date, datetime, etc.)", () => {
165
+ const capability = createToolCapability<
166
+ { due: string; createdAt: string },
167
+ string
168
+ >("dateFormats", {
169
+ title: "Date Formats",
170
+ description: "Uses date and datetime format strings",
171
+ schema: j.object({
172
+ due: j.date(),
173
+ createdAt: j.datetime(),
174
+ }),
175
+ execute: ({ due, createdAt }) => `${due} ${createdAt}`,
176
+ });
177
+
178
+ expect(capability.config.schema).toBeDefined();
179
+ });
180
+
181
+ it("rejects invalid date-time format at runtime", async () => {
182
+ const capability = createToolCapability<{ ts: string }, string>(
183
+ "datetimeValidation",
184
+ {
185
+ title: "Datetime Validation",
186
+ description: "Validates datetime input",
187
+ schema: j.object({
188
+ ts: j.datetime(),
189
+ }),
190
+ execute: ({ ts }) => ts,
191
+ },
192
+ );
193
+
194
+ const valid = await capability.handler({ ts: "2024-01-01T00:00:00Z" });
195
+ expect(valid).toEqual({
196
+ _tag: "success",
197
+ value: "2024-01-01T00:00:00Z",
198
+ });
199
+
200
+ const invalid = (await capability.handler({ ts: "not-a-date" })) as {
201
+ _tag: "error";
202
+ error: { name: string; message: string };
203
+ };
204
+ expect(invalid._tag).toBe("error");
205
+ expect(invalid.error.name).toBe("InvalidToolInputError");
206
+ });
207
+
164
208
  it("accepts schema with nullable (string | null) properties", () => {
165
209
  const capability = createToolCapability<
166
210
  { name: string; nickname: string | null },
@@ -1,10 +1,12 @@
1
1
  import { Ajv } from "ajv";
2
+ import addFormats from "ajv-formats";
2
3
  import type { AnyJSONSchema } from "../json-schema.js";
3
4
  import type { SchemaBuilder } from "../schema-builder.js";
4
5
  import { getSchema } from "../schema-builder.js";
5
6
  import type { HandlerOptions, JSONValue } from "../types.js";
6
7
  import type { CapabilityContext } from "./context.js";
7
8
  import { createCapabilityContext } from "./context.js";
9
+ import { writeOutput } from "./output.js";
8
10
 
9
11
  /**
10
12
  * Validates that every object schema in the tree has all its `properties`
@@ -160,6 +162,7 @@ export function createToolCapability<
160
162
  }
161
163
 
162
164
  const ajv = new Ajv();
165
+ addFormats(ajv);
163
166
  const validateInput = ajv.compile(inputSchema);
164
167
  const validateOutput = outputSchema ? ajv.compile(outputSchema) : null;
165
168
 
@@ -209,7 +212,7 @@ export function createToolCapability<
209
212
  error: { name: error.name, message: error.message, trace: error.stack },
210
213
  };
211
214
 
212
- process.stdout.write(`\n<output>${JSON.stringify(result)}</output>\n`);
215
+ writeOutput(result);
213
216
 
214
217
  return result;
215
218
  }
@@ -235,7 +238,7 @@ export function createToolCapability<
235
238
  },
236
239
  };
237
240
 
238
- process.stdout.write(`\n<output>${JSON.stringify(result)}</output>\n`);
241
+ writeOutput(result);
239
242
 
240
243
  return result;
241
244
  }
@@ -244,9 +247,7 @@ export function createToolCapability<
244
247
  return result;
245
248
  }
246
249
 
247
- process.stdout.write(
248
- `\n<output>${JSON.stringify({ _tag: "success", value: result })}</output>\n`,
249
- );
250
+ writeOutput({ _tag: "success", value: result });
250
251
 
251
252
  return {
252
253
  _tag: "success" as const,
@@ -266,7 +267,7 @@ export function createToolCapability<
266
267
  error: { name: error.name, message: error.message, trace: error.stack },
267
268
  };
268
269
 
269
- process.stdout.write(`\n<output>${JSON.stringify(result)}</output>\n`);
270
+ writeOutput(result);
270
271
 
271
272
  return result;
272
273
  }