@project-ajax/create 0.0.30 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@project-ajax/create",
3
- "version": "0.0.30",
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
@@ -54,7 +54,7 @@ worker.sync("mySync", {
54
54
  Project: Schema.relation("projectsSync"),
55
55
  },
56
56
  },
57
- execute: async () => {
57
+ execute: async (_state, { notion: _notion }) => {
58
58
  // Fetch and return data
59
59
  return {
60
60
  changes: [
@@ -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,18 +30,20 @@ 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
49
  ### Sync
@@ -59,8 +61,8 @@ Syncs run in a "sync cycle": a back-to-back chain of `execute` calls that starts
59
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
 
66
68
  **Example replace sync:**
@@ -70,8 +72,8 @@ worker.sync("paginatedSync", {
70
72
  mode: "replace",
71
73
  primaryKeyProperty: "ID",
72
74
  schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
73
- execute: async (context?: { page: number }) => {
74
- const page = context?.page ?? 1;
75
+ execute: async (state, { notion }) => {
76
+ const page = state?.page ?? 1;
75
77
  const pageSize = 100;
76
78
  const { items, hasMore } = await fetchPage(page, pageSize);
77
79
  return {
@@ -81,13 +83,13 @@ worker.sync("paginatedSync", {
81
83
  properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
82
84
  })),
83
85
  hasMore,
84
- nextContext: hasMore ? { page: page + 1 } : undefined,
86
+ nextState: hasMore ? { page: page + 1 } : undefined,
85
87
  };
86
88
  },
87
89
  });
88
90
  ```
89
91
 
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.
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.
91
93
 
92
94
  **Incremental example (changes only, with deletes):**
93
95
  ```ts
@@ -95,8 +97,8 @@ worker.sync("incrementalSync", {
95
97
  primaryKeyProperty: "ID",
96
98
  mode: "incremental",
97
99
  schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
98
- execute: async (context?: { cursor?: string }) => {
99
- const { upserts, deletes, nextCursor } = await fetchChanges(context?.cursor);
100
+ execute: async (state, { notion }) => {
101
+ const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
100
102
  return {
101
103
  changes: [
102
104
  ...upserts.map((item) => ({
@@ -107,7 +109,7 @@ worker.sync("incrementalSync", {
107
109
  ...deletes.map((id) => ({ type: "delete", key: id })),
108
110
  ],
109
111
  hasMore: Boolean(nextCursor),
110
- nextContext: nextCursor ? { cursor: nextCursor } : undefined,
112
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
111
113
  };
112
114
  },
113
115
  });
@@ -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.
@@ -73,7 +73,8 @@ worker.sync("tasksSync", {
73
73
  ID: Schema.richText(),
74
74
  },
75
75
  },
76
- execute: async () => ({
76
+ execute: async (_state, { notion }) => ({
77
+ // `notion` is the Notion API SDK client.
77
78
  changes: [
78
79
  {
79
80
  type: "upsert",
@@ -95,15 +96,15 @@ Notion will delete stale rows after each run. A stale row is a row that was in t
95
96
 
96
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.
97
98
 
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:
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:
99
100
 
100
101
  ```ts
101
102
  worker.sync("fullSync", {
102
103
  primaryKeyProperty: "ID",
103
104
  mode: "replace",
104
105
  schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
105
- execute: async (context?: { page: number }) => {
106
- const { items , nextCursor } = await fetchPage(context?.page);
106
+ execute: async (state, { notion }) => {
107
+ const { items , nextCursor } = await fetchPage(state?.page);
107
108
  return {
108
109
  changes: items.map((item) => ({
109
110
  type: "upsert",
@@ -111,13 +112,13 @@ worker.sync("fullSync", {
111
112
  properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
112
113
  })),
113
114
  hasMore: nextCursor ? true : false,
114
- nextCursor
115
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
115
116
  };
116
117
  },
117
118
  });
118
119
  ```
119
120
 
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
+ 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.
121
122
 
122
123
  #### Write a sync that syncs incrementally
123
124
 
@@ -130,8 +131,8 @@ worker.sync("incrementalSync", {
130
131
  primaryKeyProperty: "ID",
131
132
  mode: "incremental",
132
133
  schema: { defaultName: "Records", properties: { Name: Schema.title(), ID: Schema.richText() } },
133
- execute: async (context?: { cursor?: string }) => {
134
- const { upserts, deletes, nextCursor } = await fetchChanges(context?.cursor);
134
+ execute: async (state, { notion }) => {
135
+ const { upserts, deletes, nextCursor } = await fetchChanges(state?.cursor);
135
136
  return {
136
137
  changes: [
137
138
  ...upserts.map((item) => ({
@@ -142,13 +143,13 @@ worker.sync("incrementalSync", {
142
143
  ...deletes.map((id) => ({ type: "delete", key: id })),
143
144
  ],
144
145
  hasMore: Boolean(nextCursor),
145
- nextContext: nextCursor ? { cursor: nextCursor } : undefined,
146
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
146
147
  };
147
148
  },
148
149
  });
149
150
  ```
150
151
 
151
- Unlike the `replace` sync mode, Notion will not drop "stale" rows and `context` will persist between sync cycles.
152
+ Unlike the `replace` sync mode, Notion will not drop "stale" rows and `state` will persist between sync cycles.
152
153
 
153
154
  **Deletes**
154
155
 
@@ -184,7 +185,7 @@ worker.tool("sayHello", {
184
185
  required: ["name"],
185
186
  additionalProperties: false,
186
187
  },
187
- execute: ({ name }) => `Hello, ${name}`,
188
+ execute: ({ name }, { notion }) => `Hello, ${name}`,
188
189
  });
189
190
  ```
190
191
 
@@ -196,7 +197,8 @@ Automations run from Notion database buttons or automations.
196
197
  worker.automation("sendWelcomeEmail", {
197
198
  title: "Send Welcome Email",
198
199
  description: "Runs from a database automation",
199
- execute: async ({ pageId }) => {
200
+ execute: async (event, { notion }) => {
201
+ const { pageId } = event;
200
202
  console.log("Triggered for page", pageId);
201
203
  },
202
204
  });
@@ -93,7 +93,7 @@ worker.sync("tasksSync", {
93
93
  },
94
94
  },
95
95
 
96
- execute: async () => {
96
+ execute: async (_state, { notion: _notion }) => {
97
97
  const emojiForStatus = (status: string) => {
98
98
  switch (status) {
99
99
  case "Completed":
@@ -124,17 +124,33 @@ worker.sync("tasksSync", {
124
124
  return {
125
125
  // List of changes to apply to the Notion database.
126
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.
127
+ // Indicates whether there is more data to fetch this sync cycle. If true, the runtime will call `execute` again with the nextState.
128
128
  hasMore: false,
129
- // Optional context data Notion will pass back in the next execution.
129
+ // Optional state data Notion will pass back as `state`
130
+ // in the next execution.
130
131
  // This can be any type of data (cursor, page number, timestamp, etc.).
131
- nextContext: undefined,
132
+ nextState: undefined,
132
133
  };
133
134
  },
134
135
  });
135
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
+
136
152
  // Example agent tool for retrieving task information
137
- worker.tool("taskSearchTool", {
153
+ worker.tool<TaskSearchInput, TaskSearchOutput>("taskSearchTool", {
138
154
  title: "Task Search",
139
155
  description:
140
156
  "Look up sample tasks by ID or keyword. Helpful for demonstrating agent tool calls.",
@@ -156,7 +172,7 @@ worker.tool("taskSearchTool", {
156
172
  required: [],
157
173
  additionalProperties: false,
158
174
  },
159
- execute: async (input: { taskId?: string | null; query?: string | null }) => {
175
+ execute: async (input: TaskSearchInput, { notion: _notion }) => {
160
176
  const { taskId, query } = input;
161
177
 
162
178
  let matchingTasks = sampleTasks;
@@ -198,7 +214,7 @@ worker.tool("taskSearchTool", {
198
214
  status: task.status,
199
215
  description: task.description,
200
216
  })),
201
- };
217
+ } satisfies TaskSearchOutput;
202
218
  },
203
219
  });
204
220
 
@@ -206,7 +222,8 @@ worker.tool("taskSearchTool", {
206
222
  worker.automation("completeTaskAutomation", {
207
223
  title: "Mark Task Complete",
208
224
  description: "Automatically marks a task as complete when triggered",
209
- execute: async ({ pageId, actionType, pageData }) => {
225
+ execute: async (event, { notion: _notion }) => {
226
+ const { pageId, actionType, pageData } = event;
210
227
  // The pageData parameter contains the full page object from Notion's Public API
211
228
  // with all the database properties already encoded and ready to use.
212
229