@notionhq/apps 0.0.3 → 0.0.6
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 +47 -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 +1 -1
- 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/triggers.generated.d.ts +1 -1
- package/dist/triggers.generated.d.ts.map +1 -1
- package/dist/triggers.generated.js +1 -1
- 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 +1 -1
- 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/triggers.generated.ts +1 -1
- package/src/types.ts +109 -0
- package/src/workflow.test.ts +22 -0
- package/src/workflow.ts +2 -0
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NormalizedSchedule, Schedule, TimeUnit } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
4
|
+
const MAX_INTERVAL_MS = 7 * 24 * 60 * 60_000;
|
|
5
|
+
|
|
6
|
+
export function parseSchedule(schedule: Schedule): NormalizedSchedule {
|
|
7
|
+
if (schedule === "continuous" || schedule === "manual") return { type: schedule };
|
|
8
|
+
|
|
9
|
+
const match = schedule.match(/^(\d+)(m|h|d)$/);
|
|
10
|
+
if (!match?.[1] || !match[2]) {
|
|
11
|
+
throw new Error(`Invalid schedule format: "${schedule}"`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const value = Number.parseInt(match[1], 10);
|
|
15
|
+
const multipliers: Record<TimeUnit, number> = {
|
|
16
|
+
m: 60_000,
|
|
17
|
+
h: 60 * 60_000,
|
|
18
|
+
d: 24 * 60 * 60_000,
|
|
19
|
+
};
|
|
20
|
+
const intervalMs = value * multipliers[match[2] as TimeUnit];
|
|
21
|
+
if (intervalMs < MIN_INTERVAL_MS)
|
|
22
|
+
throw new Error("Schedule interval must be at least 1 minute");
|
|
23
|
+
if (intervalMs > MAX_INTERVAL_MS) throw new Error("Schedule interval must be at most 7 days");
|
|
24
|
+
return { type: "interval", intervalMs };
|
|
25
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Database } from "./database.js";
|
|
2
|
+
import type { DateFormat, Icon, NumberFormat, SelectOption, StatusGroup } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type TitleProperty = { type: "title" };
|
|
5
|
+
export type TextProperty = { type: "text" };
|
|
6
|
+
|
|
7
|
+
export type PropertyConfiguration =
|
|
8
|
+
| TitleProperty
|
|
9
|
+
| TextProperty
|
|
10
|
+
| { type: "url" }
|
|
11
|
+
| { type: "email" }
|
|
12
|
+
| { type: "phone_number" }
|
|
13
|
+
| { type: "checkbox" }
|
|
14
|
+
| { type: "file" }
|
|
15
|
+
| { type: "number"; format?: NumberFormat }
|
|
16
|
+
| { type: "date"; date_format?: DateFormat }
|
|
17
|
+
| { type: "select"; options: SelectOption[] }
|
|
18
|
+
| { type: "multi_select"; options: SelectOption[] }
|
|
19
|
+
| { type: "status"; groups: StatusGroup[] }
|
|
20
|
+
| { type: "people" }
|
|
21
|
+
| { type: "place" }
|
|
22
|
+
| {
|
|
23
|
+
type: "relation";
|
|
24
|
+
database: Database;
|
|
25
|
+
config: { twoWay: false } | { twoWay: true; relatedPropertyName: string };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DatabaseSchema = Record<string, PropertyConfiguration>;
|
|
29
|
+
|
|
30
|
+
export type DatabaseSchemaOptions = {
|
|
31
|
+
databaseIcon?: Icon;
|
|
32
|
+
subItems?: { parentPropertyName: string; childPropertyName: string };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Schema = {
|
|
36
|
+
title: (): TitleProperty => ({ type: "title" }),
|
|
37
|
+
richText: (): TextProperty => ({ type: "text" }),
|
|
38
|
+
url: (): { type: "url" } => ({ type: "url" }),
|
|
39
|
+
email: (): { type: "email" } => ({ type: "email" }),
|
|
40
|
+
phoneNumber: (): { type: "phone_number" } => ({ type: "phone_number" }),
|
|
41
|
+
checkbox: (): { type: "checkbox" } => ({ type: "checkbox" }),
|
|
42
|
+
file: (): { type: "file" } => ({ type: "file" }),
|
|
43
|
+
number: (format?: NumberFormat): { type: "number"; format?: NumberFormat } => ({
|
|
44
|
+
type: "number",
|
|
45
|
+
...(format === undefined ? {} : { format }),
|
|
46
|
+
}),
|
|
47
|
+
date: (date_format?: DateFormat): { type: "date"; date_format?: DateFormat } => ({
|
|
48
|
+
type: "date",
|
|
49
|
+
...(date_format === undefined ? {} : { date_format }),
|
|
50
|
+
}),
|
|
51
|
+
select: (options: SelectOption[]): { type: "select"; options: SelectOption[] } => ({
|
|
52
|
+
type: "select",
|
|
53
|
+
options,
|
|
54
|
+
}),
|
|
55
|
+
multiSelect: (options: SelectOption[]): { type: "multi_select"; options: SelectOption[] } => ({
|
|
56
|
+
type: "multi_select",
|
|
57
|
+
options,
|
|
58
|
+
}),
|
|
59
|
+
status: (config: { groups: StatusGroup[] }): { type: "status"; groups: StatusGroup[] } => ({
|
|
60
|
+
type: "status",
|
|
61
|
+
groups: config.groups,
|
|
62
|
+
}),
|
|
63
|
+
people: (): { type: "people" } => ({ type: "people" }),
|
|
64
|
+
place: (): { type: "place" } => ({ type: "place" }),
|
|
65
|
+
relation: (
|
|
66
|
+
database: Database,
|
|
67
|
+
config?: { twoWay: false } | { twoWay: true; relatedPropertyName: string },
|
|
68
|
+
): Extract<PropertyConfiguration, { type: "relation" }> => ({
|
|
69
|
+
type: "relation",
|
|
70
|
+
database,
|
|
71
|
+
config: config ?? { twoWay: false },
|
|
72
|
+
}),
|
|
73
|
+
};
|
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
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, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { Builder } from "./builder.js";
|
|
8
|
+
import { createDatabase } from "./database.js";
|
|
9
|
+
import { ExecutionError, RateLimitError } from "./error.js";
|
|
10
|
+
import { createPacer } from "./pacer.js";
|
|
11
|
+
import { Schema } from "./schema.js";
|
|
12
|
+
import { createSync, type KeyPropertyName } from "./sync.js";
|
|
13
|
+
|
|
14
|
+
const issues = createDatabase("issues", {
|
|
15
|
+
schema: {
|
|
16
|
+
Name: Schema.title(),
|
|
17
|
+
"GitHub ID": Schema.richText(),
|
|
18
|
+
Closed: Schema.checkbox(),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const projects = createDatabase("projects", {
|
|
23
|
+
schema: {
|
|
24
|
+
Name: Schema.title(),
|
|
25
|
+
"Project ID": Schema.richText(),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const keyTypes = createDatabase("key-types", {
|
|
30
|
+
schema: {
|
|
31
|
+
Name: Schema.title(),
|
|
32
|
+
ID: Schema.richText(),
|
|
33
|
+
Closed: Schema.checkbox(),
|
|
34
|
+
Count: Schema.number(),
|
|
35
|
+
Due: Schema.date(),
|
|
36
|
+
Project: Schema.relation(projects),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
type IssueKey = KeyPropertyName<typeof issues.config.schema>;
|
|
41
|
+
const textKey: IssueKey = "GitHub ID";
|
|
42
|
+
const titleKey: IssueKey = "Name";
|
|
43
|
+
// @ts-expect-error Checkboxes cannot be sync primary keys.
|
|
44
|
+
const checkboxKey: IssueKey = "Closed";
|
|
45
|
+
// @ts-expect-error The primary key must exist in the schema.
|
|
46
|
+
const missingKey: IssueKey = "Missing";
|
|
47
|
+
void [textKey, titleKey, checkboxKey, missingKey];
|
|
48
|
+
|
|
49
|
+
createSync({
|
|
50
|
+
database: keyTypes,
|
|
51
|
+
// @ts-expect-error Checkboxes cannot be sync primary keys.
|
|
52
|
+
primaryKey: "Closed",
|
|
53
|
+
handler: async () => ({ changes: [] }),
|
|
54
|
+
});
|
|
55
|
+
createSync({
|
|
56
|
+
database: keyTypes,
|
|
57
|
+
// @ts-expect-error Numbers cannot be sync primary keys.
|
|
58
|
+
primaryKey: "Count",
|
|
59
|
+
handler: async () => ({ changes: [] }),
|
|
60
|
+
});
|
|
61
|
+
createSync({
|
|
62
|
+
database: keyTypes,
|
|
63
|
+
// @ts-expect-error Dates cannot be sync primary keys.
|
|
64
|
+
primaryKey: "Due",
|
|
65
|
+
handler: async () => ({ changes: [] }),
|
|
66
|
+
});
|
|
67
|
+
createSync({
|
|
68
|
+
database: keyTypes,
|
|
69
|
+
// @ts-expect-error Relations cannot be sync primary keys.
|
|
70
|
+
primaryKey: "Project",
|
|
71
|
+
handler: async () => ({ changes: [] }),
|
|
72
|
+
});
|
|
73
|
+
createSync({
|
|
74
|
+
database: keyTypes,
|
|
75
|
+
// @ts-expect-error The primary key must be present in the database schema.
|
|
76
|
+
primaryKey: "Missing",
|
|
77
|
+
handler: async () => ({ changes: [] }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let stdoutSpy: Mock<typeof process.stdout.write>;
|
|
81
|
+
let inputDirectory: string | undefined;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
stdoutSpy.mockRestore();
|
|
89
|
+
vi.unstubAllEnvs();
|
|
90
|
+
if (inputDirectory !== undefined) {
|
|
91
|
+
await rm(inputDirectory, { recursive: true, force: true });
|
|
92
|
+
inputDirectory = undefined;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("createSync", () => {
|
|
97
|
+
it("stores its primary key on the sync config", () => {
|
|
98
|
+
const sync = createSync({
|
|
99
|
+
database: issues,
|
|
100
|
+
primaryKey: "GitHub ID",
|
|
101
|
+
mode: "replace",
|
|
102
|
+
schedule: "1h",
|
|
103
|
+
handler: async () => ({ changes: [] }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(sync.config).toEqual({
|
|
107
|
+
database: issues,
|
|
108
|
+
primaryKeyProperty: "GitHub ID",
|
|
109
|
+
mode: "replace",
|
|
110
|
+
schedule: { type: "interval", intervalMs: 3_600_000 },
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("accepts title and rich-text primary keys", () => {
|
|
115
|
+
expect(
|
|
116
|
+
createSync({
|
|
117
|
+
database: issues,
|
|
118
|
+
primaryKey: "Name",
|
|
119
|
+
handler: async () => ({ changes: [] }),
|
|
120
|
+
}).config.primaryKeyProperty,
|
|
121
|
+
).toBe("Name");
|
|
122
|
+
expect(
|
|
123
|
+
createSync({
|
|
124
|
+
database: issues,
|
|
125
|
+
primaryKey: "GitHub ID",
|
|
126
|
+
handler: async () => ({ changes: [] }),
|
|
127
|
+
}).config.primaryKeyProperty,
|
|
128
|
+
).toBe("GitHub ID");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects invalid schedules when the sync is created", () => {
|
|
132
|
+
expect(() =>
|
|
133
|
+
createSync({
|
|
134
|
+
database: issues,
|
|
135
|
+
primaryKey: "GitHub ID",
|
|
136
|
+
schedule: "30d",
|
|
137
|
+
handler: async () => ({ changes: [] }),
|
|
138
|
+
}),
|
|
139
|
+
).toThrow(/at most 7 days/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("writes the key property and replaces an unsafe handler value", async () => {
|
|
143
|
+
const sync = createSync({
|
|
144
|
+
database: issues,
|
|
145
|
+
primaryKey: "GitHub ID",
|
|
146
|
+
handler: async () => ({
|
|
147
|
+
changes: [
|
|
148
|
+
{
|
|
149
|
+
type: "upsert",
|
|
150
|
+
key: "issue-123",
|
|
151
|
+
properties: {
|
|
152
|
+
Name: Builder.title("Fix the bug"),
|
|
153
|
+
Closed: Builder.checkbox(false),
|
|
154
|
+
...({ "GitHub ID": Builder.richText("wrong") } as Record<
|
|
155
|
+
string,
|
|
156
|
+
unknown
|
|
157
|
+
>),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = await sync.handler(undefined, { concreteOutput: true });
|
|
165
|
+
|
|
166
|
+
expect(result.changes).toEqual([
|
|
167
|
+
{
|
|
168
|
+
type: "upsert",
|
|
169
|
+
key: "issue-123",
|
|
170
|
+
properties: {
|
|
171
|
+
Name: [["Fix the bug"]],
|
|
172
|
+
Closed: [["No"]],
|
|
173
|
+
"GitHub ID": [["issue-123"]],
|
|
174
|
+
},
|
|
175
|
+
targetDatabaseKey: "issues",
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("passes deletes, paging state, and incremental mode through", async () => {
|
|
181
|
+
const sync = createSync({
|
|
182
|
+
database: issues,
|
|
183
|
+
primaryKey: "GitHub ID",
|
|
184
|
+
mode: "incremental",
|
|
185
|
+
handler: async (state: { cursor: string } | undefined) => ({
|
|
186
|
+
changes: [{ type: "delete", key: "issue-123" }],
|
|
187
|
+
hasMore: true,
|
|
188
|
+
nextState: { cursor: `${state?.cursor ?? ""}next` },
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const result = await sync.handler({ state: { cursor: "page-" } }, { concreteOutput: true });
|
|
193
|
+
|
|
194
|
+
expect(sync.config.mode).toBe("incremental");
|
|
195
|
+
expect(result).toEqual({
|
|
196
|
+
changes: [{ type: "delete", key: "issue-123", targetDatabaseKey: "issues" }],
|
|
197
|
+
hasMore: true,
|
|
198
|
+
nextUserContext: { cursor: "page-next" },
|
|
199
|
+
nextPacerStates: {},
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("uses file-backed runtime state instead of inline state", async () => {
|
|
204
|
+
const handler = vi.fn(async (state: { cursor: string } | undefined) => {
|
|
205
|
+
if (state === undefined) throw new Error("Expected sync state");
|
|
206
|
+
return { changes: [], nextState: state };
|
|
207
|
+
});
|
|
208
|
+
const sync = createSync({
|
|
209
|
+
database: issues,
|
|
210
|
+
primaryKey: "GitHub ID",
|
|
211
|
+
handler,
|
|
212
|
+
});
|
|
213
|
+
inputDirectory = await mkdtemp(join(tmpdir(), "notion-apps-sync-input-"));
|
|
214
|
+
const inputPath = join(inputDirectory, "input.json");
|
|
215
|
+
await writeFile(inputPath, JSON.stringify({ state: { cursor: "file" } }));
|
|
216
|
+
vi.stubEnv("NOTION_INPUT_PAYLOAD_PATH", inputPath);
|
|
217
|
+
|
|
218
|
+
const result = await sync.handler(
|
|
219
|
+
{ state: { cursor: "inline" } },
|
|
220
|
+
{ concreteOutput: true },
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(handler).toHaveBeenCalledWith({ cursor: "file" }, expect.anything());
|
|
224
|
+
expect(result.nextUserContext).toEqual({ cursor: "file" });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("passes relations, icons, covers, and page content through", async () => {
|
|
228
|
+
const tasks = createDatabase("tasks", {
|
|
229
|
+
schema: {
|
|
230
|
+
Name: Schema.title(),
|
|
231
|
+
ID: Schema.richText(),
|
|
232
|
+
Project: Schema.relation(projects),
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const sync = createSync({
|
|
236
|
+
database: tasks,
|
|
237
|
+
primaryKey: "ID",
|
|
238
|
+
handler: async () => ({
|
|
239
|
+
changes: [
|
|
240
|
+
{
|
|
241
|
+
type: "upsert",
|
|
242
|
+
key: "task-1",
|
|
243
|
+
properties: {
|
|
244
|
+
Name: Builder.title("Write tests"),
|
|
245
|
+
Project: [Builder.relation("project-1")],
|
|
246
|
+
},
|
|
247
|
+
icon: Builder.emojiIcon("✅"),
|
|
248
|
+
cover: Builder.imageCover("https://example.com/cover.png"),
|
|
249
|
+
pageContentMarkdown: "# Details",
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const result = await sync.handler(undefined, { concreteOutput: true });
|
|
256
|
+
|
|
257
|
+
expect(result.changes).toEqual([
|
|
258
|
+
{
|
|
259
|
+
type: "upsert",
|
|
260
|
+
key: "task-1",
|
|
261
|
+
properties: {
|
|
262
|
+
Name: [["Write tests"]],
|
|
263
|
+
ID: [["task-1"]],
|
|
264
|
+
Project: [{ type: "primaryKey", value: "project-1" }],
|
|
265
|
+
},
|
|
266
|
+
icon: { type: "emoji", value: "✅" },
|
|
267
|
+
cover: {
|
|
268
|
+
type: "image",
|
|
269
|
+
url: "https://example.com/cover.png",
|
|
270
|
+
position: 0.5,
|
|
271
|
+
},
|
|
272
|
+
pageContentMarkdown: "# Details",
|
|
273
|
+
targetDatabaseKey: "tasks",
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("returns the result and writes a success envelope", async () => {
|
|
279
|
+
const sync = createSync({
|
|
280
|
+
database: issues,
|
|
281
|
+
primaryKey: "GitHub ID",
|
|
282
|
+
handler: async () => ({ changes: [] }),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await expect(sync.handler()).resolves.toEqual({
|
|
286
|
+
changes: [],
|
|
287
|
+
hasMore: false,
|
|
288
|
+
nextUserContext: undefined,
|
|
289
|
+
nextPacerStates: {},
|
|
290
|
+
});
|
|
291
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
292
|
+
'\n<__notion_output__>{"_tag":"success","value":{"changes":[],"hasMore":false,"nextPacerStates":{}}}</__notion_output__>\n',
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("threads platform pacer state through the handler", async () => {
|
|
297
|
+
const pacer = createPacer("sync-test-pacer", {
|
|
298
|
+
allowedRequests: 10,
|
|
299
|
+
intervalMs: 1_000,
|
|
300
|
+
});
|
|
301
|
+
const sync = createSync({
|
|
302
|
+
database: issues,
|
|
303
|
+
primaryKey: "GitHub ID",
|
|
304
|
+
handler: async () => {
|
|
305
|
+
await pacer.wait();
|
|
306
|
+
return { changes: [] };
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const result = await sync.handler(
|
|
311
|
+
{
|
|
312
|
+
pacers: {
|
|
313
|
+
[pacer.name]: {
|
|
314
|
+
lastScheduledAtMs: 0,
|
|
315
|
+
allowedRequests: 5,
|
|
316
|
+
intervalMs: 500,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{ concreteOutput: true },
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
expect(result.nextPacerStates[pacer.name]).toMatchObject({
|
|
324
|
+
allowedRequests: 5,
|
|
325
|
+
intervalMs: 500,
|
|
326
|
+
lastScheduledAtMs: expect.any(Number),
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("writes a rate-limit envelope and rethrows the rate-limit error", async () => {
|
|
331
|
+
const sync = createSync({
|
|
332
|
+
database: issues,
|
|
333
|
+
primaryKey: "GitHub ID",
|
|
334
|
+
handler: async () => {
|
|
335
|
+
throw new RateLimitError({ retryAfter: 30 });
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await expect(sync.handler()).rejects.toBeInstanceOf(RateLimitError);
|
|
340
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
341
|
+
'\n<__notion_output__>{"_tag":"error","error":{"_tag":"rate_limit","name":"RateLimitError","message":"Error during app execution: Rate limited by external API","retryAfter":30}}</__notion_output__>\n',
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("writes a generic error envelope and throws ExecutionError", async () => {
|
|
346
|
+
const sync = createSync({
|
|
347
|
+
database: issues,
|
|
348
|
+
primaryKey: "GitHub ID",
|
|
349
|
+
handler: async () => {
|
|
350
|
+
throw new Error("boom");
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await expect(sync.handler()).rejects.toBeInstanceOf(ExecutionError);
|
|
355
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
356
|
+
'\n<__notion_output__>{"_tag":"error","error":{"_tag":"generic","name":"ExecutionError","message":"Error during app execution: Error: boom"}}</__notion_output__>\n',
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
});
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { CapabilityContext } from "./context.js";
|
|
2
|
+
import { createCapabilityContext } from "./context.js";
|
|
3
|
+
import type { Database } from "./database.js";
|
|
4
|
+
import { ExecutionError, RateLimitError } from "./error.js";
|
|
5
|
+
import { writeOutput } from "./output.js";
|
|
6
|
+
import { initPacerState, pacerStates, type PacerEntry } from "./pacer.js";
|
|
7
|
+
import { resolveRuntimeInput } from "./runtime-input.js";
|
|
8
|
+
import { parseSchedule } from "./schedule.js";
|
|
9
|
+
import type {
|
|
10
|
+
DatabaseSchema,
|
|
11
|
+
PropertyConfiguration,
|
|
12
|
+
TextProperty,
|
|
13
|
+
TitleProperty,
|
|
14
|
+
} from "./schema.js";
|
|
15
|
+
import type {
|
|
16
|
+
Cover,
|
|
17
|
+
HandlerOptions,
|
|
18
|
+
Icon,
|
|
19
|
+
NormalizedSchedule,
|
|
20
|
+
PeopleValue,
|
|
21
|
+
PlaceValue,
|
|
22
|
+
RelationValue,
|
|
23
|
+
Schedule,
|
|
24
|
+
TextValue,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
type KeyProperty = TitleProperty | TextProperty;
|
|
28
|
+
|
|
29
|
+
/** Names of title or rich-text properties in a database schema. */
|
|
30
|
+
export type KeyPropertyName<S extends DatabaseSchema> = Extract<
|
|
31
|
+
{
|
|
32
|
+
[P in keyof S]: S[P] extends KeyProperty ? P : never;
|
|
33
|
+
}[keyof S],
|
|
34
|
+
string
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
type PropertyValue<T extends PropertyConfiguration> = T extends { type: "people" }
|
|
38
|
+
? PeopleValue
|
|
39
|
+
: T extends { type: "place" }
|
|
40
|
+
? PlaceValue
|
|
41
|
+
: T extends { type: "relation" }
|
|
42
|
+
? RelationValue
|
|
43
|
+
: TextValue;
|
|
44
|
+
|
|
45
|
+
export type SyncMode = "replace" | "incremental";
|
|
46
|
+
|
|
47
|
+
export type SyncChangeUpsert<S extends DatabaseSchema, PrimaryKey extends KeyPropertyName<S>> = {
|
|
48
|
+
type: "upsert";
|
|
49
|
+
key: string;
|
|
50
|
+
properties: Omit<{ [P in keyof S]: PropertyValue<S[P]> }, PrimaryKey>;
|
|
51
|
+
upstreamUpdatedAt?: string;
|
|
52
|
+
icon?: Icon;
|
|
53
|
+
cover?: Cover;
|
|
54
|
+
pageContentMarkdown?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type SyncChangeDelete = { type: "delete"; key: string };
|
|
58
|
+
|
|
59
|
+
export type SyncChange<S extends DatabaseSchema, PrimaryKey extends KeyPropertyName<S>> =
|
|
60
|
+
| SyncChangeUpsert<S, PrimaryKey>
|
|
61
|
+
| SyncChangeDelete;
|
|
62
|
+
|
|
63
|
+
export type SyncHandlerResult<
|
|
64
|
+
S extends DatabaseSchema,
|
|
65
|
+
PrimaryKey extends KeyPropertyName<S>,
|
|
66
|
+
State,
|
|
67
|
+
> = {
|
|
68
|
+
changes: SyncChange<S, PrimaryKey>[];
|
|
69
|
+
hasMore?: boolean;
|
|
70
|
+
nextState?: State;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SyncConfiguration<
|
|
74
|
+
S extends DatabaseSchema,
|
|
75
|
+
PrimaryKey extends KeyPropertyName<S>,
|
|
76
|
+
State,
|
|
77
|
+
> = {
|
|
78
|
+
database: Database<S>;
|
|
79
|
+
primaryKey: PrimaryKey;
|
|
80
|
+
mode?: SyncMode;
|
|
81
|
+
schedule?: Schedule;
|
|
82
|
+
handler: (
|
|
83
|
+
state: State | undefined,
|
|
84
|
+
context: CapabilityContext,
|
|
85
|
+
) => Promise<SyncHandlerResult<S, PrimaryKey, State>>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type SyncRuntimeContext = {
|
|
89
|
+
state?: unknown;
|
|
90
|
+
userContext?: unknown;
|
|
91
|
+
pacers?: Record<string, PacerEntry>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type SyncDefinitionConfig = {
|
|
95
|
+
database: Database;
|
|
96
|
+
primaryKeyProperty: string;
|
|
97
|
+
mode?: SyncMode;
|
|
98
|
+
schedule?: NormalizedSchedule;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SyncRunResult = {
|
|
102
|
+
changes: unknown[];
|
|
103
|
+
hasMore: boolean;
|
|
104
|
+
nextUserContext?: unknown;
|
|
105
|
+
nextPacerStates: Record<string, PacerEntry>;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type Sync = {
|
|
109
|
+
_tag: "sync";
|
|
110
|
+
config: SyncDefinitionConfig;
|
|
111
|
+
handler: (context?: SyncRuntimeContext, options?: HandlerOptions) => Promise<SyncRunResult>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Create a database sync. Default-export it from a file in `src/syncs/`. */
|
|
115
|
+
export function createSync<
|
|
116
|
+
const S extends DatabaseSchema,
|
|
117
|
+
const PrimaryKey extends KeyPropertyName<S>,
|
|
118
|
+
State = unknown,
|
|
119
|
+
>(configuration: SyncConfiguration<S, PrimaryKey, State>): Sync {
|
|
120
|
+
const schedule =
|
|
121
|
+
configuration.schedule === undefined ? undefined : parseSchedule(configuration.schedule);
|
|
122
|
+
const config: SyncDefinitionConfig = {
|
|
123
|
+
database: configuration.database,
|
|
124
|
+
primaryKeyProperty: configuration.primaryKey,
|
|
125
|
+
...(configuration.mode === undefined ? {} : { mode: configuration.mode }),
|
|
126
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
_tag: "sync",
|
|
131
|
+
config,
|
|
132
|
+
async handler(runtimeContext, options) {
|
|
133
|
+
try {
|
|
134
|
+
runtimeContext = await resolveRuntimeInput(runtimeContext);
|
|
135
|
+
initPacerState(runtimeContext?.pacers);
|
|
136
|
+
const state = (runtimeContext?.state ?? runtimeContext?.userContext) as
|
|
137
|
+
| State
|
|
138
|
+
| undefined;
|
|
139
|
+
const handlerResult = await configuration.handler(state, createCapabilityContext());
|
|
140
|
+
const changes = handlerResult.changes.map((change) => {
|
|
141
|
+
if (change.type === "delete") {
|
|
142
|
+
return { ...change, targetDatabaseKey: configuration.database.key };
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...change,
|
|
146
|
+
properties: {
|
|
147
|
+
...change.properties,
|
|
148
|
+
[configuration.primaryKey]: [[change.key]],
|
|
149
|
+
},
|
|
150
|
+
targetDatabaseKey: configuration.database.key,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
const result: SyncRunResult = {
|
|
154
|
+
changes,
|
|
155
|
+
hasMore: handlerResult.hasMore ?? false,
|
|
156
|
+
nextUserContext: handlerResult.nextState,
|
|
157
|
+
nextPacerStates: pacerStates(),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (options?.concreteOutput) return result;
|
|
161
|
+
writeOutput({ _tag: "success", value: result });
|
|
162
|
+
return result;
|
|
163
|
+
} catch (cause) {
|
|
164
|
+
if (cause instanceof RateLimitError) {
|
|
165
|
+
if (!options?.concreteOutput) {
|
|
166
|
+
writeOutput({
|
|
167
|
+
_tag: "error",
|
|
168
|
+
error: {
|
|
169
|
+
_tag: "rate_limit",
|
|
170
|
+
name: cause.name,
|
|
171
|
+
message: cause.message,
|
|
172
|
+
retryAfter: cause.retryAfter,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
throw cause;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const error = new ExecutionError(cause);
|
|
180
|
+
if (!options?.concreteOutput) {
|
|
181
|
+
writeOutput({
|
|
182
|
+
_tag: "error",
|
|
183
|
+
error: { _tag: "generic", name: error.name, message: error.message },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|