@notionhq/apps 0.0.4 → 0.0.7

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 (71) hide show
  1. package/README.md +53 -2
  2. package/dist/builder.d.ts +29 -0
  3. package/dist/builder.d.ts.map +1 -0
  4. package/dist/builder.js +86 -0
  5. package/dist/builder.test.d.ts +2 -0
  6. package/dist/builder.test.d.ts.map +1 -0
  7. package/dist/cli/build.js +1 -1
  8. package/dist/cli/discover.d.ts +2 -2
  9. package/dist/cli/discover.d.ts.map +1 -1
  10. package/dist/cli/discover.js +2 -1
  11. package/dist/cli/emit-manifest.d.ts +2 -2
  12. package/dist/cli/emit-manifest.d.ts.map +1 -1
  13. package/dist/cli/emit-manifest.js +177 -27
  14. package/dist/context.js +2 -2
  15. package/dist/database.d.ts +13 -0
  16. package/dist/database.d.ts.map +1 -0
  17. package/dist/database.js +9 -0
  18. package/dist/error.d.ts +7 -0
  19. package/dist/error.d.ts.map +1 -1
  20. package/dist/error.js +10 -1
  21. package/dist/manifest.d.ts +30 -5
  22. package/dist/manifest.d.ts.map +1 -1
  23. package/dist/manifest.js +4 -0
  24. package/dist/pacer.d.ts +15 -0
  25. package/dist/pacer.d.ts.map +1 -0
  26. package/dist/pacer.js +63 -0
  27. package/dist/runtime-input.d.ts +2 -0
  28. package/dist/runtime-input.d.ts.map +1 -0
  29. package/dist/runtime-input.js +20 -0
  30. package/dist/runtime-input.test.d.ts +2 -0
  31. package/dist/runtime-input.test.d.ts.map +1 -0
  32. package/dist/schedule.d.ts +3 -0
  33. package/dist/schedule.d.ts.map +1 -0
  34. package/dist/schedule.js +23 -0
  35. package/dist/schema.d.ts +111 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +39 -0
  38. package/dist/sync.d.ts +72 -0
  39. package/dist/sync.d.ts.map +1 -0
  40. package/dist/sync.js +75 -0
  41. package/dist/sync.test.d.ts +2 -0
  42. package/dist/sync.test.d.ts.map +1 -0
  43. package/dist/types.d.ts +85 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +0 -0
  46. package/dist/workflow.d.ts.map +1 -1
  47. package/dist/workflow.js +2 -0
  48. package/docs/BUILD.md +1 -0
  49. package/package.json +33 -2
  50. package/src/builder.test.ts +81 -0
  51. package/src/builder.ts +99 -0
  52. package/src/cli/build.test.ts +109 -0
  53. package/src/cli/build.ts +1 -1
  54. package/src/cli/codegen.test.ts +15 -0
  55. package/src/cli/discover.test.ts +13 -2
  56. package/src/cli/discover.ts +3 -2
  57. package/src/cli/emit-manifest.ts +229 -53
  58. package/src/context.ts +6 -6
  59. package/src/database.ts +25 -0
  60. package/src/error.ts +11 -0
  61. package/src/manifest.ts +33 -5
  62. package/src/pacer.ts +82 -0
  63. package/src/runtime-input.test.ts +65 -0
  64. package/src/runtime-input.ts +30 -0
  65. package/src/schedule.ts +25 -0
  66. package/src/schema.ts +73 -0
  67. package/src/sync.test.ts +359 -0
  68. package/src/sync.ts +190 -0
  69. package/src/types.ts +109 -0
  70. package/src/workflow.test.ts +22 -0
  71. package/src/workflow.ts +2 -0
@@ -3,13 +3,14 @@ import { pathToFileURL } from "node:url";
3
3
 
4
4
  import { Ajv } from "ajv";
5
5
 
6
- import type { AppManifest } from "../manifest.js";
6
+ import { APPS_MANIFEST_SCHEMA } from "../manifest.js";
7
+ import type { AppManifest, DatabaseManifestEntry, PacerManifestEntry } from "../manifest.js";
8
+ import { clearRegisteredPacers, registeredPacers } from "../pacer.js";
7
9
  import type { DiscoveredCapability } from "./discover.js";
8
10
 
9
11
  const SDK_VERSION = (() => {
10
12
  const require = createRequire(import.meta.url);
11
13
  const packageJson: unknown = require("../../package.json");
12
-
13
14
  if (
14
15
  typeof packageJson !== "object" ||
15
16
  packageJson === null ||
@@ -18,23 +19,61 @@ const SDK_VERSION = (() => {
18
19
  ) {
19
20
  throw new Error("Failed to read SDK version from package.json");
20
21
  }
21
-
22
22
  return packageJson.version;
23
23
  })();
24
24
 
25
25
  const MANIFEST_SCHEMA = {
26
26
  type: "object",
27
27
  properties: {
28
+ $schema: { type: "string", enum: [APPS_MANIFEST_SCHEMA] },
28
29
  sdkVersion: { type: "string" },
29
- databases: { type: "array", maxItems: 0 },
30
+ databases: {
31
+ type: "array",
32
+ items: {
33
+ type: "object",
34
+ properties: {
35
+ key: { type: "string" },
36
+ config: {
37
+ type: "object",
38
+ properties: {
39
+ initialTitle: { type: "string" },
40
+ schema: { type: "object" },
41
+ },
42
+ required: ["schema"],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ required: ["key", "config"],
47
+ additionalProperties: false,
48
+ },
49
+ },
30
50
  xldbs: { type: "array", maxItems: 0 },
31
- pacers: { type: "array", maxItems: 0 },
51
+ pacers: {
52
+ type: "array",
53
+ items: {
54
+ type: "object",
55
+ properties: {
56
+ key: { type: "string" },
57
+ config: {
58
+ type: "object",
59
+ properties: {
60
+ allowedRequests: { type: "number", exclusiveMinimum: 0 },
61
+ intervalMs: { type: "number", exclusiveMinimum: 0 },
62
+ },
63
+ required: ["allowedRequests", "intervalMs"],
64
+ additionalProperties: false,
65
+ },
66
+ },
67
+ required: ["key", "config"],
68
+ additionalProperties: false,
69
+ },
70
+ },
32
71
  capabilities: {
33
72
  type: "array",
34
73
  items: {
35
74
  type: "object",
36
75
  properties: {
37
- type: { type: "string", const: "workflow" },
76
+ type: { type: "string", enum: ["workflow", "sync"] },
38
77
  key: { type: "string" },
39
78
  config: {},
40
79
  },
@@ -43,44 +82,204 @@ const MANIFEST_SCHEMA = {
43
82
  },
44
83
  },
45
84
  },
46
- required: ["sdkVersion", "databases", "xldbs", "pacers", "capabilities"],
85
+ required: ["$schema", "sdkVersion", "databases", "xldbs", "pacers", "capabilities"],
47
86
  additionalProperties: false,
48
87
  } as const;
49
88
 
50
- type TaggedWorkflow = {
51
- _tag: "workflow";
52
- config: Record<string, unknown>;
53
- };
89
+ type TaggedCapability = { _tag: string; config: Record<string, unknown> };
90
+ type DatabaseObject = TaggedCapability & { _tag: "database"; key: string };
91
+
92
+ function asTagged(value: unknown): TaggedCapability | undefined {
93
+ if (
94
+ typeof value === "object" &&
95
+ value !== null &&
96
+ "_tag" in value &&
97
+ typeof value._tag === "string" &&
98
+ "config" in value &&
99
+ typeof value.config === "object" &&
100
+ value.config !== null
101
+ ) {
102
+ return value as TaggedCapability;
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function asDatabase(value: unknown): DatabaseObject | undefined {
108
+ const tagged = asTagged(value);
109
+ if (tagged?._tag === "database" && "key" in tagged && typeof tagged.key === "string") {
110
+ return tagged as DatabaseObject;
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function schemaProperties(database: DatabaseObject): Record<string, unknown> {
116
+ const schema = database.config.schema;
117
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
118
+ throw new Error(`Database "${database.key}" must have a schema object`);
119
+ }
120
+ return schema as Record<string, unknown>;
121
+ }
122
+
123
+ function isRelationProperty(
124
+ value: unknown,
125
+ ): value is Record<string, unknown> & { database: unknown } {
126
+ return (
127
+ typeof value === "object" &&
128
+ value !== null &&
129
+ "type" in value &&
130
+ value.type === "relation" &&
131
+ "database" in value
132
+ );
133
+ }
54
134
 
55
- /** Extract the workflow-only app manifest from a built bundle. */
135
+ class DatabaseCollector {
136
+ #keys = new Map<unknown, string>();
137
+ #byKey = new Map<string, DatabaseObject>();
138
+ #order: string[] = [];
139
+
140
+ collect(value: unknown, referencedFrom: string): string {
141
+ const known = this.#keys.get(value);
142
+ if (known !== undefined) return known;
143
+
144
+ const database = asDatabase(value);
145
+ if (!database) {
146
+ throw new Error(
147
+ `${referencedFrom}: expected a database created with createDatabase(...)`,
148
+ );
149
+ }
150
+ const duplicate = this.#byKey.get(database.key);
151
+ if (duplicate && duplicate !== database) {
152
+ throw new Error(`Two distinct databases use the key "${database.key}"`);
153
+ }
154
+
155
+ this.#keys.set(database, database.key);
156
+ this.#byKey.set(database.key, database);
157
+ this.#order.push(database.key);
158
+
159
+ for (const [propertyName, property] of Object.entries(schemaProperties(database))) {
160
+ if (isRelationProperty(property)) {
161
+ this.collect(
162
+ property.database,
163
+ `relation property "${propertyName}" of database "${database.key}"`,
164
+ );
165
+ }
166
+ }
167
+ return database.key;
168
+ }
169
+
170
+ get(key: string): DatabaseObject | undefined {
171
+ return this.#byKey.get(key);
172
+ }
173
+
174
+ entries(): DatabaseManifestEntry[] {
175
+ return this.#order.map((key) => {
176
+ const database = this.#byKey.get(key)!;
177
+ const properties: Record<string, unknown> = {};
178
+ for (const [propertyName, property] of Object.entries(schemaProperties(database))) {
179
+ if (isRelationProperty(property)) {
180
+ const { database: target, ...rest } = property;
181
+ properties[propertyName] = {
182
+ ...rest,
183
+ relatedDatabaseKey: this.#keys.get(target),
184
+ };
185
+ } else {
186
+ properties[propertyName] = property;
187
+ }
188
+ }
189
+
190
+ return {
191
+ key,
192
+ config: {
193
+ ...(typeof database.config.initialTitle === "string"
194
+ ? { initialTitle: database.config.initialTitle }
195
+ : {}),
196
+ schema: {
197
+ properties,
198
+ ...(database.config.databaseIcon === undefined
199
+ ? {}
200
+ : { databaseIcon: database.config.databaseIcon }),
201
+ ...(database.config.subItems === undefined
202
+ ? {}
203
+ : { subItems: database.config.subItems }),
204
+ },
205
+ },
206
+ };
207
+ });
208
+ }
209
+ }
210
+
211
+ /** Extract the app manifest from a built bundle. */
56
212
  export async function extractManifest(
57
213
  bundlePath: string,
58
214
  capabilities: readonly DiscoveredCapability[],
59
215
  ): Promise<AppManifest> {
216
+ clearRegisteredPacers();
60
217
  const bundle = await importBundle(bundlePath);
61
- const capabilityRegistry = getCapabilityRegistry(bundle);
218
+ const registry = getCapabilityRegistry(bundle);
219
+ const databases = new DatabaseCollector();
220
+ const primaryKeyByDatabase = new Map<string, string>();
62
221
 
63
222
  const capabilityEntries = capabilities.map((capability) => {
64
- const value = capabilityRegistry.workflow?.[capability.key];
65
- const workflow = asWorkflow(value);
66
-
67
- if (!workflow) {
223
+ const tagged = asTagged(registry[capability.type]?.[capability.key]);
224
+ if (!tagged || tagged._tag !== capability.tag) {
68
225
  throw new Error(
69
- `${capability.sourcePath}: default export is not a workflow — ` +
70
- `files under src/workflows/ must default-export createWorkflow(...)`,
226
+ `${capability.sourcePath}: default export is not a ${capability.type} — ` +
227
+ `files under this directory must default-export create${capitalize(capability.type)}(...)`,
71
228
  );
72
229
  }
73
230
 
74
- assertJsonSafe(workflow.config, `${capability.sourcePath} config`);
75
- return { type: "workflow" as const, key: capability.key, config: workflow.config };
231
+ let config: Record<string, unknown> = { ...tagged.config };
232
+ if (capability.type === "sync") {
233
+ const databaseKey = databases.collect(config.database, capability.sourcePath);
234
+ const database = databases.get(databaseKey)!;
235
+ const primaryKey = config.primaryKeyProperty;
236
+ const property =
237
+ typeof primaryKey === "string" ? schemaProperties(database)[primaryKey] : undefined;
238
+ if (
239
+ typeof primaryKey !== "string" ||
240
+ typeof property !== "object" ||
241
+ property === null ||
242
+ !("type" in property) ||
243
+ (property.type !== "title" && property.type !== "text")
244
+ ) {
245
+ throw new Error(
246
+ `${capability.sourcePath}: primaryKey "${String(primaryKey)}" must name a title or rich-text property`,
247
+ );
248
+ }
249
+
250
+ const existingPrimaryKey = primaryKeyByDatabase.get(databaseKey);
251
+ if (existingPrimaryKey !== undefined && existingPrimaryKey !== primaryKey) {
252
+ throw new Error(
253
+ `Syncs for database "${databaseKey}" disagree on its primary key: ` +
254
+ `"${existingPrimaryKey}" and "${primaryKey}"`,
255
+ );
256
+ }
257
+ primaryKeyByDatabase.set(databaseKey, primaryKey);
258
+ const { database: _database, ...rest } = config;
259
+ config = { ...rest, databaseKey };
260
+ }
261
+
262
+ assertJsonSafe(config, `${capability.sourcePath} config`);
263
+ return { type: capability.type, key: capability.key, config };
76
264
  });
77
265
 
266
+ const pacerEntries: PacerManifestEntry[] = registeredPacers().map((pacer) => ({
267
+ key: pacer.name,
268
+ config: pacer.config,
269
+ }));
270
+ for (const pacer of pacerEntries) assertJsonSafe(pacer.config, `pacer "${pacer.key}" config`);
271
+ const databaseEntries = databases.entries();
272
+ for (const database of databaseEntries) {
273
+ assertJsonSafe(database.config, `database "${database.key}" config`);
274
+ }
275
+
78
276
  const manifest: unknown = JSON.parse(
79
277
  JSON.stringify({
278
+ $schema: APPS_MANIFEST_SCHEMA,
80
279
  sdkVersion: SDK_VERSION,
81
- databases: [],
280
+ databases: databaseEntries,
82
281
  xldbs: [],
83
- pacers: [],
282
+ pacers: pacerEntries,
84
283
  capabilities: capabilityEntries,
85
284
  } satisfies AppManifest),
86
285
  );
@@ -89,7 +288,6 @@ export async function extractManifest(
89
288
  if (!ajv.validate(MANIFEST_SCHEMA, manifest)) {
90
289
  throw new Error(`Generated manifest is invalid: ${ajv.errorsText(ajv.errors)}`);
91
290
  }
92
-
93
291
  return manifest as AppManifest;
94
292
  }
95
293
 
@@ -100,9 +298,8 @@ async function importBundle(bundlePath: string): Promise<unknown> {
100
298
  return await import(`${pathToFileURL(bundlePath).href}?import=${importNonce++}`);
101
299
  } catch (error) {
102
300
  throw new Error(
103
- `Failed to import built app bundle. Workflow modules must be importable ` +
104
- `without secrets or network access — check for top-level code that throws.\n` +
105
- `Caused by: ${error instanceof Error ? error.message : String(error)}`,
301
+ `Failed to import built app bundle. Capability modules must be importable without ` +
302
+ `secrets or network access. Caused by: ${error instanceof Error ? error.message : String(error)}`,
106
303
  );
107
304
  }
108
305
  }
@@ -119,29 +316,16 @@ function getCapabilityRegistry(
119
316
  ) {
120
317
  throw new Error("Built app bundle does not export a capability registry");
121
318
  }
122
-
123
319
  return bundle.capabilities as Record<string, Record<string, unknown> | undefined>;
124
320
  }
125
321
 
126
- function asWorkflow(value: unknown): TaggedWorkflow | undefined {
127
- if (
128
- typeof value === "object" &&
129
- value !== null &&
130
- "_tag" in value &&
131
- value._tag === "workflow" &&
132
- "config" in value &&
133
- typeof value.config === "object" &&
134
- value.config !== null
135
- ) {
136
- return value as TaggedWorkflow;
137
- }
138
-
139
- return undefined;
322
+ function capitalize(value: string): string {
323
+ return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
140
324
  }
141
325
 
142
326
  /**
143
327
  * Throw if `value` contains anything `JSON.stringify` would silently drop or
144
- * rewrite, so invalid workflow configuration fails during the build.
328
+ * rewrite, so invalid manifest configuration fails during the build.
145
329
  */
146
330
  export function assertJsonSafe(value: unknown, path: string): void {
147
331
  const reject = (reason: string): never => {
@@ -150,13 +334,9 @@ export function assertJsonSafe(value: unknown, path: string): void {
150
334
 
151
335
  switch (typeof value) {
152
336
  case "function":
153
- reject("function");
154
- break;
155
337
  case "symbol":
156
- reject("symbol");
157
- break;
158
338
  case "bigint":
159
- reject("bigint");
339
+ reject(typeof value);
160
340
  break;
161
341
  case "number":
162
342
  if (!Number.isFinite(value)) reject(String(value));
@@ -166,9 +346,7 @@ export function assertJsonSafe(value: unknown, path: string): void {
166
346
  case "undefined":
167
347
  return;
168
348
  }
169
-
170
349
  if (value === null) return;
171
-
172
350
  if (Array.isArray(value)) {
173
351
  value.forEach((element, index) => {
174
352
  if (element === undefined) reject(`undefined at [${index}]`);
@@ -182,9 +360,7 @@ export function assertJsonSafe(value: unknown, path: string): void {
182
360
  if (prototype !== null && prototype !== Object.prototype) {
183
361
  reject(object.constructor?.name ?? "non-plain object");
184
362
  }
185
-
186
363
  for (const [key, property] of Object.entries(object as Record<string, unknown>)) {
187
- if (property === undefined) continue;
188
- assertJsonSafe(property, `${path}.${key}`);
364
+ if (property !== undefined) assertJsonSafe(property, `${path}.${key}`);
189
365
  }
190
366
  }
package/src/context.ts CHANGED
@@ -6,17 +6,17 @@ export type CapabilityContext = {
6
6
  notion: Client;
7
7
  };
8
8
 
9
- const FIX_STEPS =
10
- "\n\nTo fix this:\n" +
9
+ const LOCAL_FIX_STEPS =
10
+ "\n\nFor local execution:\n" +
11
11
  "1. Create a personal access token at https://app.notion.com/developers/tokens\n" +
12
- "2. Add NOTION_API_TOKEN=<your-token> to your .env file\n" +
13
- "3. For deployed apps, run: ntn apps env push";
12
+ "2. Add NOTION_API_TOKEN=<your-token> to your .env file\n\n" +
13
+ "Deployed apps receive Notion API credentials automatically. Do not push NOTION_API_TOKEN.";
14
14
 
15
15
  function missingTokenMessage(): string {
16
16
  return (
17
17
  "NOTION_API_TOKEN is not set. " +
18
- "context.notion requires an API token to make requests in workflow capabilities to Notion." +
19
- FIX_STEPS
18
+ "context.notion requires an API token when an app capability runs locally." +
19
+ LOCAL_FIX_STEPS
20
20
  );
21
21
  }
22
22
 
@@ -0,0 +1,25 @@
1
+ import type { DatabaseSchema, DatabaseSchemaOptions } from "./schema.js";
2
+
3
+ export type DatabaseConfiguration<S extends DatabaseSchema = DatabaseSchema> =
4
+ DatabaseSchemaOptions & {
5
+ initialTitle?: string;
6
+ schema: S;
7
+ };
8
+
9
+ export type Database<S extends DatabaseSchema = DatabaseSchema> = {
10
+ _tag: "database";
11
+ key: string;
12
+ config: DatabaseConfiguration<S>;
13
+ };
14
+
15
+ /** Declare a Notion database that a user will attach to this app. */
16
+ export function createDatabase<const S extends DatabaseSchema>(
17
+ key: string,
18
+ config: DatabaseConfiguration<S>,
19
+ ): Database<S> {
20
+ if (typeof key !== "string" || key.length === 0) {
21
+ throw new Error("Database key must be a non-empty string");
22
+ }
23
+
24
+ return { _tag: "database", key, config };
25
+ }
package/src/error.ts CHANGED
@@ -10,3 +10,14 @@ export class ExecutionError extends Error {
10
10
  this.cause = cause;
11
11
  }
12
12
  }
13
+
14
+ /** Tell the platform that an external service asked the sync to slow down. */
15
+ export class RateLimitError extends ExecutionError {
16
+ readonly retryAfter: number | undefined;
17
+
18
+ constructor(options?: { retryAfter?: number }) {
19
+ super("Rate limited by external API");
20
+ this.name = "RateLimitError";
21
+ this.retryAfter = options?.retryAfter;
22
+ }
23
+ }
package/src/manifest.ts CHANGED
@@ -1,5 +1,32 @@
1
- /** The only capability type supported by this SDK. */
2
- export type CapabilityType = "workflow";
1
+ import type { NormalizedSchedule } from "./types.js";
2
+
3
+ /** Identifies the schema used to validate this manifest independently of the SDK package version. */
4
+ export const APPS_MANIFEST_SCHEMA = "notion:apps-manifest:v1" as const;
5
+
6
+ /** Capability types supported by this SDK. */
7
+ export type CapabilityType = "workflow" | "sync";
8
+
9
+ export type DatabaseManifestConfig = {
10
+ initialTitle?: string;
11
+ schema: unknown;
12
+ };
13
+
14
+ export type DatabaseManifestEntry = {
15
+ key: string;
16
+ config: DatabaseManifestConfig;
17
+ };
18
+
19
+ export type PacerManifestEntry = {
20
+ key: string;
21
+ config: { allowedRequests: number; intervalMs: number };
22
+ };
23
+
24
+ export type SyncManifestConfig = {
25
+ databaseKey: string;
26
+ primaryKeyProperty: string;
27
+ mode?: "replace" | "incremental";
28
+ schedule?: NormalizedSchedule;
29
+ };
3
30
 
4
31
  /**
5
32
  * One capability in the manifest: its type, its key (derived from its
@@ -17,10 +44,11 @@ export type CapabilityManifestEntry = {
17
44
  * complete, static description of the app.
18
45
  */
19
46
  export type AppManifest = {
47
+ $schema: typeof APPS_MANIFEST_SCHEMA;
20
48
  sdkVersion: string;
21
- /** Reserved platform fields; workflow-only apps always emit empty arrays. */
22
- databases: never[];
49
+ databases: DatabaseManifestEntry[];
50
+ /** Reserved platform field; the Apps SDK does not support XLDB. */
23
51
  xldbs: never[];
24
- pacers: never[];
52
+ pacers: PacerManifestEntry[];
25
53
  capabilities: CapabilityManifestEntry[];
26
54
  };
package/src/pacer.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type PacerConfig = {
2
+ allowedRequests: number;
3
+ intervalMs: number;
4
+ };
5
+
6
+ export type PacerEntry = PacerConfig & { lastScheduledAtMs: number };
7
+
8
+ export type Pacer = {
9
+ _tag: "pacer";
10
+ name: string;
11
+ config: PacerConfig;
12
+ wait(): Promise<void>;
13
+ };
14
+
15
+ const REGISTRY_KEY = Symbol.for("@notionhq/apps/pacer-registry");
16
+ const STATE_KEY = Symbol.for("@notionhq/apps/pacer-state");
17
+
18
+ function registry(): Map<string, Pacer> {
19
+ const holder = globalThis as { [REGISTRY_KEY]?: Map<string, Pacer> };
20
+ holder[REGISTRY_KEY] ??= new Map();
21
+ return holder[REGISTRY_KEY];
22
+ }
23
+
24
+ function state(): Map<string, PacerEntry> {
25
+ const holder = globalThis as { [STATE_KEY]?: Map<string, PacerEntry> };
26
+ holder[STATE_KEY] ??= new Map();
27
+ return holder[STATE_KEY];
28
+ }
29
+
30
+ export function createPacer(name: string, config: PacerConfig): Pacer {
31
+ if (typeof name !== "string" || name.length === 0) {
32
+ throw new Error("Pacer name must be a non-empty string");
33
+ }
34
+ if (!Number.isFinite(config.allowedRequests) || config.allowedRequests <= 0) {
35
+ throw new Error("Pacer allowedRequests must be greater than zero");
36
+ }
37
+ if (!Number.isFinite(config.intervalMs) || config.intervalMs <= 0) {
38
+ throw new Error("Pacer intervalMs must be greater than zero");
39
+ }
40
+ if (registry().has(name)) {
41
+ throw new Error(`Two distinct pacers are named "${name}"`);
42
+ }
43
+
44
+ const pacer: Pacer = {
45
+ _tag: "pacer",
46
+ name,
47
+ config,
48
+ async wait() {
49
+ const entry = state().get(name) ?? { ...config, lastScheduledAtMs: 0 };
50
+ const stepMs = Math.ceil(entry.intervalMs / entry.allowedRequests);
51
+ const now = Date.now();
52
+ const scheduledAtMs = Math.max(entry.lastScheduledAtMs + stepMs, now);
53
+ state().set(name, { ...entry, lastScheduledAtMs: scheduledAtMs });
54
+ if (scheduledAtMs > now) {
55
+ await new Promise((resolve) => setTimeout(resolve, scheduledAtMs - now));
56
+ }
57
+ },
58
+ };
59
+ registry().set(name, pacer);
60
+ return pacer;
61
+ }
62
+
63
+ /** @internal */
64
+ export function initPacerState(entries: Record<string, PacerEntry> | undefined): void {
65
+ state().clear();
66
+ for (const [name, entry] of Object.entries(entries ?? {})) state().set(name, entry);
67
+ }
68
+
69
+ /** @internal */
70
+ export function pacerStates(): Record<string, PacerEntry> {
71
+ return Object.fromEntries(state());
72
+ }
73
+
74
+ /** @internal */
75
+ export function registeredPacers(): Pacer[] {
76
+ return [...registry().values()];
77
+ }
78
+
79
+ /** @internal */
80
+ export function clearRegisteredPacers(): void {
81
+ registry().clear();
82
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { resolveRuntimeInput } from "./runtime-input.js";
8
+
9
+ let inputDirectory: string | undefined;
10
+
11
+ afterEach(async () => {
12
+ vi.unstubAllEnvs();
13
+ if (inputDirectory !== undefined) {
14
+ await rm(inputDirectory, { recursive: true, force: true });
15
+ inputDirectory = undefined;
16
+ }
17
+ });
18
+
19
+ describe("resolveRuntimeInput", () => {
20
+ it("returns the inline input when no payload path is configured", async () => {
21
+ const fallback = { state: { cursor: "inline" } };
22
+
23
+ await expect(resolveRuntimeInput(fallback)).resolves.toBe(fallback);
24
+ });
25
+
26
+ it("reads and parses the configured input payload", async () => {
27
+ const fileInput = { state: { cursor: "file" } };
28
+ const inputPath = await writeInputPayload(JSON.stringify(fileInput));
29
+
30
+ vi.stubEnv("NOTION_INPUT_PAYLOAD_PATH", inputPath);
31
+
32
+ await expect(resolveRuntimeInput({ state: { cursor: "inline" } })).resolves.toEqual(
33
+ fileInput,
34
+ );
35
+ });
36
+
37
+ it("returns the inline input when the configured payload is missing", async () => {
38
+ const fallback = { state: { cursor: "inline" } };
39
+ const directory = await createInputDirectory();
40
+
41
+ vi.stubEnv("NOTION_INPUT_PAYLOAD_PATH", join(directory, "missing.json"));
42
+
43
+ await expect(resolveRuntimeInput(fallback)).resolves.toBe(fallback);
44
+ });
45
+
46
+ it("rejects malformed input payloads", async () => {
47
+ const inputPath = await writeInputPayload("{");
48
+
49
+ vi.stubEnv("NOTION_INPUT_PAYLOAD_PATH", inputPath);
50
+
51
+ await expect(resolveRuntimeInput(undefined)).rejects.toBeInstanceOf(SyntaxError);
52
+ });
53
+ });
54
+
55
+ async function writeInputPayload(contents: string): Promise<string> {
56
+ const directory = await createInputDirectory();
57
+ const inputPath = join(directory, "input.json");
58
+ await writeFile(inputPath, contents);
59
+ return inputPath;
60
+ }
61
+
62
+ async function createInputDirectory(): Promise<string> {
63
+ inputDirectory = await mkdtemp(join(tmpdir(), "notion-apps-input-"));
64
+ return inputDirectory;
65
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ /**
4
+ * Resolve a capability input from the sandbox input payload when configured.
5
+ *
6
+ * The input argument remains the fallback for compatibility with runners
7
+ * that have not adopted the file-backed transport.
8
+ *
9
+ * @internal
10
+ */
11
+ export async function resolveRuntimeInput<T>(fallback: T): Promise<T> {
12
+ const inputPayloadPath = process.env.NOTION_INPUT_PAYLOAD_PATH;
13
+ if (inputPayloadPath === undefined) {
14
+ return fallback;
15
+ }
16
+
17
+ let serializedInput: string;
18
+
19
+ try {
20
+ serializedInput = await readFile(inputPayloadPath, "utf8");
21
+ } catch (error) {
22
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
23
+ return fallback;
24
+ }
25
+
26
+ throw error;
27
+ }
28
+
29
+ return JSON.parse(serializedInput) as T;
30
+ }