@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.
- package/README.md +53 -2
- package/dist/builder.d.ts +29 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +86 -0
- package/dist/builder.test.d.ts +2 -0
- package/dist/builder.test.d.ts.map +1 -0
- package/dist/cli/build.js +1 -1
- package/dist/cli/discover.d.ts +2 -2
- package/dist/cli/discover.d.ts.map +1 -1
- package/dist/cli/discover.js +2 -1
- package/dist/cli/emit-manifest.d.ts +2 -2
- package/dist/cli/emit-manifest.d.ts.map +1 -1
- package/dist/cli/emit-manifest.js +177 -27
- package/dist/context.js +2 -2
- package/dist/database.d.ts +13 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/database.js +9 -0
- package/dist/error.d.ts +7 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +10 -1
- package/dist/manifest.d.ts +30 -5
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +4 -0
- package/dist/pacer.d.ts +15 -0
- package/dist/pacer.d.ts.map +1 -0
- package/dist/pacer.js +63 -0
- package/dist/runtime-input.d.ts +2 -0
- package/dist/runtime-input.d.ts.map +1 -0
- package/dist/runtime-input.js +20 -0
- package/dist/runtime-input.test.d.ts +2 -0
- package/dist/runtime-input.test.d.ts.map +1 -0
- package/dist/schedule.d.ts +3 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +23 -0
- package/dist/schema.d.ts +111 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +39 -0
- package/dist/sync.d.ts +72 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +75 -0
- package/dist/sync.test.d.ts +2 -0
- package/dist/sync.test.d.ts.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +2 -0
- package/docs/BUILD.md +1 -0
- package/package.json +33 -2
- package/src/builder.test.ts +81 -0
- package/src/builder.ts +99 -0
- package/src/cli/build.test.ts +109 -0
- package/src/cli/build.ts +1 -1
- package/src/cli/codegen.test.ts +15 -0
- package/src/cli/discover.test.ts +13 -2
- package/src/cli/discover.ts +3 -2
- package/src/cli/emit-manifest.ts +229 -53
- package/src/context.ts +6 -6
- package/src/database.ts +25 -0
- package/src/error.ts +11 -0
- package/src/manifest.ts +33 -5
- package/src/pacer.ts +82 -0
- package/src/runtime-input.test.ts +65 -0
- package/src/runtime-input.ts +30 -0
- package/src/schedule.ts +25 -0
- package/src/schema.ts +73 -0
- package/src/sync.test.ts +359 -0
- package/src/sync.ts +190 -0
- package/src/types.ts +109 -0
- package/src/workflow.test.ts +22 -0
- package/src/workflow.ts +2 -0
package/src/cli/emit-manifest.ts
CHANGED
|
@@ -3,13 +3,14 @@ import { pathToFileURL } from "node:url";
|
|
|
3
3
|
|
|
4
4
|
import { Ajv } from "ajv";
|
|
5
5
|
|
|
6
|
-
import
|
|
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: {
|
|
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: {
|
|
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",
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
65
|
-
|
|
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
|
|
70
|
-
`files under
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
104
|
-
`
|
|
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
|
|
127
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
10
|
-
"\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
|
-
"
|
|
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
|
|
19
|
-
|
|
18
|
+
"context.notion requires an API token when an app capability runs locally." +
|
|
19
|
+
LOCAL_FIX_STEPS
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
package/src/database.ts
ADDED
|
@@ -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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
22
|
-
|
|
49
|
+
databases: DatabaseManifestEntry[];
|
|
50
|
+
/** Reserved platform field; the Apps SDK does not support XLDB. */
|
|
23
51
|
xldbs: never[];
|
|
24
|
-
pacers:
|
|
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
|
+
}
|