create-ncblock 0.0.39 → 0.0.40

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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/scripts/utils/templates.ts +37 -7
  3. package/sdk-version.json +1 -1
  4. package/templates/worker/.agents/INSTRUCTIONS.md +536 -0
  5. package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
  6. package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
  7. package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
  8. package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
  9. package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
  10. package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
  11. package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
  12. package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
  13. package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
  14. package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
  15. package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
  16. package/templates/worker/.claudeignore +2 -0
  17. package/templates/worker/.codexignore +2 -0
  18. package/templates/worker/.examples/automation-example.ts +60 -0
  19. package/templates/worker/.examples/oauth-example.ts +79 -0
  20. package/templates/worker/.examples/sync-example.ts +184 -0
  21. package/templates/worker/.examples/tool-example.ts +37 -0
  22. package/templates/worker/.examples/webhook-example.ts +66 -0
  23. package/templates/worker/README.md +765 -0
  24. package/templates/worker/_gitignore +6 -0
  25. package/templates/worker/docs/custom-tool.png +0 -0
  26. package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
  27. package/templates/worker/package.json +25 -0
  28. package/templates/worker/src/index.ts +8 -0
  29. package/templates/worker/tsconfig.json +16 -0
  30. package/templates/worker/views/empty/AGENTS.md +67 -0
  31. package/templates/worker/views/empty/README.md +10 -0
  32. package/templates/worker/views/empty/_gitignore +2 -0
  33. package/templates/worker/views/empty/custom_blocks.json +4 -0
  34. package/templates/worker/views/empty/index.html +15 -0
  35. package/templates/worker/views/empty/package.json +23 -0
  36. package/templates/worker/views/empty/src/index.css +33 -0
  37. package/templates/worker/views/empty/src/index.tsx +20 -0
  38. package/templates/worker/views/empty/tsconfig.json +17 -0
  39. package/templates/worker/views/empty/vite.config.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ncblock",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "description": "Create a Notion custom view block project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -157,6 +157,12 @@ function copyDirectoryContents(
157
157
  targetDir: string,
158
158
  options: {
159
159
  overwrite: boolean
160
+ /**
161
+ * Entry names to skip at the top level only. Root README.md and
162
+ * package.json are rewritten by scaffoldTemplate rather than copied;
163
+ * nested ones (e.g. a view subproject's package.json) must copy
164
+ * through untouched.
165
+ */
160
166
  exclude?: Set<string>
161
167
  },
162
168
  ) {
@@ -174,7 +180,17 @@ function copyDirectoryContents(
174
180
  const targetPath = resolve(targetDir, targetName)
175
181
 
176
182
  if (entry.isDirectory()) {
177
- copyDirectoryContents(sourcePath, targetPath, options)
183
+ // Skip local-only artifacts (node_modules, dist, .notion) at every
184
+ // level, not just the template root. These are gitignored or
185
+ // machine-local — e.g. a view subproject's `.notion/` deploy binding —
186
+ // so they only exist in dirty checkouts and must never leak into a
187
+ // scaffold; the top-level `exclude` set doesn't reach nested dirs.
188
+ if (TEMPLATE_LOCAL_ARTIFACTS.has(entry.name)) {
189
+ continue
190
+ }
191
+ copyDirectoryContents(sourcePath, targetPath, {
192
+ overwrite: options.overwrite,
193
+ })
178
194
  continue
179
195
  }
180
196
 
@@ -229,9 +245,12 @@ export function scaffoldTemplate({
229
245
  overwrite,
230
246
  // README.md and package.json are rendered below with the app name and
231
247
  // SDK dependency rewritten for the standalone scaffolded project.
248
+ // workers.json is a developer's local deploy binding (workspace + worker
249
+ // id); copying it would make every scaffold deploy over the same worker.
232
250
  exclude: new Set([
233
251
  "README.md",
234
252
  "package.json",
253
+ "workers.json",
235
254
  ...TEMPLATE_LOCAL_ARTIFACTS,
236
255
  ]),
237
256
  })
@@ -244,14 +263,25 @@ export function scaffoldTemplate({
244
263
  })
245
264
  }
246
265
 
247
- // Scaffolded projects are standalone, not workspace members. If the user
248
- // scaffolds inside an existing pnpm workspace (e.g. this repo), pnpm walks
249
- // up, finds the workspace root, and the install ends up partial. A local
250
- // .npmrc with `ignore-workspace=true` makes pnpm treat the project as
251
- // standalone regardless of where it lives.
266
+ // pnpm settings a standalone scaffold needs, with the reason for each inline.
267
+ const npmrcSettings: [key: string, value: string][] = [
268
+ // Treat the project as standalone, not a workspace member. If the user
269
+ // scaffolds inside an existing pnpm workspace (e.g. this repo), pnpm would
270
+ // otherwise walk up, find the workspace root, and do a partial install.
271
+ ["ignore-workspace", "true"],
272
+ // Lay node_modules out as a flat tree of real directories instead of
273
+ // symlinks into .pnpm. Worker deploys (`ntn workers deploy --local-build`)
274
+ // ship node_modules verbatim, and the symlink layout leaves transitive
275
+ // deps unresolvable in the deploy sandbox (the SDK's `@notionhq/client`
276
+ // import crashes with ERR_MODULE_NOT_FOUND).
277
+ ["node-linker", "hoisted"],
278
+ ]
252
279
  const npmrcPath = resolve(dest, ".npmrc")
253
280
  if (overwrite || !existsSync(npmrcPath)) {
254
- writeFileSync(npmrcPath, "ignore-workspace=true\n")
281
+ const npmrc = npmrcSettings
282
+ .map(([key, value]) => `${key}=${value}`)
283
+ .join("\n")
284
+ writeFileSync(npmrcPath, `${npmrc}\n`)
255
285
  }
256
286
 
257
287
  const readmeTarget = resolve(dest, "README.md")
package/sdk-version.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.0.39"}
1
+ {"version":"0.0.40"}
@@ -0,0 +1,536 @@
1
+ # Repository Guidelines
2
+
3
+ Overall workers documentation lives at https://developers.notion.com/workers/get-started/overview.md.
4
+
5
+ ## Project Structure & Module Organization
6
+ - `src/index.ts` defines the worker and capabilities.
7
+ - `.examples/` has focused samples (sync, tool, automation, OAuth, webhook).
8
+ - Shared agent skills live in `.agents/skills/`. `.claude/skills` is kept as a compatibility symlink for Claude-specific discovery.
9
+ - Generated: `dist/` build output, `workers.json` CLI config.
10
+
11
+ ## Worker & Capability API (SDK)
12
+ - `@notionhq/workers` provides `Worker`, schema helpers, and builders; the `ntn` CLI powers worker management.
13
+ - Capability keys are unique strings used by the CLI (e.g., `ntn workers exec tasksSync`).
14
+
15
+ ```ts
16
+ import { Worker } from "@notionhq/workers";
17
+ import * as Builder from "@notionhq/workers/builder";
18
+ import * as Schema from "@notionhq/workers/schema";
19
+
20
+ const worker = new Worker();
21
+ export default worker;
22
+
23
+ // Declare a sync target database (only written to by syncs — not for general-purpose storage)
24
+ const tasks = worker.database("tasks", {
25
+ type: "managed",
26
+ initialTitle: "Tasks",
27
+ primaryKeyProperty: "ID",
28
+ schema: { properties: { Name: Schema.title(), ID: Schema.richText() } },
29
+ });
30
+
31
+ // Declare a pacer for the upstream API
32
+ const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
33
+
34
+ // Declare a sync that writes to the database
35
+ worker.sync("tasksSync", {
36
+ database: tasks,
37
+ execute: async (state) => {
38
+ await myApi.wait();
39
+ const items = await fetchItems(state?.page ?? 1);
40
+ return {
41
+ changes: items.map((i) => ({
42
+ type: "upsert" as const, key: i.id,
43
+ properties: { Name: Builder.title(i.name), ID: Builder.richText(i.id) },
44
+ })),
45
+ hasMore: false,
46
+ };
47
+ },
48
+ });
49
+
50
+ worker.tool("sayHello", {
51
+ title: "Say Hello",
52
+ description: "Return a greeting",
53
+ schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
54
+ execute: ({ name }, { notion }) => `Hello, ${name}`,
55
+ });
56
+
57
+ worker.oauth("googleAuth", {
58
+ name: "my-google-auth",
59
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
60
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
61
+ scope: "openid email",
62
+ clientId: process.env.GOOGLE_CLIENT_ID ?? "",
63
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
64
+ });
65
+
66
+ worker.webhook("onGithubPush", {
67
+ title: "GitHub Push Webhook",
68
+ description: "Handles push events from GitHub",
69
+ execute: async (events, { notion }) => {
70
+ for (const event of events) {
71
+ console.log("Push:", event.body);
72
+ }
73
+ },
74
+ });
75
+ ```
76
+
77
+ ### Notion API access (`context.notion`)
78
+
79
+ All `execute` handlers receive a `context.notion` object (a `@notionhq/client` SDK instance). You can use this to make API requests to Notion.
80
+
81
+ However, `context.notion` is only **pre-authenticated** when it's a tool capability invoked by a Custom Agent. In that case, the platform sets `NOTION_API_TOKEN` automatically, using the permissions of the Custom Agent — no setup required.
82
+
83
+ For all other capabilities (syncs, automations, webhooks), `context.notion` is **not** pre-authenticated. The user must set the `NOTION_API_TOKEN` environment variable themselves by:
84
+ 1. Creating a connection at https://app.notion.com/developers/connections
85
+ 2. Giving that connection access to the relevant pages and databases in Notion
86
+ 3. Adding the token to `.env` locally, or pushing it with `ntn workers env push` for deployed workers
87
+
88
+ Before writing code that uses `context.notion` in a non-tool capability, check whether `NOTION_API_TOKEN` is configured: look for it in `.env` (e.g. `grep -q '^NOTION_API_TOKEN=' .env`). If it is not set, prompt the user to create a connection at https://app.notion.com/developers/connections and add the token to `.env`.
89
+
90
+ - For user-managed OAuth (shown above), supply `name`, `authorizationEndpoint`, `tokenEndpoint`, `clientId`, `clientSecret`, and `scope` (optional: `authorizationParams`, `callbackUrl`, `accessTokenExpireMs`).
91
+ - **Note:** A Notion-managed OAuth shorthand (`{ provider: "google" }`) also exists but is in alpha and most users will not have access. Use the user-managed configuration above.
92
+ - After deploying a worker with an OAuth capability, the user must configure their OAuth provider's redirect URL to match the one assigned by Notion. Run `ntn workers oauth show-redirect-url` to get the redirect URL, then set it in the provider's OAuth app settings. **Always remind the user of this step after deploying any OAuth capability.**
93
+
94
+ ### Sync
95
+
96
+ #### Databases, Pacers, and Syncs
97
+
98
+ `worker.database()` declares a sync target — a Notion database that syncs write into. **Databases are read-only from the worker's perspective: the only way to write to them is through syncs.** Do not use `worker.database()` to create general-purpose databases (e.g., for storing webhook payloads, tool results, or scratch data). For non-sync writes to Notion, use `context.notion` (the Notion SDK client) directly.
99
+
100
+ Databases are declared separately and referenced by handle:
101
+
102
+ ```ts
103
+ // 1. Declare a database
104
+ const tasks = worker.database("tasks", {
105
+ type: "managed",
106
+ initialTitle: "Tasks",
107
+ primaryKeyProperty: "Task ID",
108
+ schema: {
109
+ properties: {
110
+ "Task Name": Schema.title(),
111
+ "Task ID": Schema.richText(),
112
+ Status: Schema.select([{ name: "Open" }, { name: "Done", color: "green" }]),
113
+ },
114
+ },
115
+ });
116
+
117
+ // 2. Declare a pacer for the upstream API
118
+ const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
119
+
120
+ // 3. Declare a sync
121
+ worker.sync("tasksSync", {
122
+ database: tasks,
123
+ schedule: "30m",
124
+ execute: async (state) => {
125
+ await myApi.wait();
126
+ const { items, hasMore } = await fetchTasks(state?.page ?? 1);
127
+ return {
128
+ changes: items.map((item) => ({
129
+ type: "upsert" as const,
130
+ key: item.id,
131
+ properties: {
132
+ "Task Name": Builder.title(item.name),
133
+ "Task ID": Builder.richText(item.id),
134
+ Status: Builder.select(item.status),
135
+ },
136
+ })),
137
+ hasMore,
138
+ nextState: hasMore ? { page: (state?.page ?? 1) + 1 } : undefined,
139
+ };
140
+ },
141
+ });
142
+ ```
143
+
144
+ Multiple syncs can write to the same database. Multiple syncs can share a pacer — the server apportions the budget evenly across all syncs that use it.
145
+
146
+ #### Pacers (Rate Limiting)
147
+
148
+ **Always declare a pacer** for any sync that calls an external API. Research the API's rate limits before implementing. If the limits are variable (e.g. Salesforce, where you can purchase more API calls), ask the user what budget to allocate.
149
+
150
+ - Call `await pacer.wait()` before **every** API request inside `execute`.
151
+ - The pacer ensures requests are evenly spaced over the interval window.
152
+ - If 4 syncs share a pacer with `allowedRequests: 100, intervalMs: 60_000`, each sync gets ~25 requests/minute.
153
+
154
+ ```ts
155
+ const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
156
+
157
+ // Inside execute:
158
+ await myApi.wait();
159
+ const data = await fetchFromApi();
160
+ ```
161
+
162
+ #### Choosing a Sync Strategy
163
+
164
+ **Simple replace sync** — For truly small data sources (<1k records) or APIs with no change-tracking support. One sync, replace mode. Every cycle returns the full dataset; records not returned are deleted via mark-and-sweep.
165
+
166
+ **Backfill + delta pair** — For everything else (recommended for most real integrations). Two syncs writing to the same database:
167
+ - **Backfill** (replace mode, `schedule: "manual"`): Paginates the entire upstream dataset. Triggered manually via CLI. Cleans up drift, backfills new schema properties, catches deletes the delta can't detect.
168
+ - **Delta** (incremental mode, frequent schedule like `"5m"` or `"30m"`): Fetches only recent changes via `updated_since`, change feeds, etc. Keeps Notion current with minimal API usage.
169
+
170
+ Use backfill + delta whenever the upstream API supports any form of change tracking (`updated_since`, `modified_after`, change feeds, webhooks). Most enterprise APIs do (Salesforce, Jira, Linear, Stripe, GitHub, etc.).
171
+
172
+ ##### Delete handling
173
+
174
+ - **API supports delta deletes** (returns deleted records in change feed): Emit `{ type: "delete", key }` in the delta sync.
175
+ - **API doesn't, but deletes are rare or irrelevant** (e.g. Stripe subscriptions are canceled not deleted, Jira issues are closed not deleted): No action needed — the upstream record still exists, just in a different state.
176
+ - **API doesn't, and deletes matter**: The backfill sync handles this. Its replace-mode mark-and-sweep deletes records no longer present upstream.
177
+
178
+ #### Simple Replace Sync Example
179
+
180
+ ```ts
181
+ const records = worker.database("records", {
182
+ type: "managed",
183
+ initialTitle: "Records",
184
+ primaryKeyProperty: "ID",
185
+ schema: { properties: { Name: Schema.title(), ID: Schema.richText() } },
186
+ });
187
+
188
+ const myApi = worker.pacer("myApi", { allowedRequests: 10, intervalMs: 1000 });
189
+
190
+ worker.sync("recordsSync", {
191
+ database: records,
192
+ mode: "replace",
193
+ schedule: "1h",
194
+ execute: async (state) => {
195
+ const page = state?.page ?? 1;
196
+ await myApi.wait();
197
+ const { items, hasMore } = await fetchPage(page, 100);
198
+ return {
199
+ changes: items.map((item) => ({
200
+ type: "upsert" as const,
201
+ key: item.id,
202
+ properties: { Name: Builder.title(item.name), ID: Builder.richText(item.id) },
203
+ })),
204
+ hasMore,
205
+ nextState: hasMore ? { page: page + 1 } : undefined,
206
+ };
207
+ },
208
+ });
209
+ ```
210
+
211
+ #### Backfill + Delta Example
212
+
213
+ ```ts
214
+ const tasks = worker.database("tasks", {
215
+ type: "managed",
216
+ initialTitle: "Tasks",
217
+ primaryKeyProperty: "Task ID",
218
+ schema: {
219
+ properties: {
220
+ "Task Name": Schema.title(),
221
+ "Task ID": Schema.richText(),
222
+ Status: Schema.select([{ name: "Open" }, { name: "Done", color: "green" }]),
223
+ },
224
+ },
225
+ });
226
+
227
+ const taskApi = worker.pacer("taskApi", { allowedRequests: 10, intervalMs: 1000 });
228
+
229
+ // Backfill: paginates full dataset, runs manually.
230
+ // To re-backfill: ntn workers sync state reset tasksBackfill && ntn workers sync trigger tasksBackfill
231
+ worker.sync("tasksBackfill", {
232
+ database: tasks,
233
+ mode: "replace",
234
+ schedule: "manual",
235
+ execute: async (state) => {
236
+ const page = state?.page ?? 1;
237
+ await taskApi.wait();
238
+ const { items, hasMore } = await fetchAllTasks(page, 100);
239
+ return {
240
+ changes: items.map((item) => ({
241
+ type: "upsert" as const,
242
+ key: item.id,
243
+ properties: {
244
+ "Task Name": Builder.title(item.name),
245
+ "Task ID": Builder.richText(item.id),
246
+ Status: Builder.select(item.status),
247
+ },
248
+ })),
249
+ hasMore,
250
+ nextState: hasMore ? { page: page + 1 } : undefined,
251
+ };
252
+ },
253
+ });
254
+
255
+ // Delta: fetches recent changes, runs every 5 minutes.
256
+ worker.sync("tasksDelta", {
257
+ database: tasks,
258
+ mode: "incremental",
259
+ schedule: "5m",
260
+ execute: async (state) => {
261
+ const cursor = state?.cursor;
262
+ await taskApi.wait();
263
+ const { items, nextCursor } = await fetchTaskChanges(cursor);
264
+ return {
265
+ changes: items.map((item) => ({
266
+ type: "upsert" as const,
267
+ key: item.id,
268
+ properties: {
269
+ "Task Name": Builder.title(item.name),
270
+ "Task ID": Builder.richText(item.id),
271
+ Status: Builder.select(item.status),
272
+ },
273
+ })),
274
+ hasMore: Boolean(nextCursor),
275
+ nextState: nextCursor ? { cursor: nextCursor } : undefined,
276
+ };
277
+ },
278
+ });
279
+ ```
280
+
281
+ #### Pagination
282
+
283
+ 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`.
284
+
285
+ - Always paginate. Returning too many changes in one execution will fail. Start with batch sizes of ~100.
286
+ - Return `hasMore: true` and `nextState` to continue; `hasMore: false` to finish.
287
+ - `nextState` can be any serializable value: cursor string, page number, timestamp, or complex object.
288
+
289
+ #### Schedule
290
+
291
+ Set `schedule` on a sync to control how often it runs:
292
+ - `"continuous"`: run as fast as possible
293
+ - `"manual"`: only via CLI trigger
294
+ - Interval string: `"5m"`, `"30m"`, `"1h"`, `"1d"` (min `"1m"`, max `"7d"`)
295
+ - Default: `"30m"`
296
+
297
+ #### Relations
298
+
299
+ Two databases can relate to one another using `Schema.relation(syncKey)` and `Builder.relation(primaryKey)`:
300
+
301
+ ```ts
302
+ const projects = worker.database("projects", {
303
+ type: "managed",
304
+ initialTitle: "Projects",
305
+ primaryKeyProperty: "Project ID",
306
+ schema: { properties: { "Project Name": Schema.title(), "Project ID": Schema.richText() } },
307
+ });
308
+
309
+ const tasks = worker.database("tasks", {
310
+ type: "managed",
311
+ initialTitle: "Tasks",
312
+ primaryKeyProperty: "Task ID",
313
+ schema: {
314
+ properties: {
315
+ "Task Name": Schema.title(),
316
+ "Task ID": Schema.richText(),
317
+ // Reference the sync key that populates the related database
318
+ Project: Schema.relation("projectsSync", { twoWay: true, relatedPropertyName: "Tasks" }),
319
+ },
320
+ },
321
+ });
322
+
323
+ worker.sync("projectsSync", { database: projects, execute: async () => { ... } });
324
+ worker.sync("tasksSync", {
325
+ database: tasks,
326
+ execute: async () => ({
327
+ changes: [{
328
+ type: "upsert" as const,
329
+ key: "task-1",
330
+ properties: {
331
+ "Task Name": Builder.title("Write docs"),
332
+ "Task ID": Builder.richText("task-1"),
333
+ Project: [Builder.relation("proj-1")], // array of relation refs
334
+ },
335
+ }],
336
+ hasMore: false,
337
+ }),
338
+ });
339
+ ```
340
+
341
+ ### Webhooks
342
+
343
+ Webhooks expose HTTP endpoints that external services can call. After deploying, the CLI prints the webhook URL. Use `ntn workers webhooks list` to see URLs at any time.
344
+
345
+ The execute handler receives an array of `WebhookEvent` objects. Each event contains `deliveryId` (stable idempotency key across retries), `body` (parsed JSON), `rawBody` (string, for signature verification), `headers`, and `method`.
346
+
347
+ ```ts
348
+ worker.webhook("onExternalEvent", {
349
+ title: "External Event Handler",
350
+ description: "Processes incoming webhook requests",
351
+ execute: async (events, { notion }) => {
352
+ for (const event of events) {
353
+ console.log("Method:", event.method);
354
+ console.log("Body:", JSON.stringify(event.body));
355
+ // Use event.headers to access request headers
356
+ }
357
+ },
358
+ });
359
+ ```
360
+
361
+ **Security:** Each webhook gets a unique ID in the URL path that acts as a shared secret. The URL format is:
362
+ ```text
363
+ https://www.notion.so/webhooks/worker/{spaceId}/{workerId}/{uniqueWebhookId}/{webhookName}
364
+ ```
365
+
366
+ This full URL can be retrieved using the `ntn workers webhooks list` command.
367
+
368
+ It is also the responsibility of the worker to verify the webhook. Throw `WebhookVerificationError` if the payload is not valid. 5 invalid payloads in a row will cause webhooks to short circuit until redeployed.
369
+
370
+ ### Sync Management (CLI)
371
+
372
+ **Monitor sync status:**
373
+ ```shell
374
+ ntn workers sync status # live-updating watch mode (polls every 5s)
375
+ ntn workers sync status <key> # filter to a specific sync capability
376
+ ntn workers sync status --no-watch # print once and exit
377
+ ntn workers sync status --interval 10 # custom poll interval in seconds
378
+ ```
379
+
380
+ Status labels:
381
+ - **HEALTHY** — last run succeeded
382
+ - **INITIALIZING** — deployed but hasn't succeeded yet
383
+ - **WARNING** — 1–2 consecutive failures
384
+ - **ERROR** — 3+ consecutive failures
385
+ - **DISABLED** — capability is disabled
386
+
387
+ **Preview a sync (inspect output without writing):**
388
+ ```shell
389
+ ntn workers sync trigger <key> --preview # run execute, show objects, don't write to the database
390
+ ntn workers sync trigger <key> --preview --context '{"page":2}' # resume from a previous preview's nextContext
391
+ ```
392
+ Preview calls your sync's `execute` function and shows the objects it would produce, but **does not write anything to the Notion database**. Use it to verify your sync logic and inspect the data before committing to a real run. When piped, outputs raw JSON.
393
+
394
+ **Trigger a sync (write immediately, bypass schedule):**
395
+ ```shell
396
+ ntn workers sync trigger <key>
397
+ ```
398
+ Trigger starts a **real** sync cycle that writes to the database, bypassing the normal schedule. Use it to push changes immediately rather than waiting for the next scheduled run.
399
+
400
+ **Reset sync state (restart from scratch):**
401
+ ```shell
402
+ ntn workers sync state reset <key>
403
+ ```
404
+ Clears the cursor and stats so the next run starts from the beginning.
405
+
406
+ **Enable / disable a sync:**
407
+ ```shell
408
+ ntn workers capabilities list # show all capabilities
409
+ ntn workers capabilities disable <key> # pause a sync
410
+ ntn workers capabilities enable <key> # resume a sync
411
+ ```
412
+
413
+ > **Note:** `ntn workers deploy` does **not** reset sync state. Syncs resume from their last cursor position after a deploy. Use `ntn workers sync state reset <key>` to explicitly restart from scratch.
414
+
415
+ ### Querying a database
416
+
417
+ Use `ntn datasources query <data-source-id>` to list pages in a database. **The argument is a data source ID, not a database ID** — a database in Notion is a container for one or more data sources, and the public API queries data sources directly.
418
+
419
+ If you only have a database ID, run `ntn datasources resolve <database-id>` first to list the data sources it contains:
420
+
421
+ ```shell
422
+ ntn datasources resolve <database-id>
423
+ ```
424
+
425
+ If exactly one data source is returned, retry the query with that ID. If multiple are returned, pick the one whose name matches what you want.
426
+
427
+ When `ntn datasources query <id>` returns 404 or "Could not find data source", the ID is most likely a database ID — run `resolve` against it and retry with one of the data source IDs it lists.
428
+
429
+ ## Build, Test, and Development Commands
430
+ - Node >= 22 and npm >= 10.9.2 (see `package.json` engines).
431
+ - `npm run build`: compile TypeScript to `dist/`.
432
+ - `npm run check`: type-check only (no emit).
433
+ - `ntn login`: connect to a Notion workspace.
434
+ - `ntn workers deploy`: build and publish capabilities. Does not reset sync state.
435
+ - `ntn workers exec <capability>`: run a sync or tool.
436
+ - `ntn workers sync status`: monitor sync health (live-updating).
437
+ - `ntn workers sync trigger <key> --preview`: preview sync output without writing to the database.
438
+ - `ntn workers sync trigger <key>`: trigger a real sync immediately (writes to the database).
439
+
440
+ ## Debugging & Monitoring Runs
441
+ Use `ntn workers runs` to inspect run history and logs.
442
+
443
+ **List recent runs:**
444
+ ```shell
445
+ ntn workers runs list
446
+ ```
447
+
448
+ **Get logs for a specific run:**
449
+ ```shell
450
+ ntn workers runs logs <runId>
451
+ ```
452
+
453
+ **Get logs for the latest run (any capability):**
454
+ ```shell
455
+ ntn workers runs list --plain | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
456
+ ```
457
+
458
+ **Get logs for the latest run of a specific capability:**
459
+ ```shell
460
+ ntn workers runs list --plain | grep tasksSync | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
461
+ ```
462
+
463
+ The `--plain` flag outputs tab-separated values without formatting, making it easy to pipe to other commands.
464
+
465
+ ### Debugging Syncs
466
+
467
+ **Check sync health:**
468
+ ```shell
469
+ ntn workers sync status
470
+ ```
471
+ Look at failure counts, error messages, and last succeeded times.
472
+
473
+ **Sync not running?** Check if the capability is disabled:
474
+ ```shell
475
+ ntn workers capabilities list
476
+ ```
477
+
478
+ **Preview what a sync would produce (without writing):**
479
+ ```shell
480
+ ntn workers sync trigger <key> --preview
481
+ ```
482
+
483
+ **Retry a failed sync (writes to the database):**
484
+ ```shell
485
+ ntn workers sync trigger <key>
486
+ ```
487
+
488
+ **Sync in a bad state?** Reset the cursor and restart:
489
+ ```shell
490
+ ntn workers sync state reset <key>
491
+ ```
492
+
493
+ ## Coding Style & Naming Conventions
494
+ - TypeScript with `strict` enabled; keep types explicit when shaping I/O.
495
+ - Use tabs for indentation; capability keys in lowerCamelCase.
496
+
497
+ ## Testing Guidelines
498
+ - No test runner configured; validate with `npm run check` and end-to-end testing via `ntn workers exec`.
499
+ - Write a test script that exercises each tool capability using `ntn workers exec`. This can be a bash script (`test.sh`) or a TypeScript script (`test.ts`, run via `npx tsx test.ts`). Use the `--local` flag for local execution or omit it to run against the deployed worker.
500
+
501
+ **Local execution** runs your worker code directly on your machine. Any `.env` file in the project root is automatically loaded, so secrets and config values are available via `process.env`.
502
+
503
+ **Remote execution** (without `--local`) runs against the deployed worker. Any required secrets must be pushed to the remote environment first using `ntn workers env push`.
504
+
505
+ **Example bash test script (`test.sh`):**
506
+ ```shell
507
+ #!/usr/bin/env bash
508
+ set -euo pipefail
509
+
510
+ # Run locally (uses .env automatically):
511
+ ntn workers exec sayHello --local -d '{"name": "World"}'
512
+
513
+ # Or run against the deployed worker (requires `ntn workers deploy` and `ntn workers env push` first):
514
+ # ntn workers exec sayHello -d '{"name": "World"}'
515
+ ```
516
+
517
+ **Example TypeScript test script (`test.ts`, run with `npx tsx test.ts`):**
518
+ ```ts
519
+ import { execSync } from "child_process";
520
+
521
+ function exec(capability: string, input: Record<string, unknown>) {
522
+ const result = execSync(
523
+ `ntn workers exec ${capability} --local -d '${JSON.stringify(input)}'`,
524
+ { encoding: "utf-8" },
525
+ );
526
+ console.log(result);
527
+ }
528
+
529
+ exec("sayHello", { name: "World" });
530
+ ```
531
+
532
+ Use this pattern to build up a suite of exec calls that covers each tool with representative inputs.
533
+
534
+ ## Commit & Pull Request Guidelines
535
+ - Messages typically use `feat(scope): ...`, `TASK-123: ...`, or version bumps.
536
+ - PRs should describe changes, list commands run, and update examples if behavior changes.