@project-ajax/create 0.0.28 → 0.0.30
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/sync-example.ts +37 -0
- package/template/AGENTS.md +53 -29
- package/template/README.md +45 -19
- package/template/src/index.ts +43 -2
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
|
@@ -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,6 +51,7 @@ 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
57
|
execute: async () => {
|
|
@@ -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
|
],
|
package/template/AGENTS.md
CHANGED
|
@@ -44,27 +44,30 @@ worker.oauth("googleAuth", { name: "my-google-auth", provider: "google" });
|
|
|
44
44
|
|
|
45
45
|
- For user-managed OAuth, supply `name`, `authorizationEndpoint`, `tokenEndpoint`, `clientId`, `clientSecret`, and `scope` (optional: `authorizationParams`, `callbackUrl`, `accessTokenExpireMs`).
|
|
46
46
|
|
|
47
|
-
### Sync
|
|
47
|
+
### Sync
|
|
48
|
+
#### Strategy and Pagination
|
|
48
49
|
|
|
49
50
|
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
51
|
|
|
52
|
+
- Always use pagination, when available. Returning too many changes in one execution will fail. Start with batch sizes of ~100 changes.
|
|
53
|
+
- `mode=replace` is simpler, and fine for smaller syncs (<10k)
|
|
54
|
+
- Use `mode=incremental` when the sync could return a lot of data (>10k), eg for SaaS tools like Salesforce or Stripe
|
|
55
|
+
- When using `mode=incremental`, emit delete markers as needed if easy to do (below)
|
|
56
|
+
|
|
51
57
|
**Sync strategy (`mode`):**
|
|
52
58
|
- `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.
|
|
59
|
+
- `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
60
|
|
|
61
61
|
**How pagination works:**
|
|
62
62
|
1. Return a batch of changes with `hasMore: true` and a `nextContext` value
|
|
63
63
|
2. The runtime calls `execute` again with that context
|
|
64
64
|
3. Continue until you return `hasMore: false`
|
|
65
65
|
|
|
66
|
+
**Example replace sync:**
|
|
67
|
+
|
|
66
68
|
```ts
|
|
67
69
|
worker.sync("paginatedSync", {
|
|
70
|
+
mode: "replace",
|
|
68
71
|
primaryKeyProperty: "ID",
|
|
69
72
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
70
73
|
execute: async (context?: { page: number }) => {
|
|
@@ -86,27 +89,6 @@ worker.sync("paginatedSync", {
|
|
|
86
89
|
|
|
87
90
|
**Context types:** The `nextContext` can be any serializable value—a cursor string, page number, timestamp, or complex object. Type your execute function's context parameter to match.
|
|
88
91
|
|
|
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
|
-
```
|
|
109
|
-
|
|
110
92
|
**Incremental example (changes only, with deletes):**
|
|
111
93
|
```ts
|
|
112
94
|
worker.sync("incrementalSync", {
|
|
@@ -131,6 +113,48 @@ worker.sync("incrementalSync", {
|
|
|
131
113
|
});
|
|
132
114
|
```
|
|
133
115
|
|
|
116
|
+
#### Relations
|
|
117
|
+
|
|
118
|
+
Two syncs can relate to one another using `Schema.relation(relatedSyncKey)` and `Builder.relation(primaryKey)` entries inside an array.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
worker.sync("projectsSync", {
|
|
122
|
+
primaryKeyProperty: "Project ID",
|
|
123
|
+
...
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Example sync worker that syncs sample tasks to a database
|
|
127
|
+
worker.sync("tasksSync", {
|
|
128
|
+
primaryKeyProperty: "Task ID",
|
|
129
|
+
...
|
|
130
|
+
schema: {
|
|
131
|
+
...
|
|
132
|
+
properties: {
|
|
133
|
+
...
|
|
134
|
+
Project: Schema.relation("projectsSync"),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
execute: async () => {
|
|
139
|
+
// Return sample tasks as database entries
|
|
140
|
+
const tasks = fetchTasks()
|
|
141
|
+
const changes = tasks.map((task) => ({
|
|
142
|
+
type: "upsert" as const,
|
|
143
|
+
key: task.id,
|
|
144
|
+
properties: {
|
|
145
|
+
...
|
|
146
|
+
Project: [Builder.relation(task.projectId)],
|
|
147
|
+
},
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
changes,
|
|
152
|
+
hasMore: false,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
134
158
|
## Build, Test, and Development Commands
|
|
135
159
|
- Node >= 22 and npm >= 10.9.2 (see `package.json` engines).
|
|
136
160
|
- `npm run dev`: run `src/index.ts` with live reload.
|
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";
|
|
@@ -88,42 +89,42 @@ worker.sync("tasksSync", {
|
|
|
88
89
|
});
|
|
89
90
|
```
|
|
90
91
|
|
|
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`.
|
|
92
|
+
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
93
|
|
|
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.
|
|
94
|
+
#### Write a sync that paginates
|
|
98
95
|
|
|
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.
|
|
96
|
+
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
97
|
|
|
103
|
-
|
|
98
|
+
You can use **context** to persist things like pagination tokens between `execute` runs. Notion passes `context` as the first argument to `execute`. Return `nextContext` to set the `context` for the next run:
|
|
104
99
|
|
|
105
|
-
**Replace example (full dataset each cycle, each execute call paginated):**
|
|
106
100
|
```ts
|
|
107
101
|
worker.sync("fullSync", {
|
|
108
102
|
primaryKeyProperty: "ID",
|
|
109
103
|
mode: "replace",
|
|
110
104
|
schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
|
|
111
|
-
execute: async (context?: {
|
|
112
|
-
const { items, nextCursor } = await fetchPage(context?.
|
|
105
|
+
execute: async (context?: { page: number }) => {
|
|
106
|
+
const { items , nextCursor } = await fetchPage(context?.page);
|
|
113
107
|
return {
|
|
114
108
|
changes: items.map((item) => ({
|
|
115
109
|
type: "upsert",
|
|
116
110
|
key: item.id,
|
|
117
111
|
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
118
112
|
})),
|
|
119
|
-
hasMore:
|
|
120
|
-
|
|
113
|
+
hasMore: nextCursor ? true : false,
|
|
114
|
+
nextCursor
|
|
121
115
|
};
|
|
122
116
|
},
|
|
123
117
|
});
|
|
124
118
|
```
|
|
125
119
|
|
|
126
|
-
|
|
120
|
+
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 `context=null`.
|
|
121
|
+
|
|
122
|
+
#### Write a sync that syncs incrementally
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
Set the sync's `mode` to `incremental` and use pagination as above:
|
|
127
|
+
|
|
127
128
|
```ts
|
|
128
129
|
worker.sync("incrementalSync", {
|
|
129
130
|
primaryKeyProperty: "ID",
|
|
@@ -147,6 +148,28 @@ worker.sync("incrementalSync", {
|
|
|
147
148
|
});
|
|
148
149
|
```
|
|
149
150
|
|
|
151
|
+
Unlike the `replace` sync mode, Notion will not drop "stale" rows and `context` will persist between sync cycles.
|
|
152
|
+
|
|
153
|
+
**Deletes**
|
|
154
|
+
|
|
155
|
+
With incremental syncs, you can delete rows by returning a delete marker, like so:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
changes: [
|
|
159
|
+
// this is an upsert
|
|
160
|
+
{
|
|
161
|
+
type: "upsert",
|
|
162
|
+
key: item.id,
|
|
163
|
+
properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
|
|
164
|
+
},
|
|
165
|
+
// this is a delete
|
|
166
|
+
{
|
|
167
|
+
type: "delete",
|
|
168
|
+
key: item.id
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
```
|
|
172
|
+
|
|
150
173
|
### Tool
|
|
151
174
|
|
|
152
175
|
Tools are callable by Notion custom agents.
|
|
@@ -215,6 +238,9 @@ Log in to Notion (use `--env=dev` for dev):
|
|
|
215
238
|
npx workers auth login --env=dev
|
|
216
239
|
```
|
|
217
240
|
|
|
241
|
+
Login is automatically handled by `npx workers deploy`, so this command is
|
|
242
|
+
typically not needed.
|
|
243
|
+
|
|
218
244
|
### `npx workers auth show`
|
|
219
245
|
Show the active auth token:
|
|
220
246
|
|
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,6 +89,7 @@ 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
|
|
|
@@ -81,13 +116,19 @@ 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 nextContext.
|
|
90
128
|
hasMore: false,
|
|
129
|
+
// Optional context data Notion will pass back in the next execution.
|
|
130
|
+
// This can be any type of data (cursor, page number, timestamp, etc.).
|
|
131
|
+
nextContext: undefined,
|
|
91
132
|
};
|
|
92
133
|
},
|
|
93
134
|
});
|