@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,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
+ });
@@ -0,0 +1,409 @@
1
+ import { createRequire } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ import { Ajv } from "ajv";
5
+
6
+ import type { DatabaseManifestEntry, PacerManifestEntry, WorkerManifest } from "../manifest.js";
7
+ import { clearRegisteredPacers, registeredPacers } from "../pacer.js";
8
+ import type { DiscoveredCapability } from "./discover.js";
9
+
10
+ const SDK_VERSION = (() => {
11
+ const require = createRequire(import.meta.url);
12
+ const packageJson: unknown = require("../../../package.json");
13
+
14
+ if (
15
+ typeof packageJson !== "object" ||
16
+ packageJson === null ||
17
+ !("version" in packageJson) ||
18
+ typeof packageJson.version !== "string"
19
+ ) {
20
+ throw new Error("Failed to read SDK version from package.json");
21
+ }
22
+
23
+ return packageJson.version;
24
+ })();
25
+
26
+ const MANIFEST_SCHEMA = {
27
+ type: "object",
28
+ properties: {
29
+ sdkVersion: { type: "string" },
30
+ databases: {
31
+ type: "array",
32
+ items: {
33
+ type: "object",
34
+ properties: {
35
+ key: { type: "string" },
36
+ config: {},
37
+ },
38
+ required: ["key", "config"],
39
+ additionalProperties: false,
40
+ },
41
+ },
42
+ pacers: {
43
+ type: "array",
44
+ items: {
45
+ type: "object",
46
+ properties: {
47
+ key: { type: "string" },
48
+ config: {},
49
+ },
50
+ required: ["key", "config"],
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ capabilities: {
55
+ type: "array",
56
+ items: {
57
+ type: "object",
58
+ properties: {
59
+ type: { type: "string" },
60
+ key: { type: "string" },
61
+ config: {},
62
+ },
63
+ required: ["type", "key", "config"],
64
+ additionalProperties: false,
65
+ },
66
+ },
67
+ },
68
+ required: ["sdkVersion", "databases", "pacers", "capabilities"],
69
+ additionalProperties: false,
70
+ } as const;
71
+
72
+ type TaggedObject = { _tag: string; config: Record<string, unknown> };
73
+
74
+ type DatabaseObject = TaggedObject & { name: string };
75
+
76
+ /**
77
+ * Collects the databases reachable from capability configs, deduplicating
78
+ * by object identity within the build and by `name` across deploys.
79
+ */
80
+ class DatabaseCollector {
81
+ /** Database object → name, for identity-based dedup. */
82
+ #names = new Map<unknown, string>();
83
+ /** Name → database object, for detecting distinct objects sharing a name. */
84
+ #byName = new Map<string, DatabaseObject>();
85
+ /** Names in first-seen order, for deterministic manifest output. */
86
+ #order: string[] = [];
87
+
88
+ /**
89
+ * Register a database and, transitively, every database its schema's
90
+ * relation properties reference. Returns the database's name.
91
+ */
92
+ collect(value: unknown, referencedFrom: string): string {
93
+ const known = this.#names.get(value);
94
+ if (known !== undefined) {
95
+ return known;
96
+ }
97
+
98
+ const database = asDatabase(value);
99
+ if (!database) {
100
+ throw new Error(`${referencedFrom}: expected a database created with createDatabase(...)`);
101
+ }
102
+
103
+ const existing = this.#byName.get(database.name);
104
+ if (existing && existing !== value) {
105
+ throw new Error(
106
+ `Two distinct databases are named "${database.name}" (found via ${referencedFrom}). ` +
107
+ `A name identifies one database — share a single createDatabase(...) instance ` +
108
+ `via import instead of declaring it twice`,
109
+ );
110
+ }
111
+
112
+ this.#names.set(value, database.name);
113
+ this.#byName.set(database.name, database);
114
+ this.#order.push(database.name);
115
+
116
+ validatePrimaryKey(database);
117
+
118
+ // Relations pull their target databases into the manifest even when
119
+ // no sync writes to them directly.
120
+ for (const [propertyName, property] of schemaProperties(database)) {
121
+ if (isRelationProperty(property)) {
122
+ this.collect(
123
+ property.database,
124
+ `relation property "${propertyName}" of database "${database.name}"`,
125
+ );
126
+ }
127
+ }
128
+
129
+ return database.name;
130
+ }
131
+
132
+ /** Manifest entries with relation references resolved to names. */
133
+ entries(): DatabaseManifestEntry[] {
134
+ return this.#order.map((name) => {
135
+ const database = this.#byName.get(name)!;
136
+ const resolvedProperties: Record<string, unknown> = {};
137
+
138
+ for (const [propertyName, property] of schemaProperties(database)) {
139
+ if (isRelationProperty(property)) {
140
+ const { database: target, ...rest } = property;
141
+ resolvedProperties[propertyName] = {
142
+ ...rest,
143
+ relatedDatabaseKey: this.#names.get(target)!,
144
+ };
145
+ } else {
146
+ resolvedProperties[propertyName] = property;
147
+ }
148
+ }
149
+
150
+ const schema = database.config.schema as Record<string, unknown>;
151
+ return {
152
+ key: name,
153
+ config: { ...database.config, schema: { ...schema, properties: resolvedProperties } },
154
+ };
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Extract the worker manifest from a built bundle.
161
+ *
162
+ * This is the one place the build executes worker code: the bundle is
163
+ * imported once (running module top-level code only — handlers are never
164
+ * invoked), each discovered capability's default export is validated
165
+ * against the tag its directory requires, and the handler-free configs are
166
+ * assembled into the manifest.
167
+ *
168
+ * Resources are not discovered from the filesystem. Databases are collected
169
+ * by walking capability configs — a sync's `database`, and transitively any
170
+ * database referenced by a schema's `relation` properties. Pacers register
171
+ * themselves when created, so importing the bundle populates the pacer
172
+ * registry as a side effect. Within a build, resources deduplicate by
173
+ * object identity; across deploys their `name` is the stable key. Two
174
+ * distinct resource objects sharing a name fail the build.
175
+ */
176
+ export async function extractManifest(
177
+ bundlePath: string,
178
+ capabilities: readonly DiscoveredCapability[],
179
+ ): Promise<WorkerManifest> {
180
+ // Importing the bundle registers every pacer created at module top level;
181
+ // clear first so repeated builds in one process don't see stale entries.
182
+ clearRegisteredPacers();
183
+ const bundle = await importBundle(bundlePath);
184
+ const pacerEntries: PacerManifestEntry[] = registeredPacers().map((pacer) => ({
185
+ key: pacer.name,
186
+ config: pacer.config,
187
+ }));
188
+
189
+ const capabilityRegistry = getCapabilityRegistry(bundle);
190
+ const databaseCollector = new DatabaseCollector();
191
+
192
+ const capabilityEntries = capabilities.map((capability) => {
193
+ const value = capabilityRegistry[capability.type]?.[capability.key];
194
+ const tagged = asTagged(value);
195
+
196
+ if (!tagged) {
197
+ throw new Error(`${capability.sourcePath}: default export is not a capability`);
198
+ }
199
+
200
+ if (tagged._tag !== capability.tag) {
201
+ throw new Error(
202
+ `${capability.sourcePath}: default export is not a ${capability.type} — ` +
203
+ `files under this directory must default-export create${capitalize(capability.type)}(...)`,
204
+ );
205
+ }
206
+
207
+ const config = { ...tagged.config };
208
+
209
+ if (capability.type === "sync") {
210
+ config.databaseKey = databaseCollector.collect(config.database, capability.sourcePath);
211
+ config.database = undefined;
212
+ }
213
+
214
+ // Verify after the sync transform above swaps the non-plain database
215
+ // object out for its string key.
216
+ assertJsonSafe(config, `${capability.sourcePath} config`);
217
+
218
+ return { type: capability.type, key: capability.key, config };
219
+ });
220
+
221
+ const databaseEntries = databaseCollector.entries();
222
+ for (const entry of databaseEntries) {
223
+ assertJsonSafe(entry.config, `database "${entry.key}" config`);
224
+ }
225
+ for (const entry of pacerEntries) {
226
+ assertJsonSafe(entry.config, `pacer "${entry.key}" config`);
227
+ }
228
+
229
+ // Every config was verified JSON-safe above, so this round-trip is lossless;
230
+ // it normalizes the already-validated values into plain JSON before the
231
+ // manifest-structure check below.
232
+ const manifest: unknown = JSON.parse(
233
+ JSON.stringify({
234
+ sdkVersion: SDK_VERSION,
235
+ databases: databaseEntries,
236
+ pacers: pacerEntries,
237
+ capabilities: capabilityEntries,
238
+ } satisfies WorkerManifest),
239
+ );
240
+
241
+ const ajv = new Ajv();
242
+ if (!ajv.validate(MANIFEST_SCHEMA, manifest)) {
243
+ throw new Error(`Generated manifest is invalid: ${ajv.errorsText(ajv.errors)}`);
244
+ }
245
+
246
+ return manifest as WorkerManifest;
247
+ }
248
+
249
+ function schemaProperties(database: DatabaseObject): [string, unknown][] {
250
+ const schema = database.config.schema;
251
+ if (
252
+ typeof schema !== "object" ||
253
+ schema === null ||
254
+ !("properties" in schema) ||
255
+ typeof schema.properties !== "object" ||
256
+ schema.properties === null
257
+ ) {
258
+ throw new Error(`Database "${database.name}" has no schema.properties`);
259
+ }
260
+
261
+ return Object.entries(schema.properties);
262
+ }
263
+
264
+ function validatePrimaryKey(database: DatabaseObject): void {
265
+ const propertyNames = schemaProperties(database).map(([name]) => name);
266
+ const primaryKey = database.config.primaryKey;
267
+
268
+ if (typeof primaryKey !== "string" || !propertyNames.includes(primaryKey)) {
269
+ throw new Error(
270
+ `Database "${database.name}": primaryKey "${String(primaryKey)}" is not a property ` +
271
+ `in the schema`,
272
+ );
273
+ }
274
+ }
275
+
276
+ function isRelationProperty(property: unknown): property is { database: unknown } {
277
+ return (
278
+ typeof property === "object" &&
279
+ property !== null &&
280
+ "type" in property &&
281
+ property.type === "relation" &&
282
+ "database" in property
283
+ );
284
+ }
285
+
286
+ // Node's ESM module cache is keyed by URL and never evicts, so importing
287
+ // the same bundle path twice in one process (tests, watch mode) would
288
+ // return whichever module loaded first: stale code after a rebuild, and —
289
+ // even when the code is unchanged — a cache hit skips re-running module
290
+ // top-level side effects, which manifest extraction depends on (e.g.
291
+ // pacers register themselves at creation). A per-import nonce forces a
292
+ // fresh module instance every time; the abandoned cache entries are
293
+ // negligible for a build process. Irrelevant for a single `notion-workers
294
+ // build`, which imports exactly once.
295
+ let importNonce = 0;
296
+
297
+ async function importBundle(bundlePath: string): Promise<unknown> {
298
+ try {
299
+ return await import(`${pathToFileURL(bundlePath).href}?import=${importNonce++}`);
300
+ } catch (error) {
301
+ throw new Error(
302
+ `Failed to import built worker bundle. Capability modules must be importable ` +
303
+ `without secrets or network access — check for top-level code that throws.\n` +
304
+ `Caused by: ${error instanceof Error ? error.message : String(error)}`,
305
+ );
306
+ }
307
+ }
308
+
309
+ function getCapabilityRegistry(
310
+ bundle: unknown,
311
+ ): Record<string, Record<string, unknown> | undefined> {
312
+ if (
313
+ typeof bundle !== "object" ||
314
+ bundle === null ||
315
+ !("capabilities" in bundle) ||
316
+ typeof bundle.capabilities !== "object" ||
317
+ bundle.capabilities === null
318
+ ) {
319
+ throw new Error(`Built worker bundle does not export a capability registry`);
320
+ }
321
+
322
+ return bundle.capabilities as Record<string, Record<string, unknown> | undefined>;
323
+ }
324
+
325
+ function asTagged(value: unknown): TaggedObject | undefined {
326
+ if (
327
+ typeof value === "object" &&
328
+ value !== null &&
329
+ "_tag" in value &&
330
+ typeof value._tag === "string" &&
331
+ "config" in value &&
332
+ typeof value.config === "object" &&
333
+ value.config !== null
334
+ ) {
335
+ return value as TaggedObject;
336
+ }
337
+
338
+ return undefined;
339
+ }
340
+
341
+ function asDatabase(value: unknown): DatabaseObject | undefined {
342
+ const tagged = asTagged(value);
343
+ if (tagged?._tag === "database" && "name" in tagged && typeof tagged.name === "string") {
344
+ return tagged as DatabaseObject;
345
+ }
346
+
347
+ return undefined;
348
+ }
349
+
350
+ function capitalize(value: string): string {
351
+ return value.charAt(0).toUpperCase() + value.slice(1);
352
+ }
353
+
354
+ /**
355
+ * Throw if `value` contains anything `JSON.stringify` would silently drop or
356
+ * rewrite, so a config that can't round-trip fails the build here rather than
357
+ * producing a manifest that quietly disagrees with the author's source.
358
+ *
359
+ * Rejects functions, symbols, bigints, non-finite numbers, `undefined` array
360
+ * elements, and non-plain objects (Map, Set, Date, class instances). Allows
361
+ * null, booleans, finite numbers, strings, plain arrays, and plain objects.
362
+ * `undefined`-valued object properties are permitted and omitted, mirroring
363
+ * JSON semantics for unset optional fields.
364
+ */
365
+ export function assertJsonSafe(value: unknown, path: string): void {
366
+ const reject = (reason: string): never => {
367
+ throw new Error(`${path}: not JSON-serializable (${reason})`);
368
+ };
369
+
370
+ switch (typeof value) {
371
+ case "function":
372
+ reject("function");
373
+ break;
374
+ case "symbol":
375
+ reject("symbol");
376
+ break;
377
+ case "bigint":
378
+ reject("bigint");
379
+ break;
380
+ case "number":
381
+ if (!Number.isFinite(value)) reject(String(value));
382
+ return;
383
+ case "string":
384
+ case "boolean":
385
+ case "undefined":
386
+ return;
387
+ }
388
+
389
+ if (value === null) return;
390
+
391
+ if (Array.isArray(value)) {
392
+ value.forEach((element, index) => {
393
+ if (element === undefined) reject(`undefined at [${index}]`);
394
+ assertJsonSafe(element, `${path}[${index}]`);
395
+ });
396
+ return;
397
+ }
398
+
399
+ const object = value as { constructor?: { name?: string } };
400
+ const prototype = Object.getPrototypeOf(object);
401
+ if (prototype !== null && prototype !== Object.prototype) {
402
+ reject(object.constructor?.name ?? "non-plain object");
403
+ }
404
+
405
+ for (const [key, property] of Object.entries(object as Record<string, unknown>)) {
406
+ if (property === undefined) continue;
407
+ assertJsonSafe(property, `${path}.${key}`);
408
+ }
409
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import * as path from "node:path";
3
+
4
+ import { buildWorker } from "./build.js";
5
+
6
+ const USAGE = `Usage: notion-workers <command>
7
+
8
+ Commands:
9
+ build Build the worker in the current directory
10
+ `;
11
+
12
+ async function main(): Promise<void> {
13
+ const command = process.argv[2];
14
+
15
+ switch (command) {
16
+ case "build": {
17
+ const projectRoot = process.cwd();
18
+ const { manifest, bundlePath, manifestPath } = await buildWorker(projectRoot);
19
+ const count = manifest.capabilities.length;
20
+ console.log(
21
+ `Built ${path.relative(projectRoot, bundlePath)} and ` +
22
+ `${path.relative(projectRoot, manifestPath)} ` +
23
+ `(${count} ${count === 1 ? "capability" : "capabilities"})`,
24
+ );
25
+ break;
26
+ }
27
+ case undefined:
28
+ case "help":
29
+ case "--help":
30
+ console.log(USAGE);
31
+ break;
32
+ default:
33
+ console.error(`Unknown command: ${command}\n\n${USAGE}`);
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+
38
+ main().catch((error: unknown) => {
39
+ console.error(error instanceof Error ? error.message : String(error));
40
+ process.exitCode = 1;
41
+ });