@project-ajax/create 0.0.28 → 0.0.31
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/dist/index.js +0 -2
- package/package.json +1 -1
- package/template/.examples/automation-example.ts +2 -7
- package/template/.examples/sync-example.ts +38 -1
- package/template/.examples/tool-example.ts +1 -1
- package/template/AGENTS.md +67 -41
- package/template/CLAUDE.md +178 -0
- package/template/README.md +53 -25
- package/template/src/index.ts +65 -7
package/dist/index.js
CHANGED
|
@@ -94,14 +94,12 @@ function printNextSteps(directoryName) {
|
|
|
94
94
|
if (directoryName === ".") {
|
|
95
95
|
console.log(`
|
|
96
96
|
${chalk.bold("npm install")}
|
|
97
|
-
${chalk.bold("npx workers auth login")}
|
|
98
97
|
${chalk.bold("npx workers deploy")}
|
|
99
98
|
`);
|
|
100
99
|
} else {
|
|
101
100
|
console.log(`
|
|
102
101
|
${chalk.bold(`cd ${directoryName}`)}
|
|
103
102
|
${chalk.bold("npm install")}
|
|
104
|
-
${chalk.bold("npx workers auth login")}
|
|
105
103
|
${chalk.bold("npx workers deploy")}
|
|
106
104
|
`);
|
|
107
105
|
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import { Client } from "@notionhq/client";
|
|
2
1
|
import { Worker } from "@project-ajax/sdk";
|
|
3
2
|
|
|
4
3
|
const worker = new Worker();
|
|
5
4
|
export default worker;
|
|
6
5
|
|
|
7
|
-
// Initialize the Notion client with OAuth token from environment
|
|
8
|
-
const notion = new Client({
|
|
9
|
-
auth: process.env.NOTION_API_TOKEN,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
6
|
type RichTextProperty = {
|
|
13
7
|
type: "rich_text";
|
|
14
8
|
rich_text: Array<{ plain_text: string }>;
|
|
@@ -26,7 +20,8 @@ worker.automation("questionAnswerAutomation", {
|
|
|
26
20
|
title: "Question Answer Automation",
|
|
27
21
|
description:
|
|
28
22
|
"Reads questions from database pages and updates them with answers",
|
|
29
|
-
execute: async ({
|
|
23
|
+
execute: async (event, { notion }) => {
|
|
24
|
+
const { pageId, pageData } = event;
|
|
30
25
|
// Extract email from the page dat
|
|
31
26
|
const emailProperty = pageData?.properties?.Email as
|
|
32
27
|
| RichTextProperty
|
|
@@ -5,6 +5,41 @@ import * as Schema from "@project-ajax/sdk/schema";
|
|
|
5
5
|
const worker = new Worker();
|
|
6
6
|
export default worker;
|
|
7
7
|
|
|
8
|
+
const projectId = "project-1";
|
|
9
|
+
const projectName = "Example Project";
|
|
10
|
+
|
|
11
|
+
worker.sync("projectsSync", {
|
|
12
|
+
// Which field to use in each object as the primary key. Must be unique.
|
|
13
|
+
primaryKeyProperty: "Project ID",
|
|
14
|
+
// The schema of the collection to create in Notion.
|
|
15
|
+
schema: {
|
|
16
|
+
// Name of the collection to create in Notion.
|
|
17
|
+
defaultName: "Projects",
|
|
18
|
+
properties: {
|
|
19
|
+
// See `Schema` for the full list of possible column types.
|
|
20
|
+
"Project Name": Schema.title(),
|
|
21
|
+
"Project ID": Schema.richText(),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
execute: async () => {
|
|
25
|
+
// Fetch and return data
|
|
26
|
+
return {
|
|
27
|
+
changes: [
|
|
28
|
+
// Each change must match the shape of `properties` above.
|
|
29
|
+
{
|
|
30
|
+
type: "upsert" as const,
|
|
31
|
+
key: projectId,
|
|
32
|
+
properties: {
|
|
33
|
+
"Project Name": Builder.title(projectName),
|
|
34
|
+
"Project ID": Builder.richText(projectId),
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
hasMore: false,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
8
43
|
worker.sync("mySync", {
|
|
9
44
|
// Which field to use in each object as the primary key. Must be unique.
|
|
10
45
|
primaryKeyProperty: "ID",
|
|
@@ -16,9 +51,10 @@ worker.sync("mySync", {
|
|
|
16
51
|
// See `Schema` for the full list of possible column types.
|
|
17
52
|
Title: Schema.title(),
|
|
18
53
|
ID: Schema.richText(),
|
|
54
|
+
Project: Schema.relation("projectsSync"),
|
|
19
55
|
},
|
|
20
56
|
},
|
|
21
|
-
execute: async () => {
|
|
57
|
+
execute: async (_state, { notion: _notion }) => {
|
|
22
58
|
// Fetch and return data
|
|
23
59
|
return {
|
|
24
60
|
changes: [
|
|
@@ -29,6 +65,7 @@ worker.sync("mySync", {
|
|
|
29
65
|
properties: {
|
|
30
66
|
Title: Builder.title("Item 1"),
|
|
31
67
|
ID: Builder.richText("1"),
|
|
68
|
+
Project: [Builder.relation(projectId)],
|
|
32
69
|
},
|
|
33
70
|
},
|
|
34
71
|
],
|
|
@@ -41,7 +41,7 @@ worker.tool<
|
|
|
41
41
|
additionalProperties: false,
|
|
42
42
|
},
|
|
43
43
|
// The function that executes when the tool is called
|
|
44
|
-
execute: async (input) => {
|
|
44
|
+
execute: async (input, { notion: _notion }) => {
|
|
45
45
|
// Destructure input with default values
|
|
46
46
|
const { query: _query, limit: _limit = 10 } = input;
|
|
47
47
|
|
package/template/AGENTS.md
CHANGED
|
@@ -20,7 +20,7 @@ export default worker;
|
|
|
20
20
|
worker.sync("tasksSync", {
|
|
21
21
|
primaryKeyProperty: "ID",
|
|
22
22
|
schema: { defaultName: "Tasks", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
23
|
-
execute: async () => ({
|
|
23
|
+
execute: async (_state, { notion }) => ({
|
|
24
24
|
changes: [{ type: "upsert", key: "1", properties: { Name: Builder.title("Write docs"), ID: Builder.richText("1") } }],
|
|
25
25
|
hasMore: false,
|
|
26
26
|
}),
|
|
@@ -30,45 +30,50 @@ worker.tool("sayHello", {
|
|
|
30
30
|
title: "Say Hello",
|
|
31
31
|
description: "Return a greeting",
|
|
32
32
|
schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
33
|
-
execute: ({ name }) => `Hello, ${name}`,
|
|
33
|
+
execute: ({ name }, { notion }) => `Hello, ${name}`,
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
worker.automation("sendWelcomeEmail", {
|
|
37
37
|
title: "Send Welcome Email",
|
|
38
38
|
description: "Runs from a database automation",
|
|
39
|
-
execute: async () => {},
|
|
39
|
+
execute: async (event, { notion }) => {},
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
worker.oauth("googleAuth", { name: "my-google-auth", provider: "google" });
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
- All `execute` handlers receive a Notion SDK client in the second argument as `context.notion`.
|
|
46
|
+
|
|
45
47
|
- For user-managed OAuth, supply `name`, `authorizationEndpoint`, `tokenEndpoint`, `clientId`, `clientSecret`, and `scope` (optional: `authorizationParams`, `callbackUrl`, `accessTokenExpireMs`).
|
|
46
48
|
|
|
47
|
-
### Sync
|
|
49
|
+
### Sync
|
|
50
|
+
#### Strategy and Pagination
|
|
48
51
|
|
|
49
52
|
Syncs run in a "sync cycle": a back-to-back chain of `execute` calls that starts at a scheduled trigger and ends when an execution returns `hasMore: false`.
|
|
50
53
|
|
|
54
|
+
- Always use pagination, when available. Returning too many changes in one execution will fail. Start with batch sizes of ~100 changes.
|
|
55
|
+
- `mode=replace` is simpler, and fine for smaller syncs (<10k)
|
|
56
|
+
- Use `mode=incremental` when the sync could return a lot of data (>10k), eg for SaaS tools like Salesforce or Stripe
|
|
57
|
+
- When using `mode=incremental`, emit delete markers as needed if easy to do (below)
|
|
58
|
+
|
|
51
59
|
**Sync strategy (`mode`):**
|
|
52
60
|
- `replace`: each sync cycle must return the full dataset. After the final `hasMore: false`, any records not seen during that cycle are deleted.
|
|
53
|
-
- `incremental`: each sync cycle returns
|
|
54
|
-
|
|
55
|
-
**When to use each:**
|
|
56
|
-
- `replace`: small datasets or cheap full scans. Each cycle returns the full dataset for simplicity.
|
|
57
|
-
- `incremental`: large datasets (e.g., Stripe, Salesforce, Zendesk) where full scans are expensive. First backfill, then return only incremental changes.
|
|
58
|
-
|
|
59
|
-
Implement pagination to avoid exceeding output size limits. Returning too many changes in one execution can cause the output JSON to exceed size limits and fail.
|
|
61
|
+
- `incremental`: each sync cycle returns a subset of the full dataset (usually the changes since the last run). Deletions must be explicit via `{ type: "delete", key: "..." }`. Records not mentioned are left unchanged.
|
|
60
62
|
|
|
61
63
|
**How pagination works:**
|
|
62
|
-
1. Return a batch of changes with `hasMore: true` and a `
|
|
63
|
-
2. The runtime calls `execute` again with that
|
|
64
|
+
1. Return a batch of changes with `hasMore: true` and a `nextState` value
|
|
65
|
+
2. The runtime calls `execute` again with that state
|
|
64
66
|
3. Continue until you return `hasMore: false`
|
|
65
67
|
|
|
68
|
+
**Example replace sync:**
|
|
69
|
+
|
|
66
70
|
```ts
|
|
67
71
|
worker.sync("paginatedSync", {
|
|
72
|
+
mode: "replace",
|
|
68
73
|
primaryKeyProperty: "ID",
|
|
69
74
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
70
|
-
execute: async (
|
|
71
|
-
const page =
|
|
75
|
+
execute: async (state, { notion }) => {
|
|
76
|
+
const page = state?.page ?? 1;
|
|
72
77
|
const pageSize = 100;
|
|
73
78
|
const { items, hasMore } = await fetchPage(page, pageSize);
|
|
74
79
|
return {
|
|
@@ -78,34 +83,13 @@ worker.sync("paginatedSync", {
|
|
|
78
83
|
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
79
84
|
})),
|
|
80
85
|
hasMore,
|
|
81
|
-
|
|
86
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
82
87
|
};
|
|
83
88
|
},
|
|
84
89
|
});
|
|
85
90
|
```
|
|
86
91
|
|
|
87
|
-
**
|
|
88
|
-
|
|
89
|
-
**Replace example (full dataset each cycle, each execute call paginated):**
|
|
90
|
-
```ts
|
|
91
|
-
worker.sync("fullSync", {
|
|
92
|
-
primaryKeyProperty: "ID",
|
|
93
|
-
mode: "replace",
|
|
94
|
-
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
95
|
-
execute: async (context?: { cursor?: string }) => {
|
|
96
|
-
const { items, nextCursor } = await fetchPage(context?.cursor);
|
|
97
|
-
return {
|
|
98
|
-
changes: items.map((item) => ({
|
|
99
|
-
type: "upsert",
|
|
100
|
-
key: item.id,
|
|
101
|
-
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
102
|
-
})),
|
|
103
|
-
hasMore: Boolean(nextCursor),
|
|
104
|
-
nextContext: nextCursor ? { cursor: nextCursor } : undefined,
|
|
105
|
-
};
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
```
|
|
92
|
+
**State types:** The `nextState` can be any serializable value—a cursor string, page number, timestamp, or complex object. Type your execute function's `state` to match.
|
|
109
93
|
|
|
110
94
|
**Incremental example (changes only, with deletes):**
|
|
111
95
|
```ts
|
|
@@ -113,8 +97,8 @@ worker.sync("incrementalSync", {
|
|
|
113
97
|
primaryKeyProperty: "ID",
|
|
114
98
|
mode: "incremental",
|
|
115
99
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
116
|
-
execute: async (
|
|
117
|
-
const { upserts, deletes, nextCursor } = await fetchChanges(
|
|
100
|
+
execute: async (state, { notion }) => {
|
|
101
|
+
const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
|
|
118
102
|
return {
|
|
119
103
|
changes: [
|
|
120
104
|
...upserts.map((item) => ({
|
|
@@ -125,12 +109,54 @@ worker.sync("incrementalSync", {
|
|
|
125
109
|
...deletes.map((id) => ({ type: "delete", key: id })),
|
|
126
110
|
],
|
|
127
111
|
hasMore: Boolean(nextCursor),
|
|
128
|
-
|
|
112
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
129
113
|
};
|
|
130
114
|
},
|
|
131
115
|
});
|
|
132
116
|
```
|
|
133
117
|
|
|
118
|
+
#### Relations
|
|
119
|
+
|
|
120
|
+
Two syncs can relate to one another using `Schema.relation(relatedSyncKey)` and `Builder.relation(primaryKey)` entries inside an array.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
worker.sync("projectsSync", {
|
|
124
|
+
primaryKeyProperty: "Project ID",
|
|
125
|
+
...
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Example sync worker that syncs sample tasks to a database
|
|
129
|
+
worker.sync("tasksSync", {
|
|
130
|
+
primaryKeyProperty: "Task ID",
|
|
131
|
+
...
|
|
132
|
+
schema: {
|
|
133
|
+
...
|
|
134
|
+
properties: {
|
|
135
|
+
...
|
|
136
|
+
Project: Schema.relation("projectsSync"),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
execute: async () => {
|
|
141
|
+
// Return sample tasks as database entries
|
|
142
|
+
const tasks = fetchTasks()
|
|
143
|
+
const changes = tasks.map((task) => ({
|
|
144
|
+
type: "upsert" as const,
|
|
145
|
+
key: task.id,
|
|
146
|
+
properties: {
|
|
147
|
+
...
|
|
148
|
+
Project: [Builder.relation(task.projectId)],
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
changes,
|
|
154
|
+
hasMore: false,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
134
160
|
## Build, Test, and Development Commands
|
|
135
161
|
- Node >= 22 and npm >= 10.9.2 (see `package.json` engines).
|
|
136
162
|
- `npm run dev`: run `src/index.ts` with live reload.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Repository Guidelines
|
|
2
|
+
|
|
3
|
+
## Project Structure & Module Organization
|
|
4
|
+
- `src/index.ts` defines the worker and capabilities.
|
|
5
|
+
- `.examples/` has focused samples (sync, tool, automation, OAuth).
|
|
6
|
+
- Generated: `dist/` build output, `workers.json` CLI config.
|
|
7
|
+
|
|
8
|
+
## Worker & Capability API (SDK)
|
|
9
|
+
- `@project-ajax/sdk` provides `Worker`, schema helpers, and builders; `@project-ajax/cli` powers `npx workers ...`.
|
|
10
|
+
- Capability keys are unique strings used by the CLI (e.g., `npx workers exec tasksSync`).
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Worker } from "@project-ajax/sdk";
|
|
14
|
+
import * as Builder from "@project-ajax/sdk/builder";
|
|
15
|
+
import * as Schema from "@project-ajax/sdk/schema";
|
|
16
|
+
|
|
17
|
+
const worker = new Worker();
|
|
18
|
+
export default worker;
|
|
19
|
+
|
|
20
|
+
worker.sync("tasksSync", {
|
|
21
|
+
primaryKeyProperty: "ID",
|
|
22
|
+
schema: { defaultName: "Tasks", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
23
|
+
execute: async (_state, { notion }) => ({
|
|
24
|
+
changes: [{ type: "upsert", key: "1", properties: { Name: Builder.title("Write docs"), ID: Builder.richText("1") } }],
|
|
25
|
+
hasMore: false,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
worker.tool("sayHello", {
|
|
30
|
+
title: "Say Hello",
|
|
31
|
+
description: "Return a greeting",
|
|
32
|
+
schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
33
|
+
execute: ({ name }, { notion }) => `Hello, ${name}`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
worker.automation("sendWelcomeEmail", {
|
|
37
|
+
title: "Send Welcome Email",
|
|
38
|
+
description: "Runs from a database automation",
|
|
39
|
+
execute: async (event, { notion }) => {},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
worker.oauth("googleAuth", { name: "my-google-auth", provider: "google" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- All `execute` handlers receive a Notion SDK client in the second argument as `context.notion`.
|
|
46
|
+
|
|
47
|
+
- For user-managed OAuth, supply `name`, `authorizationEndpoint`, `tokenEndpoint`, `clientId`, `clientSecret`, and `scope` (optional: `authorizationParams`, `callbackUrl`, `accessTokenExpireMs`).
|
|
48
|
+
|
|
49
|
+
### Sync
|
|
50
|
+
#### Strategy and Pagination
|
|
51
|
+
|
|
52
|
+
Syncs run in a "sync cycle": a back-to-back chain of `execute` calls that starts at a scheduled trigger and ends when an execution returns `hasMore: false`.
|
|
53
|
+
|
|
54
|
+
- Always use pagination, when available. Returning too many changes in one execution will fail. Start with batch sizes of ~100 changes.
|
|
55
|
+
- `mode=replace` is simpler, and fine for smaller syncs (<10k)
|
|
56
|
+
- Use `mode=incremental` when the sync could return a lot of data (>10k), eg for SaaS tools like Salesforce or Stripe
|
|
57
|
+
- When using `mode=incremental`, emit delete markers as needed if easy to do (below)
|
|
58
|
+
|
|
59
|
+
**Sync strategy (`mode`):**
|
|
60
|
+
- `replace`: each sync cycle must return the full dataset. After the final `hasMore: false`, any records not seen during that cycle are deleted.
|
|
61
|
+
- `incremental`: each sync cycle returns a subset of the full dataset (usually the changes since the last run). Deletions must be explicit via `{ type: "delete", key: "..." }`. Records not mentioned are left unchanged.
|
|
62
|
+
|
|
63
|
+
**How pagination works:**
|
|
64
|
+
1. Return a batch of changes with `hasMore: true` and a `nextState` value
|
|
65
|
+
2. The runtime calls `execute` again with that state
|
|
66
|
+
3. Continue until you return `hasMore: false`
|
|
67
|
+
|
|
68
|
+
**Example replace sync:**
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
worker.sync("paginatedSync", {
|
|
72
|
+
mode: "replace",
|
|
73
|
+
primaryKeyProperty: "ID",
|
|
74
|
+
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
75
|
+
execute: async (state, { notion }) => {
|
|
76
|
+
const page = state?.page ?? 1;
|
|
77
|
+
const pageSize = 100;
|
|
78
|
+
const { items, hasMore } = await fetchPage(page, pageSize);
|
|
79
|
+
return {
|
|
80
|
+
changes: items.map((item) => ({
|
|
81
|
+
type: "upsert",
|
|
82
|
+
key: item.id,
|
|
83
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
84
|
+
})),
|
|
85
|
+
hasMore,
|
|
86
|
+
nextState: hasMore ? { page: page + 1 } : undefined,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**State types:** The `nextState` can be any serializable value—a cursor string, page number, timestamp, or complex object. Type your execute function's `state` to match.
|
|
93
|
+
|
|
94
|
+
**Incremental example (changes only, with deletes):**
|
|
95
|
+
```ts
|
|
96
|
+
worker.sync("incrementalSync", {
|
|
97
|
+
primaryKeyProperty: "ID",
|
|
98
|
+
mode: "incremental",
|
|
99
|
+
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
100
|
+
execute: async (state, { notion }) => {
|
|
101
|
+
const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
|
|
102
|
+
return {
|
|
103
|
+
changes: [
|
|
104
|
+
...upserts.map((item) => ({
|
|
105
|
+
type: "upsert",
|
|
106
|
+
key: item.id,
|
|
107
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
108
|
+
})),
|
|
109
|
+
...deletes.map((id) => ({ type: "delete", key: id })),
|
|
110
|
+
],
|
|
111
|
+
hasMore: Boolean(nextCursor),
|
|
112
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
#### Relations
|
|
119
|
+
|
|
120
|
+
Two syncs can relate to one another using `Schema.relation(relatedSyncKey)` and `Builder.relation(primaryKey)` entries inside an array.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
worker.sync("projectsSync", {
|
|
124
|
+
primaryKeyProperty: "Project ID",
|
|
125
|
+
...
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Example sync worker that syncs sample tasks to a database
|
|
129
|
+
worker.sync("tasksSync", {
|
|
130
|
+
primaryKeyProperty: "Task ID",
|
|
131
|
+
...
|
|
132
|
+
schema: {
|
|
133
|
+
...
|
|
134
|
+
properties: {
|
|
135
|
+
...
|
|
136
|
+
Project: Schema.relation("projectsSync"),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
execute: async () => {
|
|
141
|
+
// Return sample tasks as database entries
|
|
142
|
+
const tasks = fetchTasks()
|
|
143
|
+
const changes = tasks.map((task) => ({
|
|
144
|
+
type: "upsert" as const,
|
|
145
|
+
key: task.id,
|
|
146
|
+
properties: {
|
|
147
|
+
...
|
|
148
|
+
Project: [Builder.relation(task.projectId)],
|
|
149
|
+
},
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
changes,
|
|
154
|
+
hasMore: false,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Build, Test, and Development Commands
|
|
161
|
+
- Node >= 22 and npm >= 10.9.2 (see `package.json` engines).
|
|
162
|
+
- `npm run dev`: run `src/index.ts` with live reload.
|
|
163
|
+
- `npm run build`: compile TypeScript to `dist/`.
|
|
164
|
+
- `npm run check`: type-check only (no emit).
|
|
165
|
+
- `npx workers auth login [--env=dev]`: connect to a Notion workspace.
|
|
166
|
+
- `npx workers deploy`: build and publish capabilities.
|
|
167
|
+
- `npx workers exec <capability>`: run a sync or tool.
|
|
168
|
+
|
|
169
|
+
## Coding Style & Naming Conventions
|
|
170
|
+
- TypeScript with `strict` enabled; keep types explicit when shaping I/O.
|
|
171
|
+
- Use tabs for indentation; capability keys in lowerCamelCase.
|
|
172
|
+
|
|
173
|
+
## Testing Guidelines
|
|
174
|
+
- No test runner configured; validate with `npm run check` and a deploy/exec loop.
|
|
175
|
+
|
|
176
|
+
## Commit & Pull Request Guidelines
|
|
177
|
+
- Messages typically use `feat(scope): ...`, `TASK-123: ...`, or version bumps.
|
|
178
|
+
- PRs should describe changes, list commands run, and update examples if behavior changes.
|
package/template/README.md
CHANGED
|
@@ -22,10 +22,9 @@ npm install
|
|
|
22
22
|
Connect to a Notion workspace and deploy the sample worker:
|
|
23
23
|
|
|
24
24
|
```shell
|
|
25
|
-
npx workers auth login
|
|
26
|
-
# or target a specific environment:
|
|
27
|
-
npx workers auth login --env=dev
|
|
28
25
|
npx workers deploy
|
|
26
|
+
# or target a specific environment:
|
|
27
|
+
npx workers deploy --env=dev
|
|
29
28
|
```
|
|
30
29
|
|
|
31
30
|
Run the sample sync to create a database:
|
|
@@ -56,6 +55,8 @@ export default worker;
|
|
|
56
55
|
|
|
57
56
|
Syncs create or update a Notion database from your source data.
|
|
58
57
|
|
|
58
|
+
The most basic sync returns all data that should be copied to the Notion database on each run:
|
|
59
|
+
|
|
59
60
|
```ts
|
|
60
61
|
import * as Builder from "@project-ajax/sdk/builder";
|
|
61
62
|
import * as Schema from "@project-ajax/sdk/schema";
|
|
@@ -72,7 +73,8 @@ worker.sync("tasksSync", {
|
|
|
72
73
|
ID: Schema.richText(),
|
|
73
74
|
},
|
|
74
75
|
},
|
|
75
|
-
execute: async () => ({
|
|
76
|
+
execute: async (_state, { notion }) => ({
|
|
77
|
+
// `notion` is the Notion API SDK client.
|
|
76
78
|
changes: [
|
|
77
79
|
{
|
|
78
80
|
type: "upsert",
|
|
@@ -88,49 +90,49 @@ worker.sync("tasksSync", {
|
|
|
88
90
|
});
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
A sync runs in a "sync cycle": a back-to-back chain of `execute` calls that ends when an execution returns `hasMore: false`.
|
|
93
|
+
Notion will delete stale rows after each run. A stale row is a row that was in the database but that your function did not return.
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
- `replace` (default): each cycle returns the full dataset. After the final `hasMore: false`, any records not seen in that cycle are deleted.
|
|
97
|
-
- `incremental`: each cycle returns only changes since the last run. Deletions must be explicit via `{ type: "delete", key: "..." }`. Records not mentioned are left unchanged.
|
|
95
|
+
#### Write a sync that paginates
|
|
98
96
|
|
|
99
|
-
|
|
100
|
-
- `replace`: small datasets or cheap full scans. Each cycle returns the full dataset for simplicity.
|
|
101
|
-
- `incremental`: large datasets (Stripe, Salesforce, Zendesk) where full scans are expensive. First backfill, then return only changes.
|
|
97
|
+
When your sync is pulling in many rows of data (>1k), you'll want to use pagination. Breaking down pages to ~100 is a good starting point.
|
|
102
98
|
|
|
103
|
-
|
|
99
|
+
You can use `state` to persist things like pagination tokens between `execute` runs. Notion passes `state` as the first argument to `execute`, plus a `context` object that includes the Notion client at `context.notion`. Return `nextState` to set the `state` for the next run:
|
|
104
100
|
|
|
105
|
-
**Replace example (full dataset each cycle, each execute call paginated):**
|
|
106
101
|
```ts
|
|
107
102
|
worker.sync("fullSync", {
|
|
108
103
|
primaryKeyProperty: "ID",
|
|
109
104
|
mode: "replace",
|
|
110
105
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
111
|
-
execute: async (
|
|
112
|
-
const { items, nextCursor } = await fetchPage(
|
|
106
|
+
execute: async (state, { notion }) => {
|
|
107
|
+
const { items , nextCursor } = await fetchPage(state?.page);
|
|
113
108
|
return {
|
|
114
109
|
changes: items.map((item) => ({
|
|
115
110
|
type: "upsert",
|
|
116
111
|
key: item.id,
|
|
117
112
|
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
118
113
|
})),
|
|
119
|
-
hasMore:
|
|
120
|
-
|
|
114
|
+
hasMore: nextCursor ? true : false,
|
|
115
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
121
116
|
};
|
|
122
117
|
},
|
|
123
118
|
});
|
|
124
119
|
```
|
|
125
120
|
|
|
126
|
-
|
|
121
|
+
Return `hasMore=false` for each run until you reach the end. On the last run, return `hasMore=true`. At the start of the next cycle, Notion will start anew and call `execute` with `state` undefined.
|
|
122
|
+
|
|
123
|
+
#### Write a sync that syncs incrementally
|
|
124
|
+
|
|
125
|
+
When your sync is working with a lot of data (10k+), you'll want to use the `incremental` sync mode. With incremental syncs, you can for example backfill all the data from an API into Notion, and then sync only incremental updates from that point forward.
|
|
126
|
+
|
|
127
|
+
Set the sync's `mode` to `incremental` and use pagination as above:
|
|
128
|
+
|
|
127
129
|
```ts
|
|
128
130
|
worker.sync("incrementalSync", {
|
|
129
131
|
primaryKeyProperty: "ID",
|
|
130
132
|
mode: "incremental",
|
|
131
133
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
132
|
-
execute: async (
|
|
133
|
-
const { upserts, deletes, nextCursor } = await fetchChanges(
|
|
134
|
+
execute: async (state, { notion }) => {
|
|
135
|
+
const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
|
|
134
136
|
return {
|
|
135
137
|
changes: [
|
|
136
138
|
...upserts.map((item) => ({
|
|
@@ -141,12 +143,34 @@ worker.sync("incrementalSync", {
|
|
|
141
143
|
...deletes.map((id) => ({ type: "delete", key: id })),
|
|
142
144
|
],
|
|
143
145
|
hasMore: Boolean(nextCursor),
|
|
144
|
-
|
|
146
|
+
nextState: nextCursor ? { cursor: nextCursor } : undefined,
|
|
145
147
|
};
|
|
146
148
|
},
|
|
147
149
|
});
|
|
148
150
|
```
|
|
149
151
|
|
|
152
|
+
Unlike the `replace` sync mode, Notion will not drop "stale" rows and `state` will persist between sync cycles.
|
|
153
|
+
|
|
154
|
+
**Deletes**
|
|
155
|
+
|
|
156
|
+
With incremental syncs, you can delete rows by returning a delete marker, like so:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
changes: [
|
|
160
|
+
// this is an upsert
|
|
161
|
+
{
|
|
162
|
+
type: "upsert",
|
|
163
|
+
key: item.id,
|
|
164
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
165
|
+
},
|
|
166
|
+
// this is a delete
|
|
167
|
+
{
|
|
168
|
+
type: "delete",
|
|
169
|
+
key: item.id
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
```
|
|
173
|
+
|
|
150
174
|
### Tool
|
|
151
175
|
|
|
152
176
|
Tools are callable by Notion custom agents.
|
|
@@ -161,7 +185,7 @@ worker.tool("sayHello", {
|
|
|
161
185
|
required: ["name"],
|
|
162
186
|
additionalProperties: false,
|
|
163
187
|
},
|
|
164
|
-
execute: ({ name }) => `Hello, ${name}`,
|
|
188
|
+
execute: ({ name }, { notion }) => `Hello, ${name}`,
|
|
165
189
|
});
|
|
166
190
|
```
|
|
167
191
|
|
|
@@ -173,7 +197,8 @@ Automations run from Notion database buttons or automations.
|
|
|
173
197
|
worker.automation("sendWelcomeEmail", {
|
|
174
198
|
title: "Send Welcome Email",
|
|
175
199
|
description: "Runs from a database automation",
|
|
176
|
-
execute: async ({
|
|
200
|
+
execute: async (event, { notion }) => {
|
|
201
|
+
const { pageId } = event;
|
|
177
202
|
console.log("Triggered for page", pageId);
|
|
178
203
|
},
|
|
179
204
|
});
|
|
@@ -215,6 +240,9 @@ Log in to Notion (use `--env=dev` for dev):
|
|
|
215
240
|
npx workers auth login --env=dev
|
|
216
241
|
```
|
|
217
242
|
|
|
243
|
+
Login is automatically handled by `npx workers deploy`, so this command is
|
|
244
|
+
typically not needed.
|
|
245
|
+
|
|
218
246
|
### `npx workers auth show`
|
|
219
247
|
Show the active auth token:
|
|
220
248
|
|
package/template/src/index.ts
CHANGED
|
@@ -5,6 +5,9 @@ import * as Schema from "@project-ajax/sdk/schema";
|
|
|
5
5
|
const worker = new Worker();
|
|
6
6
|
export default worker;
|
|
7
7
|
|
|
8
|
+
const projectId = "project-123";
|
|
9
|
+
const projectName = "Project 1";
|
|
10
|
+
|
|
8
11
|
// Sample data for demonstration
|
|
9
12
|
const sampleTasks = [
|
|
10
13
|
{
|
|
@@ -12,21 +15,52 @@ const sampleTasks = [
|
|
|
12
15
|
title: "Welcome to Project Ajax",
|
|
13
16
|
status: "Completed",
|
|
14
17
|
description: "This is a simple hello world example",
|
|
18
|
+
projectId,
|
|
15
19
|
},
|
|
16
20
|
{
|
|
17
21
|
id: "task-2",
|
|
18
22
|
title: "Build your first worker",
|
|
19
23
|
status: "In Progress",
|
|
20
24
|
description: "Create a sync or tool worker",
|
|
25
|
+
projectId,
|
|
21
26
|
},
|
|
22
27
|
{
|
|
23
28
|
id: "task-3",
|
|
24
29
|
title: "Deploy to production",
|
|
25
30
|
status: "Todo",
|
|
26
31
|
description: "Share your worker with your team",
|
|
32
|
+
projectId,
|
|
27
33
|
},
|
|
28
34
|
];
|
|
29
35
|
|
|
36
|
+
worker.sync("projectsSync", {
|
|
37
|
+
primaryKeyProperty: "Project ID",
|
|
38
|
+
schema: {
|
|
39
|
+
defaultName: "Projects",
|
|
40
|
+
databaseIcon: Builder.notionIcon("activity"),
|
|
41
|
+
properties: {
|
|
42
|
+
"Project Name": Schema.title(),
|
|
43
|
+
"Project ID": Schema.richText(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
execute: async () => {
|
|
47
|
+
return {
|
|
48
|
+
changes: [
|
|
49
|
+
{
|
|
50
|
+
type: "upsert" as const,
|
|
51
|
+
key: projectId,
|
|
52
|
+
icon: Builder.notionIcon("activity"),
|
|
53
|
+
properties: {
|
|
54
|
+
"Project Name": Builder.title(projectName),
|
|
55
|
+
"Project ID": Builder.richText(projectId),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
hasMore: false,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
30
64
|
// Example sync worker that syncs sample tasks to a database
|
|
31
65
|
worker.sync("tasksSync", {
|
|
32
66
|
primaryKeyProperty: "Task ID",
|
|
@@ -36,9 +70,9 @@ worker.sync("tasksSync", {
|
|
|
36
70
|
schedule: "continuous",
|
|
37
71
|
|
|
38
72
|
// Sync mode:
|
|
39
|
-
// - "replace": Each sync returns the complete dataset. After hasMore:false,
|
|
73
|
+
// - "replace": Each sync cycle returns the complete dataset. After hasMore:false,
|
|
40
74
|
// pages not seen in this sync run are deleted.
|
|
41
|
-
// - "incremental":
|
|
75
|
+
// - "incremental": Each sync cycle returns a subset of the complete dataset. Use delete markers
|
|
42
76
|
// (e.g., { type: "delete", key: "task-1" }) to remove pages.
|
|
43
77
|
// Defaults to "replace".
|
|
44
78
|
// mode: "replace",
|
|
@@ -55,10 +89,11 @@ worker.sync("tasksSync", {
|
|
|
55
89
|
{ name: "In Progress", color: "blue" },
|
|
56
90
|
{ name: "Todo", color: "default" },
|
|
57
91
|
]),
|
|
92
|
+
Project: Schema.relation("projectsSync"),
|
|
58
93
|
},
|
|
59
94
|
},
|
|
60
95
|
|
|
61
|
-
execute: async () => {
|
|
96
|
+
execute: async (_state, { notion: _notion }) => {
|
|
62
97
|
const emojiForStatus = (status: string) => {
|
|
63
98
|
switch (status) {
|
|
64
99
|
case "Completed":
|
|
@@ -81,19 +116,41 @@ worker.sync("tasksSync", {
|
|
|
81
116
|
"Task ID": Builder.richText(task.id),
|
|
82
117
|
Description: Builder.richText(task.description),
|
|
83
118
|
Status: Builder.select(task.status),
|
|
119
|
+
Project: [Builder.relation(projectId)],
|
|
84
120
|
},
|
|
85
121
|
pageContentMarkdown: `## ${task.title}\n\n${task.description}`,
|
|
86
122
|
}));
|
|
87
123
|
|
|
88
124
|
return {
|
|
125
|
+
// List of changes to apply to the Notion database.
|
|
89
126
|
changes,
|
|
127
|
+
// Indicates whether there is more data to fetch this sync cycle. If true, the runtime will call `execute` again with the nextState.
|
|
90
128
|
hasMore: false,
|
|
129
|
+
// Optional state data Notion will pass back as `state`
|
|
130
|
+
// in the next execution.
|
|
131
|
+
// This can be any type of data (cursor, page number, timestamp, etc.).
|
|
132
|
+
nextState: undefined,
|
|
91
133
|
};
|
|
92
134
|
},
|
|
93
135
|
});
|
|
94
136
|
|
|
137
|
+
type TaskSearchInput = {
|
|
138
|
+
taskId?: string | null;
|
|
139
|
+
query?: string | null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
type TaskSearchOutput = {
|
|
143
|
+
count: number;
|
|
144
|
+
tasks: {
|
|
145
|
+
id: string;
|
|
146
|
+
title: string;
|
|
147
|
+
status: string;
|
|
148
|
+
description: string;
|
|
149
|
+
}[];
|
|
150
|
+
};
|
|
151
|
+
|
|
95
152
|
// Example agent tool for retrieving task information
|
|
96
|
-
worker.tool("taskSearchTool", {
|
|
153
|
+
worker.tool<TaskSearchInput, TaskSearchOutput>("taskSearchTool", {
|
|
97
154
|
title: "Task Search",
|
|
98
155
|
description:
|
|
99
156
|
"Look up sample tasks by ID or keyword. Helpful for demonstrating agent tool calls.",
|
|
@@ -115,7 +172,7 @@ worker.tool("taskSearchTool", {
|
|
|
115
172
|
required: [],
|
|
116
173
|
additionalProperties: false,
|
|
117
174
|
},
|
|
118
|
-
execute: async (input: {
|
|
175
|
+
execute: async (input: TaskSearchInput, { notion: _notion }) => {
|
|
119
176
|
const { taskId, query } = input;
|
|
120
177
|
|
|
121
178
|
let matchingTasks = sampleTasks;
|
|
@@ -157,7 +214,7 @@ worker.tool("taskSearchTool", {
|
|
|
157
214
|
status: task.status,
|
|
158
215
|
description: task.description,
|
|
159
216
|
})),
|
|
160
|
-
};
|
|
217
|
+
} satisfies TaskSearchOutput;
|
|
161
218
|
},
|
|
162
219
|
});
|
|
163
220
|
|
|
@@ -165,7 +222,8 @@ worker.tool("taskSearchTool", {
|
|
|
165
222
|
worker.automation("completeTaskAutomation", {
|
|
166
223
|
title: "Mark Task Complete",
|
|
167
224
|
description: "Automatically marks a task as complete when triggered",
|
|
168
|
-
execute: async ({
|
|
225
|
+
execute: async (event, { notion: _notion }) => {
|
|
226
|
+
const { pageId, actionType, pageData } = event;
|
|
169
227
|
// The pageData parameter contains the full page object from Notion's Public API
|
|
170
228
|
// with all the database properties already encoded and ready to use.
|
|
171
229
|
|