@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@project-ajax/create",
3
- "version": "0.0.28",
3
+ "version": "0.0.31",
4
4
  "description": "Initialize a new Notion Project Ajax extensions repo.",
5
5
  "bin": {
6
6
  "create-ajax": "dist/index.js"
@@ -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 ({ pageId, pageData }) => {
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
 
@@ -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 Strategy and Pagination
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 only changes since the last run. Deletions must be explicit via `{ type: "delete", key: "..." }`. Records not mentioned are left unchanged.
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 `nextContext` value
63
- 2. The runtime calls `execute` again with that context
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 (context?: { page: number }) => {
71
- const page = context?.page ?? 1;
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
- nextContext: hasMore ? { page: page + 1 } : undefined,
86
+ nextState: hasMore ? { page: page + 1 } : undefined,
82
87
  };
83
88
  },
84
89
  });
85
90
  ```
86
91
 
87
- **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
-
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 (context?: { cursor?: string }) => {
117
- const { upserts, deletes, nextCursor } = await fetchChanges(context?.cursor);
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
- nextContext: nextCursor ? { cursor: nextCursor } : undefined,
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.
@@ -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
- #### Sync strategy and pagination
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
- Choose a strategy with `mode`:
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
- **When to use each:**
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
- Paginate large datasets by returning `hasMore: true` with a `nextContext` cursor, and continue until `hasMore: false`.
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 (context?: { cursor?: string }) => {
112
- const { items, nextCursor } = await fetchPage(context?.cursor);
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: Boolean(nextCursor),
120
- nextContext: nextCursor ? { cursor: nextCursor } : undefined,
114
+ hasMore: nextCursor ? true : false,
115
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
121
116
  };
122
117
  },
123
118
  });
124
119
  ```
125
120
 
126
- **Incremental example (changes only, with deletes):**
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 (context?: { cursor?: string }) => {
133
- const { upserts, deletes, nextCursor } = await fetchChanges(context?.cursor);
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
- nextContext: nextCursor ? { cursor: nextCursor } : undefined,
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 ({ pageId }) => {
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
 
@@ -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": Sync returns changes only. Use delete markers
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: { taskId?: string | null; query?: string | null }) => {
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 ({ pageId, actionType, pageData }) => {
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