@notionhq/workers 0.5.0 → 0.7.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 (182) hide show
  1. package/dist/alpha/ajv-formats.d.js +0 -0
  2. package/dist/alpha/builder.d.ts +149 -0
  3. package/dist/alpha/builder.d.ts.map +1 -0
  4. package/dist/alpha/builder.js +260 -0
  5. package/dist/alpha/builder.test.d.ts +2 -0
  6. package/dist/alpha/builder.test.d.ts.map +1 -0
  7. package/dist/alpha/cli/build.d.ts +13 -0
  8. package/dist/alpha/cli/build.d.ts.map +1 -0
  9. package/dist/alpha/cli/build.js +36 -0
  10. package/dist/alpha/cli/build.test.d.ts +2 -0
  11. package/dist/alpha/cli/build.test.d.ts.map +1 -0
  12. package/dist/alpha/cli/codegen.d.ts +10 -0
  13. package/dist/alpha/cli/codegen.d.ts.map +1 -0
  14. package/dist/alpha/cli/codegen.js +41 -0
  15. package/dist/alpha/cli/codegen.test.d.ts +2 -0
  16. package/dist/alpha/cli/codegen.test.d.ts.map +1 -0
  17. package/dist/alpha/cli/discover.d.ts +35 -0
  18. package/dist/alpha/cli/discover.d.ts.map +1 -0
  19. package/dist/alpha/cli/discover.js +39 -0
  20. package/dist/alpha/cli/discover.test.d.ts +2 -0
  21. package/dist/alpha/cli/discover.test.d.ts.map +1 -0
  22. package/dist/alpha/cli/emit-manifest.d.ts +33 -0
  23. package/dist/alpha/cli/emit-manifest.d.ts.map +1 -0
  24. package/dist/alpha/cli/emit-manifest.js +266 -0
  25. package/dist/alpha/cli/emit-manifest.test.d.ts +2 -0
  26. package/dist/alpha/cli/emit-manifest.test.d.ts.map +1 -0
  27. package/dist/alpha/cli/index.d.ts +3 -0
  28. package/dist/alpha/cli/index.d.ts.map +1 -0
  29. package/dist/alpha/cli/index.js +36 -0
  30. package/dist/alpha/context.d.ts +8 -0
  31. package/dist/alpha/context.d.ts.map +1 -0
  32. package/dist/alpha/context.js +33 -0
  33. package/dist/alpha/database.d.ts +90 -0
  34. package/dist/alpha/database.d.ts.map +1 -0
  35. package/dist/alpha/database.js +9 -0
  36. package/dist/alpha/error.d.ts +41 -0
  37. package/dist/alpha/error.d.ts.map +1 -0
  38. package/dist/alpha/error.js +25 -0
  39. package/dist/alpha/icon-names.d.ts +6 -0
  40. package/dist/alpha/icon-names.d.ts.map +1 -0
  41. package/dist/alpha/icon-names.js +0 -0
  42. package/dist/alpha/json-schema.d.ts +117 -0
  43. package/dist/alpha/json-schema.d.ts.map +1 -0
  44. package/dist/alpha/json-schema.js +0 -0
  45. package/dist/alpha/manifest.d.ts +44 -0
  46. package/dist/alpha/manifest.d.ts.map +1 -0
  47. package/dist/alpha/manifest.js +0 -0
  48. package/dist/alpha/output.d.ts +7 -0
  49. package/dist/alpha/output.d.ts.map +1 -0
  50. package/dist/alpha/output.js +8 -0
  51. package/dist/alpha/pacer.d.ts +74 -0
  52. package/dist/alpha/pacer.d.ts.map +1 -0
  53. package/dist/alpha/pacer.js +66 -0
  54. package/dist/alpha/pacer.test.d.ts +2 -0
  55. package/dist/alpha/pacer.test.d.ts.map +1 -0
  56. package/dist/alpha/schedule.d.ts +6 -0
  57. package/dist/alpha/schedule.d.ts.map +1 -0
  58. package/dist/alpha/schedule.js +43 -0
  59. package/dist/alpha/schedule.test.d.ts +2 -0
  60. package/dist/alpha/schedule.test.d.ts.map +1 -0
  61. package/dist/alpha/schema-builder.d.ts +79 -0
  62. package/dist/alpha/schema-builder.d.ts.map +1 -0
  63. package/dist/alpha/schema-builder.js +135 -0
  64. package/dist/alpha/schema.d.ts +185 -0
  65. package/dist/alpha/schema.d.ts.map +1 -0
  66. package/dist/alpha/schema.js +115 -0
  67. package/dist/alpha/sync.d.ts +233 -0
  68. package/dist/alpha/sync.d.ts.map +1 -0
  69. package/dist/alpha/sync.js +70 -0
  70. package/dist/alpha/sync.test.d.ts +2 -0
  71. package/dist/alpha/sync.test.d.ts.map +1 -0
  72. package/dist/alpha/tool.d.ts +132 -0
  73. package/dist/alpha/tool.d.ts.map +1 -0
  74. package/dist/alpha/tool.js +156 -0
  75. package/dist/alpha/tool.test.d.ts +2 -0
  76. package/dist/alpha/tool.test.d.ts.map +1 -0
  77. package/dist/alpha/triggers.d.ts +365 -0
  78. package/dist/alpha/triggers.d.ts.map +1 -0
  79. package/dist/alpha/triggers.generated.d.ts +246 -0
  80. package/dist/alpha/triggers.generated.d.ts.map +1 -0
  81. package/dist/alpha/triggers.generated.js +239 -0
  82. package/dist/alpha/triggers.js +0 -0
  83. package/dist/alpha/types.d.ts +228 -0
  84. package/dist/alpha/types.d.ts.map +1 -0
  85. package/dist/alpha/types.js +0 -0
  86. package/dist/alpha/workflow.d.ts +70 -0
  87. package/dist/alpha/workflow.d.ts.map +1 -0
  88. package/dist/alpha/workflow.js +39 -0
  89. package/dist/alpha/workflow.test.d.ts +2 -0
  90. package/dist/alpha/workflow.test.d.ts.map +1 -0
  91. package/dist/block.d.ts.map +1 -1
  92. package/dist/builder.d.ts.map +1 -1
  93. package/dist/builder.js +2 -6
  94. package/dist/capabilities/ai_connector.d.ts.map +1 -1
  95. package/dist/capabilities/automation.d.ts.map +1 -1
  96. package/dist/capabilities/context.d.ts.map +1 -1
  97. package/dist/capabilities/oauth.d.ts.map +1 -1
  98. package/dist/capabilities/output.d.ts.map +1 -1
  99. package/dist/capabilities/output.js +2 -4
  100. package/dist/capabilities/stateful-capability.d.ts.map +1 -1
  101. package/dist/capabilities/stateful-capability.js +2 -6
  102. package/dist/capabilities/sync.d.ts.map +1 -1
  103. package/dist/capabilities/sync.js +2 -8
  104. package/dist/capabilities/tool.d.ts.map +1 -1
  105. package/dist/capabilities/tool.js +6 -20
  106. package/dist/capabilities/webhook.d.ts.map +1 -1
  107. package/dist/capabilities/workflow.d.ts +2 -2
  108. package/dist/capabilities/workflow.d.ts.map +1 -1
  109. package/dist/index.d.ts +2 -2
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +1 -7
  112. package/dist/json-schema.d.ts.map +1 -1
  113. package/dist/schema-builder.d.ts.map +1 -1
  114. package/dist/schema.d.ts.map +1 -1
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/worker.d.ts.map +1 -1
  117. package/dist/worker.js +1 -4
  118. package/package.json +74 -14
  119. package/src/alpha/ajv-formats.d.ts +7 -0
  120. package/src/alpha/builder.test.ts +197 -0
  121. package/src/alpha/builder.ts +338 -0
  122. package/src/alpha/cli/build.test.ts +240 -0
  123. package/src/alpha/cli/build.ts +57 -0
  124. package/src/alpha/cli/codegen.test.ts +31 -0
  125. package/src/alpha/cli/codegen.ts +51 -0
  126. package/src/alpha/cli/discover.test.ts +43 -0
  127. package/src/alpha/cli/discover.ts +81 -0
  128. package/src/alpha/cli/emit-manifest.test.ts +45 -0
  129. package/src/alpha/cli/emit-manifest.ts +409 -0
  130. package/src/alpha/cli/index.ts +41 -0
  131. package/src/alpha/context.ts +61 -0
  132. package/src/alpha/database.ts +102 -0
  133. package/src/alpha/error.ts +49 -0
  134. package/src/alpha/icon-names.ts +890 -0
  135. package/src/alpha/json-schema.ts +180 -0
  136. package/src/alpha/manifest.ts +47 -0
  137. package/src/alpha/output.ts +8 -0
  138. package/src/alpha/pacer.test.ts +81 -0
  139. package/src/alpha/pacer.ts +181 -0
  140. package/src/alpha/schedule.test.ts +31 -0
  141. package/src/alpha/schedule.ts +53 -0
  142. package/src/alpha/schema-builder.ts +193 -0
  143. package/src/alpha/schema.ts +222 -0
  144. package/src/alpha/sync.test.ts +191 -0
  145. package/src/alpha/sync.ts +340 -0
  146. package/src/alpha/tool.test.ts +116 -0
  147. package/src/alpha/tool.ts +319 -0
  148. package/src/alpha/triggers.generated.ts +489 -0
  149. package/src/alpha/triggers.ts +500 -0
  150. package/src/alpha/types.ts +284 -0
  151. package/src/alpha/workflow.test.ts +84 -0
  152. package/src/alpha/workflow.ts +131 -0
  153. package/src/block.ts +1 -5
  154. package/src/builder.test.ts +28 -60
  155. package/src/builder.ts +3 -10
  156. package/src/capabilities/ai_connector.test.ts +9 -32
  157. package/src/capabilities/ai_connector.ts +5 -17
  158. package/src/capabilities/automation.test.ts +3 -12
  159. package/src/capabilities/automation.ts +3 -11
  160. package/src/capabilities/context.ts +3 -6
  161. package/src/capabilities/oauth.test.ts +1 -0
  162. package/src/capabilities/oauth.ts +1 -3
  163. package/src/capabilities/output.ts +1 -3
  164. package/src/capabilities/stateful-capability.test.ts +1 -0
  165. package/src/capabilities/stateful-capability.ts +2 -6
  166. package/src/capabilities/sync.test.ts +15 -45
  167. package/src/capabilities/sync.ts +10 -40
  168. package/src/capabilities/tool.test.ts +106 -146
  169. package/src/capabilities/tool.ts +17 -39
  170. package/src/capabilities/webhook.ts +3 -10
  171. package/src/capabilities/workflow.test.ts +10 -31
  172. package/src/capabilities/workflow.ts +5 -12
  173. package/src/index.ts +2 -12
  174. package/src/json-schema.test.ts +2 -4
  175. package/src/json-schema.ts +7 -6
  176. package/src/pacer.test.ts +1 -3
  177. package/src/pacer_internal.ts +1 -5
  178. package/src/schema-builder.test.ts +10 -35
  179. package/src/schema-builder.ts +5 -16
  180. package/src/schema.ts +2 -10
  181. package/src/types.ts +1 -7
  182. package/src/worker.ts +8 -34
@@ -0,0 +1,338 @@
1
+ import type { NoticonName } from "./icon-names.js";
2
+ import type {
3
+ Cover,
4
+ DateValue,
5
+ Icon,
6
+ NoticonColor,
7
+ PeopleValue,
8
+ PlaceValue,
9
+ RelationReference,
10
+ TextValue,
11
+ } from "./types.js";
12
+
13
+ /**
14
+ * Validates a date string is in YYYY-MM-DD format and represents a valid date.
15
+ */
16
+ function validateDateString(dateString: string): void {
17
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
18
+ throw new Error(`Invalid date format: ${dateString}. Expected YYYY-MM-DD format.`);
19
+ }
20
+
21
+ const date = new Date(dateString);
22
+ if (Number.isNaN(date.getTime())) {
23
+ throw new Error(`Invalid date: ${dateString}`);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Internal helper to create a date mention token from a DateValue object.
29
+ */
30
+ function createDateToken(dateValue: DateValue): TextValue {
31
+ return [["‣", [["d", dateValue]]]];
32
+ }
33
+
34
+ /**
35
+ * Parses an ISO 8601 datetime string into date and time components.
36
+ * Accepts formats like "2024-01-15T10:30", "2024-01-15T10:30:00Z",
37
+ * "2024-01-15T10:30:00.000+05:00", etc.
38
+ */
39
+ function parseISODateTime(isoString: string): { date: string; time: string } {
40
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(isoString)) {
41
+ throw new Error(
42
+ `Invalid ISO 8601 datetime: ${isoString}. Expected format like 2024-01-15T10:30 or 2024-01-15T10:30:00Z`,
43
+ );
44
+ }
45
+
46
+ const date = isoString.slice(0, 10);
47
+ const time = isoString.slice(11, 16);
48
+
49
+ if (Number.isNaN(new Date(isoString).getTime())) {
50
+ throw new Error(`Invalid datetime: ${isoString}`);
51
+ }
52
+
53
+ return { date, time };
54
+ }
55
+
56
+ /**
57
+ * Builders for property values in sync changes.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import { Builder } from "@notionhq/workers/alpha/builder";
62
+ *
63
+ * const change = {
64
+ * type: "upsert",
65
+ * key: "task-1",
66
+ * properties: {
67
+ * "Task Name": Builder.title("Write docs"),
68
+ * Status: Builder.select("Open"),
69
+ * Due: Builder.date("2026-08-01"),
70
+ * Assignees: Builder.people("alice@example.com"),
71
+ * },
72
+ * };
73
+ * ```
74
+ */
75
+ export const Builder = {
76
+ /**
77
+ * Creates a rich text value.
78
+ */
79
+ richText(content: string): TextValue {
80
+ return [[content]];
81
+ },
82
+
83
+ /**
84
+ * Creates a URL value.
85
+ */
86
+ url(url: string): TextValue {
87
+ return [[url]];
88
+ },
89
+
90
+ /**
91
+ * Creates a title value.
92
+ */
93
+ title(content: string): TextValue {
94
+ return [[content]];
95
+ },
96
+
97
+ /**
98
+ * Creates a text value.
99
+ */
100
+ text(content: string): TextValue {
101
+ return [[content]];
102
+ },
103
+
104
+ /**
105
+ * Creates an email value.
106
+ */
107
+ email(email: string): TextValue {
108
+ return [[email]];
109
+ },
110
+
111
+ /**
112
+ * Creates a phone number value.
113
+ */
114
+ phoneNumber(phone: string): TextValue {
115
+ return [[phone]];
116
+ },
117
+
118
+ /**
119
+ * Creates a checkbox value.
120
+ */
121
+ checkbox(checked: boolean): TextValue {
122
+ return checked ? [["Yes"]] : [["No"]];
123
+ },
124
+
125
+ /**
126
+ * Creates a file URL value.
127
+ * @param fileUrl - The URL of the file
128
+ * @param fileName - Optional display name for the file (defaults to URL)
129
+ */
130
+ file(fileUrl: string, fileName?: string): TextValue {
131
+ return [[fileName ?? fileUrl, [["a", fileUrl]]]];
132
+ },
133
+
134
+ /**
135
+ * Creates a number value.
136
+ */
137
+ number(value: number): TextValue {
138
+ if (Number.isNaN(value)) {
139
+ return [];
140
+ }
141
+ return [[value.toString()]];
142
+ },
143
+
144
+ /**
145
+ * Creates a date value from a date string (YYYY-MM-DD).
146
+ */
147
+ date(dateString: string): TextValue {
148
+ validateDateString(dateString);
149
+
150
+ return createDateToken({
151
+ type: "date",
152
+ start_date: dateString,
153
+ });
154
+ },
155
+
156
+ /**
157
+ * Creates a datetime value from an ISO 8601 datetime string.
158
+ * @param isoString - An ISO 8601 datetime string (e.g., "2024-01-15T10:30", "2024-01-15T10:30:00Z")
159
+ * @param timeZone - Optional IANA timezone name (e.g., "America/New_York")
160
+ */
161
+ dateTime(isoString: string, timeZone?: string): TextValue {
162
+ const { date, time } = parseISODateTime(isoString);
163
+
164
+ const dateValue: DateValue = {
165
+ type: "datetime",
166
+ start_date: date,
167
+ start_time: time,
168
+ };
169
+
170
+ if (timeZone) {
171
+ dateValue.time_zone = timeZone;
172
+ }
173
+
174
+ return createDateToken(dateValue);
175
+ },
176
+
177
+ /**
178
+ * Creates a date range value from date strings.
179
+ */
180
+ dateRange(startDate: string, endDate: string): TextValue {
181
+ validateDateString(startDate);
182
+ validateDateString(endDate);
183
+
184
+ return createDateToken({
185
+ type: "daterange",
186
+ start_date: startDate,
187
+ end_date: endDate,
188
+ });
189
+ },
190
+
191
+ /**
192
+ * Creates a datetime range value from ISO 8601 datetime strings.
193
+ * @param startDateTime - An ISO 8601 datetime string for the range start
194
+ * @param endDateTime - An ISO 8601 datetime string for the range end
195
+ * @param timeZone - Optional IANA timezone name (e.g., "America/New_York")
196
+ */
197
+ dateTimeRange(startDateTime: string, endDateTime: string, timeZone?: string): TextValue {
198
+ const start = parseISODateTime(startDateTime);
199
+ const end = parseISODateTime(endDateTime);
200
+
201
+ const dateValue: DateValue = {
202
+ type: "datetimerange",
203
+ start_date: start.date,
204
+ start_time: start.time,
205
+ end_date: end.date,
206
+ end_time: end.time,
207
+ };
208
+
209
+ if (timeZone) {
210
+ dateValue.time_zone = timeZone;
211
+ }
212
+
213
+ return createDateToken(dateValue);
214
+ },
215
+
216
+ /**
217
+ * Creates a link with custom display text.
218
+ * @param displayText - The text to display
219
+ * @param url - The URL to link to
220
+ */
221
+ link(displayText: string, url: string): TextValue {
222
+ return [[displayText, [["a", url]]]];
223
+ },
224
+
225
+ /**
226
+ * Creates a select value from a single option.
227
+ */
228
+ select(value: string): TextValue {
229
+ return [[value]];
230
+ },
231
+
232
+ /**
233
+ * Creates a multi-select value from multiple options.
234
+ * @param values - Array of option names to select
235
+ */
236
+ multiSelect(...values: string[]): TextValue {
237
+ if (values.length === 0) {
238
+ return [];
239
+ }
240
+ return [[values.join(",")]];
241
+ },
242
+
243
+ /**
244
+ * Creates a status value from a status option name.
245
+ */
246
+ status(value: string): TextValue {
247
+ return [[value]];
248
+ },
249
+
250
+ /**
251
+ * Creates a people value from email addresses.
252
+ * @param emails - Array of email addresses for people to include
253
+ */
254
+ people(...emails: string[]): PeopleValue {
255
+ return emails.map((email) => ({ email }));
256
+ },
257
+
258
+ /**
259
+ * Creates a place value for a geographic location.
260
+ * @param value - The place value with lat/lon coordinates and optional name/address
261
+ */
262
+ place(value: PlaceValue): PlaceValue {
263
+ if (typeof value.lat !== "number" || typeof value.lon !== "number") {
264
+ throw new Error("Place value must have numeric lat and lon coordinates");
265
+ }
266
+ return value;
267
+ },
268
+
269
+ /**
270
+ * Creates a relation reference from a primary key of a related record.
271
+ * Use an array of relation references as the property value.
272
+ *
273
+ * @param primaryKey - The primary key of the related record
274
+ * @example
275
+ * ```typescript
276
+ * // Single relation
277
+ * { Project: [Builder.relation("project-123")] }
278
+ *
279
+ * // Multiple relations
280
+ * { Projects: [Builder.relation("project-123"), Builder.relation("project-456")] }
281
+ * ```
282
+ */
283
+ relation(primaryKey: string): RelationReference {
284
+ return { type: "primaryKey", value: primaryKey };
285
+ },
286
+
287
+ /**
288
+ * Creates an emoji icon.
289
+ * @param emoji - An emoji string (e.g., "🎯", "✨", "🚀")
290
+ */
291
+ emojiIcon(emoji: string): Icon {
292
+ return {
293
+ type: "emoji",
294
+ value: emoji,
295
+ };
296
+ },
297
+
298
+ /**
299
+ * Creates a Notion icon with a specific color.
300
+ * @param icon - The name of the Notion icon (e.g., "checkmark", "pizza", "rocket")
301
+ * @param color - The color variant (defaults to "gray")
302
+ */
303
+ notionIcon(icon: NoticonName, color: NoticonColor = "gray"): Icon {
304
+ return {
305
+ type: "notion",
306
+ icon,
307
+ color,
308
+ };
309
+ },
310
+
311
+ /**
312
+ * Creates an image icon from an external URL.
313
+ * @param url - The URL of the image (e.g., "https://example.com/icon.png")
314
+ */
315
+ imageIcon(url: string): Icon {
316
+ return {
317
+ type: "image",
318
+ url,
319
+ };
320
+ },
321
+
322
+ /**
323
+ * Creates an image cover from an external URL.
324
+ * @param url - The URL of the image (e.g., "https://example.com/cover.png")
325
+ * @param position - Vertical image position, from 0 (top) to 1 (bottom). Defaults to 0.5.
326
+ */
327
+ imageCover(url: string, position = 0.5): Cover {
328
+ if (!Number.isFinite(position) || position < 0 || position > 1) {
329
+ throw new Error(`Invalid cover position: ${position}. Expected a number between 0 and 1.`);
330
+ }
331
+
332
+ return {
333
+ type: "image",
334
+ url,
335
+ position,
336
+ };
337
+ },
338
+ };
@@ -0,0 +1,240 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { buildWorker } from "./build.js";
8
+
9
+ const FIXTURES = fileURLToPath(new URL("../../../test/fixtures/", import.meta.url));
10
+ const EXAMPLES = fileURLToPath(new URL("../../../examples/", import.meta.url));
11
+
12
+ const builtDirectories: string[] = [];
13
+
14
+ function fixture(name: string): string {
15
+ const root = path.join(FIXTURES, name);
16
+ builtDirectories.push(root);
17
+ return root;
18
+ }
19
+
20
+ function example(name: string): string {
21
+ const root = path.join(EXAMPLES, name);
22
+ builtDirectories.push(root);
23
+ return root;
24
+ }
25
+
26
+ afterEach(async () => {
27
+ for (const root of builtDirectories.splice(0)) {
28
+ for (const artifact of [".notion", "dist"]) {
29
+ await fs.promises.rm(path.join(root, artifact), { recursive: true, force: true });
30
+ }
31
+ }
32
+ });
33
+
34
+ describe("buildWorker", () => {
35
+ it("builds a bundle and manifest from capability files", async () => {
36
+ const { manifest, bundlePath, manifestPath } = await buildWorker(fixture("basic"));
37
+
38
+ expect(manifest.capabilities).toEqual([
39
+ {
40
+ type: "sync",
41
+ key: "static",
42
+ config: {
43
+ databaseKey: "tasks",
44
+ mode: "replace",
45
+ schedule: { type: "interval", intervalMs: 24 * 60 * 60 * 1000 },
46
+ },
47
+ },
48
+ ]);
49
+
50
+ // The sync's inline database is keyed by its own name.
51
+ expect(manifest.databases).toEqual([
52
+ {
53
+ key: "tasks",
54
+ config: {
55
+ type: "managed",
56
+ primaryKey: "ID",
57
+ schema: { properties: { ID: { type: "text" } } },
58
+ },
59
+ },
60
+ ]);
61
+
62
+ const written: unknown = JSON.parse(await fs.promises.readFile(manifestPath, "utf8"));
63
+ expect(written).toEqual(manifest);
64
+
65
+ // The bundle is runnable: run() dispatches to the sync's handler, which
66
+ // produces the v1-shaped run result.
67
+ const bundle = (await import(pathToFileURL(bundlePath).href)) as {
68
+ run: (
69
+ type: string,
70
+ key: string,
71
+ context?: unknown,
72
+ options?: { concreteOutput?: true },
73
+ ) => Promise<unknown>;
74
+ };
75
+ await expect(
76
+ bundle.run("sync", "static", undefined, { concreteOutput: true }),
77
+ ).resolves.toEqual({
78
+ changes: [
79
+ { type: "upsert", key: "1", properties: { ID: [["1"]] }, targetDatabaseKey: "tasks" },
80
+ ],
81
+ hasMore: false,
82
+ nextUserContext: undefined,
83
+ nextPacerStates: {},
84
+ });
85
+ await expect(bundle.run("sync", "missing")).rejects.toThrow(
86
+ 'Capability "sync/missing" not found',
87
+ );
88
+ });
89
+
90
+ it("collects databases by reachability and resolves relations to names", async () => {
91
+ const { manifest } = await buildWorker(fixture("relations"));
92
+
93
+ // `projects` is reachable only through `tasks`' relation property, and
94
+ // both live in a plain lib/ module — no dedicated database directory.
95
+ expect(manifest.databases).toEqual([
96
+ {
97
+ key: "tasks",
98
+ config: {
99
+ type: "managed",
100
+ initialTitle: "Tasks",
101
+ primaryKey: "ID",
102
+ schema: {
103
+ properties: {
104
+ ID: { type: "text" },
105
+ Project: {
106
+ type: "relation",
107
+ relatedDatabaseKey: "projects",
108
+ config: { twoWay: false },
109
+ },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ {
115
+ key: "projects",
116
+ config: {
117
+ type: "managed",
118
+ primaryKey: "ID",
119
+ schema: { properties: { ID: { type: "text" } } },
120
+ },
121
+ },
122
+ ]);
123
+
124
+ expect(manifest.capabilities).toEqual([
125
+ {
126
+ type: "sync",
127
+ key: "tasks",
128
+ config: { databaseKey: "tasks", mode: undefined, schedule: undefined },
129
+ },
130
+ ]);
131
+ });
132
+
133
+ it("deduplicates a database and pacer shared by two syncs", async () => {
134
+ const { manifest } = await buildWorker(example("shared-resources"));
135
+
136
+ // Both syncs import the same instances, so each shared resource lands
137
+ // in the manifest exactly once — not once per sync. `projects` is
138
+ // reachable only through the issues database's relation property.
139
+ expect(manifest.databases.map((database) => database.key)).toEqual(["issues", "projects"]);
140
+
141
+ const issuesConfig = manifest.databases[0]?.config as {
142
+ schema: { properties: Record<string, unknown> };
143
+ };
144
+ expect(issuesConfig.schema.properties.Project).toEqual({
145
+ type: "relation",
146
+ relatedDatabaseKey: "projects",
147
+ config: { twoWay: false },
148
+ });
149
+
150
+ expect(manifest.pacers).toHaveLength(1);
151
+ expect(manifest.pacers[0]).toEqual({
152
+ key: "jira",
153
+ config: { allowedRequests: 10, intervalMs: 1000 },
154
+ });
155
+
156
+ const syncConfigs = manifest.capabilities
157
+ .filter((capability) => capability.type === "sync")
158
+ .map((capability) => ({
159
+ key: capability.key,
160
+ databaseKey: (capability.config as { databaseKey: string }).databaseKey,
161
+ }));
162
+ expect(syncConfigs).toEqual([
163
+ { key: "closedIssues", databaseKey: "issues" },
164
+ { key: "openIssues", databaseKey: "issues" },
165
+ ]);
166
+ });
167
+
168
+ it("re-collects pacers when rebuilding an unchanged project in one process", async () => {
169
+ // Pacer collection relies on the bundle's top-level side effects
170
+ // re-running on every import; a module-cache hit (identical bundle,
171
+ // same URL) would silently drop all pacers on the second build.
172
+ await buildWorker(example("shared-resources"));
173
+ const { manifest } = await buildWorker(example("shared-resources"));
174
+
175
+ expect(manifest.pacers).toHaveLength(1);
176
+ });
177
+
178
+ it("discovers tools with resolved schemas in the manifest", async () => {
179
+ const { manifest } = await buildWorker(example("shared-resources"));
180
+
181
+ const tools = manifest.capabilities.filter((capability) => capability.type === "tool");
182
+ expect(tools.map((tool) => tool.key)).toEqual(["createIssue", "listIssues"]);
183
+
184
+ expect(tools[0]?.config).toMatchObject({
185
+ title: "Create Issue",
186
+ schema: {
187
+ type: "object",
188
+ required: ["title", "project"],
189
+ additionalProperties: false,
190
+ },
191
+ outputSchema: {
192
+ type: "object",
193
+ required: ["key"],
194
+ },
195
+ });
196
+ expect(tools[1]?.config).toMatchObject({
197
+ title: "List Issues",
198
+ hints: { readOnlyHint: true },
199
+ });
200
+ });
201
+
202
+ it("discovers workflows with their triggers in the manifest", async () => {
203
+ const { manifest } = await buildWorker(example("sync"));
204
+
205
+ const workflows = manifest.capabilities.filter((capability) => capability.type === "workflow");
206
+ expect(workflows).toEqual([
207
+ {
208
+ type: "workflow",
209
+ key: "onPageCreated",
210
+ config: {
211
+ title: "Log New Pages",
212
+ description: "Logs every page created in the workspace",
213
+ triggers: [{ type: "notion.page.created" }],
214
+ },
215
+ },
216
+ ]);
217
+ });
218
+
219
+ it("fails when two distinct databases share a name", async () => {
220
+ await expect(buildWorker(fixture("duplicate-name"))).rejects.toThrow(
221
+ /Two distinct databases are named "shared"/,
222
+ );
223
+ });
224
+
225
+ it("fails when a capability file's default export has the wrong tag", async () => {
226
+ await expect(buildWorker(fixture("wrong-tag"))).rejects.toThrow(
227
+ /tool\.ts.*default export is not a sync/,
228
+ );
229
+ });
230
+
231
+ it("fails when a capability file has no default export", async () => {
232
+ await expect(buildWorker(fixture("no-default-export"))).rejects.toThrow(
233
+ /"default" is not exported by .*nothing\.ts/,
234
+ );
235
+ });
236
+
237
+ it("fails when the project has no capabilities", async () => {
238
+ await expect(buildWorker(fixture("empty"))).rejects.toThrow(/No capabilities found/);
239
+ });
240
+ });
@@ -0,0 +1,57 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import { rolldown } from "rolldown";
5
+
6
+ import type { WorkerManifest } from "../manifest.js";
7
+ import { generateEntry } from "./codegen.js";
8
+ import { discoverCapabilities } from "./discover.js";
9
+ import { extractManifest } from "./emit-manifest.js";
10
+
11
+ export type BuildResult = {
12
+ manifest: WorkerManifest;
13
+ bundlePath: string;
14
+ manifestPath: string;
15
+ };
16
+
17
+ /**
18
+ * Build a worker project: discover capability files by convention, generate
19
+ * the entry module, bundle it into a single `dist/worker.js`, and emit
20
+ * `dist/manifest.json` describing every capability.
21
+ */
22
+ export async function buildWorker(projectRoot: string): Promise<BuildResult> {
23
+ const capabilities = await discoverCapabilities(projectRoot);
24
+
25
+ if (capabilities.length === 0) {
26
+ throw new Error(
27
+ `No capabilities found in ${projectRoot}. ` +
28
+ `Add capability files by convention, e.g. src/syncs/<name>.ts default-exporting createSync(...).`,
29
+ );
30
+ }
31
+
32
+ const entryPath = path.join(projectRoot, ".notion", "entry.ts");
33
+ await fs.promises.mkdir(path.dirname(entryPath), { recursive: true });
34
+ await fs.promises.writeFile(entryPath, generateEntry(capabilities));
35
+
36
+ const bundlePath = path.join(projectRoot, "dist", "worker.js");
37
+ // Bundle only the worker's own code. Bare imports (npm packages) stay
38
+ // external and resolve from node_modules at runtime, so packages behave
39
+ // normally — including native addons, which cannot be inlined.
40
+ const bundle = await rolldown({
41
+ input: entryPath,
42
+ platform: "node",
43
+ external: (id) => !id.startsWith(".") && !path.isAbsolute(id),
44
+ });
45
+ try {
46
+ await bundle.write({ format: "esm", file: bundlePath });
47
+ } finally {
48
+ await bundle.close();
49
+ }
50
+
51
+ const manifest = await extractManifest(bundlePath, capabilities);
52
+
53
+ const manifestPath = path.join(projectRoot, "dist", "manifest.json");
54
+ await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, "\t")}\n`);
55
+
56
+ return { manifest, bundlePath, manifestPath };
57
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { generateEntry } from "./codegen.js";
4
+
5
+ describe("generateEntry", () => {
6
+ it("statically imports each capability and registers it by type and key", () => {
7
+ const entry = generateEntry([
8
+ { type: "sync", tag: "sync", key: "static", sourcePath: "src/syncs/static.ts" },
9
+ { type: "sync", tag: "sync", key: "my-sync", sourcePath: "src/syncs/my-sync.ts" },
10
+ ]);
11
+
12
+ expect(entry).toContain(`import capability_0 from "../src/syncs/static.ts";`);
13
+ expect(entry).toContain(`import capability_1 from "../src/syncs/my-sync.ts";`);
14
+ expect(entry).toContain(`"static": capability_0,`);
15
+ expect(entry).toContain(`"my-sync": capability_1,`);
16
+ expect(entry).toContain("export async function run(");
17
+ });
18
+
19
+ it("quotes capability type keys so non-identifier types are valid JavaScript", () => {
20
+ const entry = generateEntry([
21
+ {
22
+ type: "ai-connector",
23
+ tag: "ai-connector",
24
+ key: "my-connector",
25
+ sourcePath: "src/ai-connectors/my-connector.ts",
26
+ },
27
+ ]);
28
+
29
+ expect(entry).toContain(`"ai-connector": {`);
30
+ });
31
+ });