@notionhq/workers 0.4.0 → 0.6.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 (204) hide show
  1. package/dist/alpha/builder.d.ts +149 -0
  2. package/dist/alpha/builder.d.ts.map +1 -0
  3. package/dist/alpha/builder.js +260 -0
  4. package/dist/alpha/builder.test.d.ts +2 -0
  5. package/dist/alpha/builder.test.d.ts.map +1 -0
  6. package/dist/alpha/cli/build.d.ts +13 -0
  7. package/dist/alpha/cli/build.d.ts.map +1 -0
  8. package/dist/alpha/cli/build.js +36 -0
  9. package/dist/alpha/cli/build.test.d.ts +2 -0
  10. package/dist/alpha/cli/build.test.d.ts.map +1 -0
  11. package/dist/alpha/cli/codegen.d.ts +10 -0
  12. package/dist/alpha/cli/codegen.d.ts.map +1 -0
  13. package/dist/alpha/cli/codegen.js +41 -0
  14. package/dist/alpha/cli/codegen.test.d.ts +2 -0
  15. package/dist/alpha/cli/codegen.test.d.ts.map +1 -0
  16. package/dist/alpha/cli/discover.d.ts +35 -0
  17. package/dist/alpha/cli/discover.d.ts.map +1 -0
  18. package/dist/alpha/cli/discover.js +39 -0
  19. package/dist/alpha/cli/discover.test.d.ts +2 -0
  20. package/dist/alpha/cli/discover.test.d.ts.map +1 -0
  21. package/dist/alpha/cli/emit-manifest.d.ts +33 -0
  22. package/dist/alpha/cli/emit-manifest.d.ts.map +1 -0
  23. package/dist/alpha/cli/emit-manifest.js +266 -0
  24. package/dist/alpha/cli/emit-manifest.test.d.ts +2 -0
  25. package/dist/alpha/cli/emit-manifest.test.d.ts.map +1 -0
  26. package/dist/alpha/cli/index.d.ts +3 -0
  27. package/dist/alpha/cli/index.d.ts.map +1 -0
  28. package/dist/alpha/cli/index.js +36 -0
  29. package/dist/alpha/context.d.ts +8 -0
  30. package/dist/alpha/context.d.ts.map +1 -0
  31. package/dist/alpha/context.js +33 -0
  32. package/dist/alpha/database.d.ts +90 -0
  33. package/dist/alpha/database.d.ts.map +1 -0
  34. package/dist/alpha/database.js +9 -0
  35. package/dist/alpha/error.d.ts +41 -0
  36. package/dist/alpha/error.d.ts.map +1 -0
  37. package/dist/alpha/error.js +25 -0
  38. package/dist/alpha/icon-names.d.ts +6 -0
  39. package/dist/alpha/icon-names.d.ts.map +1 -0
  40. package/dist/alpha/icon-names.js +0 -0
  41. package/dist/alpha/json-schema.d.ts +117 -0
  42. package/dist/alpha/json-schema.d.ts.map +1 -0
  43. package/dist/alpha/json-schema.js +0 -0
  44. package/dist/alpha/manifest.d.ts +44 -0
  45. package/dist/alpha/manifest.d.ts.map +1 -0
  46. package/dist/alpha/manifest.js +0 -0
  47. package/dist/alpha/output.d.ts +7 -0
  48. package/dist/alpha/output.d.ts.map +1 -0
  49. package/dist/alpha/output.js +8 -0
  50. package/dist/alpha/pacer.d.ts +74 -0
  51. package/dist/alpha/pacer.d.ts.map +1 -0
  52. package/dist/alpha/pacer.js +66 -0
  53. package/dist/alpha/pacer.test.d.ts +2 -0
  54. package/dist/alpha/pacer.test.d.ts.map +1 -0
  55. package/dist/alpha/schedule.d.ts +6 -0
  56. package/dist/alpha/schedule.d.ts.map +1 -0
  57. package/dist/alpha/schedule.js +43 -0
  58. package/dist/alpha/schedule.test.d.ts +2 -0
  59. package/dist/alpha/schedule.test.d.ts.map +1 -0
  60. package/dist/alpha/schema-builder.d.ts +79 -0
  61. package/dist/alpha/schema-builder.d.ts.map +1 -0
  62. package/dist/alpha/schema-builder.js +135 -0
  63. package/dist/alpha/schema.d.ts +185 -0
  64. package/dist/alpha/schema.d.ts.map +1 -0
  65. package/dist/alpha/schema.js +115 -0
  66. package/dist/alpha/sync.d.ts +233 -0
  67. package/dist/alpha/sync.d.ts.map +1 -0
  68. package/dist/alpha/sync.js +70 -0
  69. package/dist/alpha/sync.test.d.ts +2 -0
  70. package/dist/alpha/sync.test.d.ts.map +1 -0
  71. package/dist/alpha/tool.d.ts +132 -0
  72. package/dist/alpha/tool.d.ts.map +1 -0
  73. package/dist/alpha/tool.js +156 -0
  74. package/dist/alpha/tool.test.d.ts +2 -0
  75. package/dist/alpha/tool.test.d.ts.map +1 -0
  76. package/dist/alpha/triggers.d.ts +365 -0
  77. package/dist/alpha/triggers.d.ts.map +1 -0
  78. package/dist/alpha/triggers.generated.d.ts +246 -0
  79. package/dist/alpha/triggers.generated.d.ts.map +1 -0
  80. package/dist/alpha/triggers.generated.js +239 -0
  81. package/dist/alpha/triggers.js +0 -0
  82. package/dist/alpha/types.d.ts +228 -0
  83. package/dist/alpha/types.d.ts.map +1 -0
  84. package/dist/alpha/types.js +0 -0
  85. package/dist/alpha/workflow.d.ts +70 -0
  86. package/dist/alpha/workflow.d.ts.map +1 -0
  87. package/dist/alpha/workflow.js +39 -0
  88. package/dist/alpha/workflow.test.d.ts +2 -0
  89. package/dist/alpha/workflow.test.d.ts.map +1 -0
  90. package/dist/builder.d.ts +7 -1
  91. package/dist/builder.d.ts.map +1 -1
  92. package/dist/builder.js +12 -3
  93. package/dist/capabilities/ai_connector.d.ts.map +1 -1
  94. package/dist/capabilities/automation.d.ts.map +1 -1
  95. package/dist/capabilities/context.d.ts.map +1 -1
  96. package/dist/capabilities/context.js +1 -1
  97. package/dist/capabilities/custom-block.d.ts +124 -0
  98. package/dist/capabilities/custom-block.d.ts.map +1 -0
  99. package/dist/capabilities/custom-block.js +22 -0
  100. package/dist/capabilities/oauth.d.ts.map +1 -1
  101. package/dist/capabilities/output.d.ts.map +1 -1
  102. package/dist/capabilities/output.js +2 -4
  103. package/dist/capabilities/stateful-capability.d.ts.map +1 -1
  104. package/dist/capabilities/stateful-capability.js +3 -7
  105. package/dist/capabilities/sync.d.ts +11 -1
  106. package/dist/capabilities/sync.d.ts.map +1 -1
  107. package/dist/capabilities/sync.js +2 -8
  108. package/dist/capabilities/tool.d.ts.map +1 -1
  109. package/dist/capabilities/tool.js +6 -20
  110. package/dist/capabilities/webhook.d.ts.map +1 -1
  111. package/dist/capabilities/workflow.d.ts +50 -0
  112. package/dist/capabilities/workflow.d.ts.map +1 -0
  113. package/dist/capabilities/workflow.js +40 -0
  114. package/dist/capabilities/workflow.test.d.ts +2 -0
  115. package/dist/capabilities/workflow.test.d.ts.map +1 -0
  116. package/dist/index.d.ts +5 -3
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +2 -1
  119. package/dist/json-schema.d.ts.map +1 -1
  120. package/dist/schema-builder.d.ts.map +1 -1
  121. package/dist/schema.d.ts.map +1 -1
  122. package/dist/triggers.d.ts +365 -0
  123. package/dist/triggers.d.ts.map +1 -0
  124. package/dist/triggers.generated.d.ts +246 -0
  125. package/dist/triggers.generated.d.ts.map +1 -0
  126. package/dist/triggers.generated.js +239 -0
  127. package/dist/triggers.js +0 -0
  128. package/dist/types.d.ts +18 -0
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/worker.d.ts +72 -2
  131. package/dist/worker.d.ts.map +1 -1
  132. package/dist/worker.js +85 -4
  133. package/package.json +81 -14
  134. package/src/alpha/ajv-formats.d.ts +7 -0
  135. package/src/alpha/builder.test.ts +197 -0
  136. package/src/alpha/builder.ts +338 -0
  137. package/src/alpha/cli/build.test.ts +240 -0
  138. package/src/alpha/cli/build.ts +57 -0
  139. package/src/alpha/cli/codegen.test.ts +31 -0
  140. package/src/alpha/cli/codegen.ts +51 -0
  141. package/src/alpha/cli/discover.test.ts +43 -0
  142. package/src/alpha/cli/discover.ts +81 -0
  143. package/src/alpha/cli/emit-manifest.test.ts +45 -0
  144. package/src/alpha/cli/emit-manifest.ts +409 -0
  145. package/src/alpha/cli/index.ts +41 -0
  146. package/src/alpha/context.ts +61 -0
  147. package/src/alpha/database.ts +102 -0
  148. package/src/alpha/error.ts +49 -0
  149. package/src/alpha/icon-names.ts +890 -0
  150. package/src/alpha/json-schema.ts +180 -0
  151. package/src/alpha/manifest.ts +47 -0
  152. package/src/alpha/output.ts +8 -0
  153. package/src/alpha/pacer.test.ts +81 -0
  154. package/src/alpha/pacer.ts +181 -0
  155. package/src/alpha/schedule.test.ts +31 -0
  156. package/src/alpha/schedule.ts +53 -0
  157. package/src/alpha/schema-builder.ts +193 -0
  158. package/src/alpha/schema.ts +222 -0
  159. package/src/alpha/sync.test.ts +191 -0
  160. package/src/alpha/sync.ts +340 -0
  161. package/src/alpha/tool.test.ts +116 -0
  162. package/src/alpha/tool.ts +319 -0
  163. package/src/alpha/triggers.generated.ts +489 -0
  164. package/src/alpha/triggers.ts +500 -0
  165. package/src/alpha/types.ts +284 -0
  166. package/src/alpha/workflow.test.ts +84 -0
  167. package/src/alpha/workflow.ts +131 -0
  168. package/src/builder.test.ts +28 -60
  169. package/src/builder.ts +20 -7
  170. package/src/capabilities/ai_connector.test.ts +9 -32
  171. package/src/capabilities/ai_connector.ts +5 -17
  172. package/src/capabilities/automation.test.ts +3 -12
  173. package/src/capabilities/automation.ts +3 -11
  174. package/src/capabilities/context.ts +5 -8
  175. package/src/capabilities/custom-block.ts +181 -0
  176. package/src/capabilities/oauth.test.ts +1 -0
  177. package/src/capabilities/oauth.ts +1 -3
  178. package/src/capabilities/output.ts +1 -3
  179. package/src/capabilities/stateful-capability.test.ts +1 -0
  180. package/src/capabilities/stateful-capability.ts +3 -7
  181. package/src/capabilities/sync.test.ts +76 -42
  182. package/src/capabilities/sync.ts +16 -40
  183. package/src/capabilities/tool.test.ts +106 -146
  184. package/src/capabilities/tool.ts +17 -39
  185. package/src/capabilities/webhook.ts +3 -10
  186. package/src/capabilities/workflow.test.ts +163 -0
  187. package/src/capabilities/workflow.ts +112 -0
  188. package/src/index.ts +18 -6
  189. package/src/json-schema.test.ts +2 -4
  190. package/src/json-schema.ts +7 -6
  191. package/src/pacer.test.ts +1 -3
  192. package/src/pacer_internal.ts +1 -5
  193. package/src/schema-builder.test.ts +10 -35
  194. package/src/schema-builder.ts +6 -16
  195. package/src/schema.ts +2 -10
  196. package/src/triggers.generated.ts +489 -0
  197. package/src/triggers.ts +504 -0
  198. package/src/types.ts +21 -7
  199. package/src/worker.test.ts +147 -0
  200. package/src/worker.ts +155 -32
  201. package/dist/block.d.ts +0 -321
  202. package/dist/block.d.ts.map +0 -1
  203. package/src/block.ts +0 -529
  204. /package/dist/{block.js → alpha/ajv-formats.d.js} +0 -0
@@ -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
+ });
@@ -0,0 +1,51 @@
1
+ import type { DiscoveredCapability } from "./discover.js";
2
+
3
+ /**
4
+ * Generate the worker entry module for a set of discovered capabilities.
5
+ *
6
+ * The module uses only static imports so that the bundler can bundle the
7
+ * whole worker into a single file. It is written to `.notion/entry.ts` in
8
+ * the worker project, so imports are relative to that location.
9
+ */
10
+ export function generateEntry(capabilities: readonly DiscoveredCapability[]): string {
11
+ const imports: string[] = [];
12
+ const registryEntries = new Map<string, string[]>();
13
+
14
+ capabilities.forEach((capability, index) => {
15
+ const binding = `capability_${index}`;
16
+ imports.push(`import ${binding} from ${JSON.stringify(`../${capability.sourcePath}`)};`);
17
+
18
+ const entries = registryEntries.get(capability.type) ?? [];
19
+ entries.push(`\t\t${JSON.stringify(capability.key)}: ${binding},`);
20
+ registryEntries.set(capability.type, entries);
21
+ });
22
+
23
+ const registry = Array.from(registryEntries.entries())
24
+ .map(([type, entries]) => `\t${JSON.stringify(type)}: {\n${entries.join("\n")}\n\t},`)
25
+ .join("\n");
26
+
27
+ return `// Generated by \`notion-workers build\`. Do not edit.
28
+ ${imports.join("\n")}
29
+
30
+ type Handler = (context?: unknown, options?: { concreteOutput?: true }) => Promise<unknown>;
31
+
32
+ export const capabilities: Record<string, Record<string, { handler: Handler }>> = {
33
+ ${registry}
34
+ };
35
+
36
+ export async function run(
37
+ type: string,
38
+ key: string,
39
+ context?: unknown,
40
+ options?: { concreteOutput?: true },
41
+ ): Promise<unknown> {
42
+ const capability = capabilities[type]?.[key];
43
+
44
+ if (!capability) {
45
+ throw new Error(\`Capability "\${type}/\${key}" not found\`);
46
+ }
47
+
48
+ return capability.handler(context, options);
49
+ }
50
+ `;
51
+ }
@@ -0,0 +1,43 @@
1
+ import * as path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { discoverCapabilities } from "./discover.js";
7
+
8
+ const FIXTURES = fileURLToPath(new URL("../../../test/fixtures/", import.meta.url));
9
+
10
+ describe("discoverCapabilities", () => {
11
+ it("finds top-level files in a registered capability type's directory", async () => {
12
+ const capabilities = await discoverCapabilities(path.join(FIXTURES, "custom-type"), [
13
+ { type: "greeting", directory: "greetings", tag: "greeting" },
14
+ ]);
15
+
16
+ // Only top-level .ts files count; subdirectories are plain modules.
17
+ expect(capabilities).toEqual([
18
+ {
19
+ type: "greeting",
20
+ tag: "greeting",
21
+ key: "hello",
22
+ sourcePath: path.join("src", "greetings", "hello.ts"),
23
+ },
24
+ ]);
25
+ });
26
+
27
+ it("finds capability files by convention", async () => {
28
+ const capabilities = await discoverCapabilities(path.join(FIXTURES, "basic"));
29
+
30
+ expect(capabilities).toEqual([
31
+ {
32
+ type: "sync",
33
+ tag: "sync",
34
+ key: "static",
35
+ sourcePath: path.join("src", "syncs", "static.ts"),
36
+ },
37
+ ]);
38
+ });
39
+
40
+ it("returns nothing when no capability directories exist", async () => {
41
+ expect(await discoverCapabilities(path.join(FIXTURES, "empty"))).toEqual([]);
42
+ });
43
+ });
@@ -0,0 +1,81 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import type { CapabilityType } from "../manifest.js";
5
+
6
+ /**
7
+ * A capability type the build tool knows how to discover: which directory
8
+ * under `src/` its files live in, and the `_tag` its default exports must
9
+ * carry. Adding a new capability type to the SDK means adding an entry here
10
+ * (plus its factory function).
11
+ */
12
+ export type CapabilityTypeDefinition = {
13
+ type: CapabilityType;
14
+ directory: string;
15
+ tag: string;
16
+ };
17
+
18
+ export const CAPABILITY_TYPES: readonly CapabilityTypeDefinition[] = [
19
+ { type: "sync", directory: "syncs", tag: "sync" },
20
+ { type: "tool", directory: "tools", tag: "tool" },
21
+ { type: "workflow", directory: "workflows", tag: "workflow" },
22
+ ];
23
+
24
+ /**
25
+ * A capability file found during discovery. The key is the file's basename;
26
+ * combined with the type it forms the capability's identity (`sync/static`).
27
+ */
28
+ export type DiscoveredCapability = {
29
+ type: CapabilityType;
30
+ tag: string;
31
+ key: string;
32
+ /** Path to the source file, relative to the project root. */
33
+ sourcePath: string;
34
+ };
35
+
36
+ /**
37
+ * Discover capability files by convention: every `.ts` file directly under
38
+ * `src/<directory>/` for each registered capability type. Subdirectories are
39
+ * not searched — they hold plain modules, not capabilities.
40
+ *
41
+ * Only capabilities are discovered by file location. Resources (databases,
42
+ * pacers) live anywhere and are discovered by walking capability configs at
43
+ * manifest time.
44
+ */
45
+ export async function discoverCapabilities(
46
+ projectRoot: string,
47
+ capabilityTypes: readonly CapabilityTypeDefinition[] = CAPABILITY_TYPES,
48
+ ): Promise<DiscoveredCapability[]> {
49
+ const discovered: DiscoveredCapability[] = [];
50
+
51
+ for (const definition of capabilityTypes) {
52
+ const directory = path.join(projectRoot, "src", definition.directory);
53
+
54
+ let entries: fs.Dirent[];
55
+ try {
56
+ entries = await fs.promises.readdir(directory, { withFileTypes: true });
57
+ } catch (error) {
58
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
59
+ continue;
60
+ }
61
+ throw error;
62
+ }
63
+
64
+ entries.sort((a, b) => a.name.localeCompare(b.name));
65
+
66
+ for (const entry of entries) {
67
+ if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) {
68
+ continue;
69
+ }
70
+
71
+ discovered.push({
72
+ type: definition.type,
73
+ tag: definition.tag,
74
+ key: entry.name.slice(0, -".ts".length),
75
+ sourcePath: path.join("src", definition.directory, entry.name),
76
+ });
77
+ }
78
+ }
79
+
80
+ return discovered;
81
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { assertJsonSafe } from "./emit-manifest.js";
4
+
5
+ describe("assertJsonSafe", () => {
6
+ it("accepts plain JSON values, including nested objects and arrays", () => {
7
+ expect(() =>
8
+ assertJsonSafe(
9
+ {
10
+ name: "sync",
11
+ count: 3,
12
+ enabled: true,
13
+ missing: null,
14
+ tags: ["a", "b"],
15
+ nested: { list: [1, 2, { deep: "value" }] },
16
+ },
17
+ "config",
18
+ ),
19
+ ).not.toThrow();
20
+ });
21
+
22
+ it("omits undefined-valued object properties without throwing", () => {
23
+ expect(() => assertJsonSafe({ a: undefined, b: 1 }, "config")).not.toThrow();
24
+ });
25
+
26
+ it.each([
27
+ ["function", { a: () => {} }],
28
+ ["symbol", { a: Symbol("x") }],
29
+ ["bigint", { a: 1n }],
30
+ ["NaN", { a: Number.NaN }],
31
+ ["Infinity", { a: Number.POSITIVE_INFINITY }],
32
+ ["Map", { a: new Map() }],
33
+ ["Set", { a: new Set() }],
34
+ ["Date", { a: new Date(0) }],
35
+ ["undefined array element", { a: [undefined] }],
36
+ ])("throws for non-serializable %s", (_label, value) => {
37
+ expect(() => assertJsonSafe(value, "config")).toThrow(/not JSON-serializable/);
38
+ });
39
+
40
+ it("includes the path to the offending value in the error", () => {
41
+ expect(() => assertJsonSafe({ outer: { inner: () => {} } }, "src/syncs/foo.ts config")).toThrow(
42
+ "src/syncs/foo.ts config.outer.inner",
43
+ );
44
+ });
45
+ });