create-ncblock 0.0.39 → 0.0.41

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 (40) hide show
  1. package/package.json +1 -1
  2. package/scripts/init.ts +39 -23
  3. package/scripts/utils/templates.ts +53 -8
  4. package/sdk-version.json +1 -1
  5. package/templates/worker/.agents/INSTRUCTIONS.md +593 -0
  6. package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
  7. package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
  8. package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
  9. package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
  10. package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
  11. package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
  12. package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
  13. package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
  14. package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
  15. package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
  16. package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
  17. package/templates/worker/.claudeignore +2 -0
  18. package/templates/worker/.codexignore +2 -0
  19. package/templates/worker/.examples/automation-example.ts +60 -0
  20. package/templates/worker/.examples/oauth-example.ts +79 -0
  21. package/templates/worker/.examples/sync-example.ts +184 -0
  22. package/templates/worker/.examples/tool-example.ts +37 -0
  23. package/templates/worker/.examples/webhook-example.ts +66 -0
  24. package/templates/worker/README.md +765 -0
  25. package/templates/worker/_gitignore +6 -0
  26. package/templates/worker/docs/custom-tool.png +0 -0
  27. package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
  28. package/templates/worker/package.json +25 -0
  29. package/templates/worker/src/index.ts +8 -0
  30. package/templates/worker/tsconfig.json +16 -0
  31. package/templates/worker/views/empty/AGENTS.md +71 -0
  32. package/templates/worker/views/empty/README.md +10 -0
  33. package/templates/worker/views/empty/_gitignore +2 -0
  34. package/templates/worker/views/empty/custom_blocks.json +4 -0
  35. package/templates/worker/views/empty/index.html +15 -0
  36. package/templates/worker/views/empty/package.json +23 -0
  37. package/templates/worker/views/empty/src/index.css +33 -0
  38. package/templates/worker/views/empty/src/index.tsx +20 -0
  39. package/templates/worker/views/empty/tsconfig.json +17 -0
  40. package/templates/worker/views/empty/vite.config.ts +7 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Delta sync — opaque cursor for incremental change tracking.
3
+ *
4
+ * This is the delta half of a backfill+delta pair. It handles ongoing
5
+ * change detection for APIs that sort results by updated_at and return
6
+ * an opaque pagination cursor. On each cycle, the cursor picks up where
7
+ * the last cycle left off, fetching only newly modified records.
8
+ *
9
+ * Example: Shopify GraphQL (sortKey: UPDATED_AT), any API with opaque
10
+ * cursor pagination that returns results in modification order.
11
+ *
12
+ * Key points:
13
+ * - State is just { cursor: string | null } — no phase discrimination
14
+ * - First run: cursor is null, starts from the beginning
15
+ * - Subsequent runs: cursor picks up where the last cycle left off
16
+ * - The cursor never resets in incremental mode — it persists forever
17
+ * - Cursor preservation: if the API returns no endCursor (empty page at
18
+ * frontier), keep the existing cursor rather than regressing to null
19
+ *
20
+ * For a full dataset load (backfill), add a separate replace-mode sync
21
+ * targeting the same database — see replace-paginated.ts for the pattern.
22
+ * Trigger it via CLI:
23
+ * ntn workers sync state reset ordersBackfill && ntn workers sync trigger ordersBackfill
24
+ */
25
+
26
+ import { Worker } from "@notionhq/workers"
27
+ import * as Builder from "@notionhq/workers/builder"
28
+ import * as Schema from "@notionhq/workers/schema"
29
+
30
+ const worker = new Worker()
31
+ export default worker
32
+
33
+ // Database shared between backfill (replace) and delta (incremental) syncs
34
+ const orders = worker.database("orders", {
35
+ type: "managed",
36
+ initialTitle: "Orders",
37
+ primaryKeyProperty: "Order ID",
38
+ schema: {
39
+ properties: {
40
+ Title: Schema.title(),
41
+ "Order ID": Schema.richText(),
42
+ },
43
+ },
44
+ })
45
+
46
+ // Rate-limit API calls — shared across all syncs hitting this API
47
+ const apiPacer = worker.pacer("shopify", {
48
+ allowedRequests: 4,
49
+ intervalMs: 1000,
50
+ })
51
+
52
+ type CursorState = { cursor: string | null }
53
+
54
+ // Delta sync: incremental mode, runs every 5 minutes to pick up changes
55
+ worker.sync("ordersDelta", {
56
+ database: orders,
57
+ mode: "incremental",
58
+ schedule: "5m",
59
+ execute: async (state: CursorState | undefined) => {
60
+ const cursor = state?.cursor ?? null
61
+
62
+ // GraphQL query with Relay-style pagination, sorted by UPDATED_AT
63
+ const query = `
64
+ query ($after: String) {
65
+ orders(first: 100, sortKey: UPDATED_AT, after: $after) {
66
+ edges { node { id name } }
67
+ pageInfo { hasNextPage endCursor }
68
+ }
69
+ }
70
+ `
71
+
72
+ await apiPacer.wait()
73
+ const response = await fetch(
74
+ "https://shop.example.com/admin/api/graphql.json",
75
+ {
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ "X-Access-Token": process.env.SHOP_TOKEN ?? "",
80
+ },
81
+ body: JSON.stringify({ query, variables: { after: cursor } }),
82
+ },
83
+ )
84
+ const { data } = await response.json()
85
+ const { edges, pageInfo } = data.orders
86
+
87
+ return {
88
+ changes: edges.map((edge: { node: { id: string; name: string } }) => ({
89
+ type: "upsert" as const,
90
+ key: edge.node.id,
91
+ properties: {
92
+ Title: Builder.title(edge.node.name),
93
+ "Order ID": Builder.richText(edge.node.id),
94
+ },
95
+ })),
96
+ hasMore: pageInfo.hasNextPage,
97
+ nextState: {
98
+ // Preserve existing cursor if API returns null (empty frontier)
99
+ cursor: pageInfo.endCursor ?? cursor,
100
+ },
101
+ }
102
+ },
103
+ })
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Two-sync pattern — backfill + delta writing to the same database.
3
+ *
4
+ * Instead of a single sync with a bi-modal state machine (phase: "backfill" | "delta"),
5
+ * this uses two separate syncs targeting the same database:
6
+ *
7
+ * - **Backfill sync** (replace mode, manual schedule): Paginates the full dataset
8
+ * using keyset pagination on (created_at, id). Triggered on demand via CLI.
9
+ * - **Delta sync** (incremental mode, 5m schedule): Fetches only recently modified
10
+ * records using keyset pagination on (updated_at, id) with a 15s consistency buffer.
11
+ *
12
+ * Advantages over the single-sync bi-modal approach:
13
+ * - No phase discrimination in state — each sync has simple, focused state
14
+ * - No backfill-to-delta transition logic
15
+ * - Backfill and delta run independently — re-backfill anytime without disrupting delta
16
+ * - Easier to reason about and debug
17
+ *
18
+ * This is the Salesforce/HubSpot pattern:
19
+ * - Backfill: keyset pagination on (created_at, id)
20
+ * - Delta: keyset pagination on (updated_at, id) with consistency buffer
21
+ *
22
+ * To trigger a full re-backfill:
23
+ * ntn workers sync state reset contactsBackfill && ntn workers sync trigger contactsBackfill
24
+ */
25
+
26
+ import { Worker } from "@notionhq/workers"
27
+ import * as Builder from "@notionhq/workers/builder"
28
+ import * as Schema from "@notionhq/workers/schema"
29
+
30
+ const worker = new Worker()
31
+ export default worker
32
+
33
+ // One database shared by both syncs
34
+ const contacts = worker.database("contacts", {
35
+ type: "managed",
36
+ initialTitle: "Contacts",
37
+ primaryKeyProperty: "Contact ID",
38
+ schema: {
39
+ properties: {
40
+ Name: Schema.title(),
41
+ "Contact ID": Schema.richText(),
42
+ },
43
+ },
44
+ })
45
+
46
+ // One pacer shared by both syncs — budget is apportioned evenly
47
+ const apiPacer = worker.pacer("crm", {
48
+ allowedRequests: 10,
49
+ intervalMs: 1000,
50
+ })
51
+
52
+ const BATCH_SIZE = 100
53
+ const CONSISTENCY_BUFFER_MS = 15_000 // 15 seconds
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Shared helper — maps a contact record to a sync upsert change
57
+ // ---------------------------------------------------------------------------
58
+ function toUpsert(record: { id: string; name: string }) {
59
+ return {
60
+ type: "upsert" as const,
61
+ key: record.id,
62
+ properties: {
63
+ Name: Builder.title(record.name),
64
+ "Contact ID": Builder.richText(record.id),
65
+ },
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Backfill sync: replace mode, manual schedule
71
+ // ---------------------------------------------------------------------------
72
+ // Paginates the full dataset using keyset pagination on (created_at, id).
73
+ // Replace mode means the runtime will delete any records not seen during the
74
+ // full cycle, ensuring the database is a faithful mirror of the source.
75
+
76
+ type BackfillState = {
77
+ cursorTimestamp: string | null
78
+ cursorId: string | null
79
+ }
80
+
81
+ worker.sync("contactsBackfill", {
82
+ database: contacts,
83
+ mode: "replace",
84
+ schedule: "manual",
85
+ execute: async (state: BackfillState | undefined) => {
86
+ // Keyset pagination: WHERE created_at > X OR (created_at = X AND id > Y)
87
+ const params = new URLSearchParams({
88
+ limit: String(BATCH_SIZE),
89
+ order_by: "created_at,id",
90
+ })
91
+ if (state?.cursorTimestamp) {
92
+ params.set("created_after", state.cursorTimestamp)
93
+ params.set("created_after_id", state.cursorId ?? "")
94
+ }
95
+
96
+ await apiPacer.wait()
97
+ const response = await fetch(`https://api.example.com/contacts?${params}`, {
98
+ headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
99
+ })
100
+ const data = await response.json()
101
+ const records: Array<{
102
+ id: string
103
+ name: string
104
+ created_at: string
105
+ }> = data.contacts
106
+ const done = records.length < BATCH_SIZE
107
+
108
+ if (done) {
109
+ return {
110
+ changes: records.map(toUpsert),
111
+ hasMore: false,
112
+ }
113
+ }
114
+
115
+ const last = records[records.length - 1]
116
+ return {
117
+ changes: records.map(toUpsert),
118
+ hasMore: true,
119
+ nextState: {
120
+ cursorTimestamp: last.created_at,
121
+ cursorId: last.id,
122
+ },
123
+ }
124
+ },
125
+ })
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Delta sync: incremental mode, every 5 minutes
129
+ // ---------------------------------------------------------------------------
130
+ // Fetches only records modified since the cursor. Uses keyset pagination on
131
+ // (updated_at, id) with a 15s consistency buffer to avoid advancing past
132
+ // records that the API hasn't indexed yet.
133
+
134
+ type DeltaState = {
135
+ cursorTimestamp: string
136
+ cursorId: string
137
+ }
138
+
139
+ worker.sync("contactsDelta", {
140
+ database: contacts,
141
+ mode: "incremental",
142
+ schedule: "5m",
143
+ execute: async (state: DeltaState | undefined) => {
144
+ // On first run, start from "now" minus the consistency buffer.
145
+ // This means the first delta cycle won't fetch any historical data —
146
+ // that's the backfill sync's job.
147
+ if (!state) {
148
+ const startTs = new Date(Date.now() - CONSISTENCY_BUFFER_MS).toISOString()
149
+ return {
150
+ changes: [],
151
+ hasMore: false,
152
+ nextState: { cursorTimestamp: startTs, cursorId: "" },
153
+ }
154
+ }
155
+
156
+ const params = new URLSearchParams({
157
+ limit: String(BATCH_SIZE),
158
+ order_by: "updated_at,id",
159
+ updated_after: state.cursorTimestamp,
160
+ updated_after_id: state.cursorId,
161
+ })
162
+
163
+ await apiPacer.wait()
164
+ const response = await fetch(`https://api.example.com/contacts?${params}`, {
165
+ headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
166
+ })
167
+ const data = await response.json()
168
+ const records: Array<{
169
+ id: string
170
+ name: string
171
+ updated_at: string
172
+ }> = data.contacts
173
+ const done = records.length < BATCH_SIZE
174
+
175
+ // Consistency buffer: never advance the cursor closer than 15s to "now".
176
+ // In incremental mode the cursor never resets, so if we advance past a record
177
+ // that hasn't been indexed yet, it's lost permanently.
178
+ const bufferTs = new Date(Date.now() - CONSISTENCY_BUFFER_MS).toISOString()
179
+ const last = records[records.length - 1]
180
+
181
+ let nextCursorTs: string
182
+ let nextCursorId: string
183
+ if (done) {
184
+ // Caught up — cap the cursor at the buffer boundary
185
+ nextCursorTs =
186
+ last && last.updated_at < bufferTs
187
+ ? last.updated_at
188
+ : state.cursorTimestamp < bufferTs
189
+ ? bufferTs
190
+ : state.cursorTimestamp
191
+ nextCursorId = last?.id ?? state.cursorId
192
+ } else {
193
+ // More pages — advance cursor to last record on this page
194
+ nextCursorTs = last.updated_at
195
+ nextCursorId = last.id
196
+ }
197
+
198
+ return {
199
+ changes: records.map(toUpsert),
200
+ hasMore: !done,
201
+ nextState: {
202
+ cursorTimestamp: nextCursorTs,
203
+ cursorId: nextCursorId,
204
+ },
205
+ }
206
+ },
207
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Delta sync — event-feed pattern (Stripe-style).
3
+ *
4
+ * This is the delta half of a backfill+delta pair. It reads from an events/
5
+ * changelog endpoint to detect changes incrementally. A separate replace-mode
6
+ * backfill sync (not shown here) handles the full dataset load.
7
+ *
8
+ * The event feed provides a reliable change stream: each event has a unique ID
9
+ * that serves as a cursor. Events can produce both upserts and deletes.
10
+ *
11
+ * Key points:
12
+ * - State is just { eventCursor: string } — no phase discrimination
13
+ * - First run: fetch the latest event ID as the starting cursor
14
+ * - Consistency buffer: skip events younger than 10 seconds (they may not be
15
+ * fully consistent yet, and since the cursor never resets, skipping = permanent loss)
16
+ * - Events can map to both upserts and deletes
17
+ *
18
+ * For the backfill half, use a replace-mode sync targeting the same database —
19
+ * see replace-paginated.ts for the pattern. Trigger it via CLI:
20
+ * ntn workers sync state reset customersBackfill && ntn workers sync trigger customersBackfill
21
+ */
22
+
23
+ import { Worker } from "@notionhq/workers"
24
+ import * as Builder from "@notionhq/workers/builder"
25
+ import * as Schema from "@notionhq/workers/schema"
26
+
27
+ const worker = new Worker()
28
+ export default worker
29
+
30
+ // Database shared between backfill (replace) and this delta (incremental) sync
31
+ const customers = worker.database("customers", {
32
+ type: "managed",
33
+ initialTitle: "Customers",
34
+ primaryKeyProperty: "Customer ID",
35
+ schema: {
36
+ properties: {
37
+ Name: Schema.title(),
38
+ "Customer ID": Schema.richText(),
39
+ },
40
+ },
41
+ })
42
+
43
+ // Rate-limit API calls — shared across all syncs hitting this API
44
+ const apiPacer = worker.pacer("stripe", {
45
+ allowedRequests: 25,
46
+ intervalMs: 1000,
47
+ })
48
+
49
+ const CONSISTENCY_BUFFER_SECONDS = 10
50
+
51
+ type DeltaState = { eventCursor: string }
52
+
53
+ // Delta sync: reads the event feed for incremental changes
54
+ worker.sync("customersDelta", {
55
+ database: customers,
56
+ mode: "incremental",
57
+ schedule: "5m",
58
+ execute: async (state: DeltaState | undefined) => {
59
+ // First run: capture the latest event ID as the starting cursor.
60
+ // We don't process any events on the first run — the backfill sync
61
+ // handles the full dataset. This just establishes "start here" for
62
+ // future delta cycles.
63
+ if (!state) {
64
+ await apiPacer.wait()
65
+ const anchorResponse = await apiCall("/v1/events?limit=1")
66
+ const eventCursor = anchorResponse.data[0]?.id ?? ""
67
+ return {
68
+ changes: [],
69
+ hasMore: false,
70
+ nextState: { eventCursor },
71
+ }
72
+ }
73
+
74
+ // Read events from the changelog endpoint.
75
+ // Events are returned in reverse-chronological order, so we read backwards
76
+ // from the latest event to our cursor position.
77
+ await apiPacer.wait()
78
+ const response = await apiCall(
79
+ `/v1/events?limit=100&ending_before=${state.eventCursor}`,
80
+ )
81
+ const events: Array<{
82
+ id: string
83
+ type: string
84
+ created: number
85
+ data: { object: { id: string; name: string } }
86
+ }> = response.data
87
+
88
+ // Consistency buffer: skip events younger than 10 seconds.
89
+ // The event stream may not be fully consistent for very recent events.
90
+ // Since the cursor never resets, advancing past an inconsistent event
91
+ // means we'd miss the final state of that record permanently.
92
+ const cutoff = Date.now() / 1000 - CONSISTENCY_BUFFER_SECONDS
93
+ const safeEvents = events.filter(e => e.created < cutoff)
94
+
95
+ // Map events to changes (upserts or deletes)
96
+ const changes = safeEvents.map(event => {
97
+ if (event.type.endsWith(".deleted")) {
98
+ return { type: "delete" as const, key: event.data.object.id }
99
+ }
100
+ return toUpsert(event.data.object)
101
+ })
102
+
103
+ // Only advance cursor if we have safe events to process.
104
+ // If all events are too recent, cursor stays put — we'll re-check next cycle.
105
+ const lastSafe = safeEvents[safeEvents.length - 1]
106
+ const nextCursor = lastSafe?.id ?? state.eventCursor
107
+
108
+ return {
109
+ changes,
110
+ hasMore: response.has_more && safeEvents.length > 0,
111
+ nextState: { eventCursor: nextCursor },
112
+ }
113
+ },
114
+ })
115
+
116
+ function toUpsert(customer: { id: string; name: string }) {
117
+ return {
118
+ type: "upsert" as const,
119
+ key: customer.id,
120
+ properties: {
121
+ Name: Builder.title(customer.name),
122
+ "Customer ID": Builder.richText(customer.id),
123
+ },
124
+ }
125
+ }
126
+
127
+ async function apiCall(path: string) {
128
+ const response = await fetch(`https://api.stripe.com${path}`, {
129
+ headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
130
+ })
131
+ return response.json()
132
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Replace mode — paginated API.
3
+ *
4
+ * Fetches all records page by page. Each cycle re-fetches everything.
5
+ * The runtime deletes any records not seen during the cycle.
6
+ *
7
+ * This pattern is also used for backfill syncs (with schedule: "manual"),
8
+ * where you want to re-import all data on demand rather than on a timer.
9
+ *
10
+ * Key points:
11
+ * - State is just within-cycle pagination: { page: number }
12
+ * - State effectively resets between cycles (each cycle starts from page 1)
13
+ * - hasMore: true while there are more pages to fetch
14
+ * - Batch size ~100 to avoid overloading a single execute call
15
+ * - A pacer rate-limits API calls to avoid hitting upstream rate limits
16
+ */
17
+
18
+ import { Worker } from "@notionhq/workers"
19
+ import * as Builder from "@notionhq/workers/builder"
20
+ import * as Schema from "@notionhq/workers/schema"
21
+
22
+ const worker = new Worker()
23
+ export default worker
24
+
25
+ const productsDb = worker.database("productsDb", {
26
+ type: "managed",
27
+ initialTitle: "Products",
28
+ primaryKeyProperty: "Product ID",
29
+ schema: {
30
+ properties: {
31
+ Name: Schema.title(),
32
+ "Product ID": Schema.richText(),
33
+ },
34
+ },
35
+ })
36
+
37
+ // Rate-limit API calls: 10 requests per second
38
+ const apiPacer = worker.pacer("exampleApi", {
39
+ allowedRequests: 10,
40
+ intervalMs: 1000,
41
+ })
42
+
43
+ // State is simple — just track which page we're on within this cycle
44
+ type PaginationState = { page: number }
45
+
46
+ worker.sync("productsSync", {
47
+ database: productsDb,
48
+ mode: "replace",
49
+ execute: async (state: PaginationState | undefined) => {
50
+ const page = state?.page ?? 1
51
+ const pageSize = 100
52
+
53
+ // Wait for the pacer before each API call
54
+ await apiPacer.wait()
55
+
56
+ // Fetch one page from the API
57
+ const response = await fetch(
58
+ `https://api.example.com/products?page=${page}&limit=${pageSize}`,
59
+ { headers: { Authorization: `Bearer ${process.env.API_TOKEN}` } },
60
+ )
61
+ const data = await response.json()
62
+
63
+ const hasMore = data.products.length === pageSize
64
+
65
+ return {
66
+ changes: data.products.map((product: { id: string; name: string }) => ({
67
+ type: "upsert" as const,
68
+ key: product.id,
69
+ properties: {
70
+ Name: Builder.title(product.name),
71
+ "Product ID": Builder.richText(product.id),
72
+ },
73
+ })),
74
+ hasMore,
75
+ // Next page, or undefined if done (cycle complete)
76
+ nextState: hasMore ? { page: page + 1 } : undefined,
77
+ }
78
+ },
79
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Replace mode — static data, no pagination.
3
+ *
4
+ * The simplest possible sync. Returns hardcoded data with no API calls.
5
+ * Good for: testing the sync setup, syncing small static datasets.
6
+ *
7
+ * Key points:
8
+ * - mode: "replace" means the runtime deletes any records not returned each cycle
9
+ * - No state needed — there's no pagination and no cursor
10
+ * - hasMore: false on every call — single-page cycle
11
+ * - Database is declared separately via worker.database() and referenced by handle
12
+ */
13
+
14
+ import { Worker } from "@notionhq/workers"
15
+ import * as Builder from "@notionhq/workers/builder"
16
+ import * as Schema from "@notionhq/workers/schema"
17
+
18
+ const worker = new Worker()
19
+ export default worker
20
+
21
+ const teamDb = worker.database("teamDb", {
22
+ type: "managed",
23
+ initialTitle: "Team Members",
24
+ primaryKeyProperty: "Member ID",
25
+ schema: {
26
+ properties: {
27
+ Name: Schema.title(),
28
+ "Member ID": Schema.richText(),
29
+ },
30
+ },
31
+ })
32
+
33
+ worker.sync("teamSync", {
34
+ database: teamDb,
35
+ mode: "replace",
36
+ execute: async () => {
37
+ // In a real sync, you'd fetch this from an API
38
+ const members = [
39
+ { id: "m-1", name: "Alice" },
40
+ { id: "m-2", name: "Bob" },
41
+ { id: "m-3", name: "Charlie" },
42
+ ]
43
+
44
+ return {
45
+ changes: members.map(m => ({
46
+ type: "upsert" as const,
47
+ key: m.id,
48
+ properties: {
49
+ Name: Builder.title(m.name),
50
+ "Member ID": Builder.richText(m.id),
51
+ },
52
+ })),
53
+ // No more pages — cycle is complete
54
+ hasMore: false,
55
+ }
56
+ },
57
+ })
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: sync-validate
3
+ description: Review a sync capability for common bugs — cursor advancement, pagination termination, state persistence, bi-modal correctness, consistency buffers, and deletion handling
4
+ user-invocable: true
5
+ disable-model-invocation: true
6
+ allowed-tools: ["Read", "Glob", "Grep", "Bash"]
7
+ ---
8
+
9
+ ## Instructions
10
+
11
+ Read the sync capabilities in `src/index.ts` (and any imported modules). For each sync found, run through the checklist below. Report findings grouped by severity.
12
+
13
+ Before starting, read `.agents/skills/sync-guide/SKILL.md` for the full sync concepts reference.
14
+
15
+ ### Critical Issues (will break the sync or cause data loss)
16
+
17
+ 1. **No pagination termination**: Does `hasMore` eventually become `false`? Look for: infinite loops where `nextState` doesn't advance, missing base cases, conditions that can never be met.
18
+
19
+ 2. **Cursor doesn't advance**: Does `nextState` change between iterations? If the cursor is the same as the previous state, the sync will loop forever. Check that each execute call makes progress.
20
+
21
+ 3. **Missing first-run handling**: When `state` is `undefined` (first run), does the code handle it gracefully? Look for: `state.cursor` without `state?.cursor`, property access on potentially undefined state.
22
+
23
+ 4. **Batch too large**: Is the sync returning thousands of changes in one execution? Recommend batches of ~100. Large batches will fail.
24
+
25
+ 5. **Replace mode when API supports change tracking**: If mode is `replace` (or unset — it defaults to `replace`), does the source API support `updated_at` filters, event feeds, or similar change tracking? If so, recommend switching to `incremental` to avoid re-fetching everything each cycle.
26
+
27
+ 6. **State persistence misunderstanding**: In incremental mode, the cursor never resets between cycles. The next cycle starts exactly where the last one left off. Check for code that assumes a fresh start each cycle — this will cause records to be re-fetched or skipped permanently.
28
+
29
+ ### Structural Issues (bi-modal correctness)
30
+
31
+ 7. **Single-mode cursor for incremental sync**: Is the sync using the same cursor strategy for both backfill and delta? Unless the API sorts by `updated_at` and uses an opaque cursor (where one cursor naturally serves both), the sync should have a discriminated state union with separate backfill and delta phases.
32
+
33
+ 8. **Missing backfill-to-delta transition**: For bi-modal syncs, how is the delta cursor seeded? It must come from a marker captured *before* the backfill started (event anchor, timestamp with overlap), NOT from the last record in the backfill. Otherwise, changes during the backfill window are lost permanently.
34
+
35
+ 9. **No overlap in transition**: When transitioning from backfill to delta, is there an overlap window (e.g., `backfillStartedAt - 5 minutes`)? Without overlap, records modified during the backfill but before the delta cursor starts are missed permanently.
36
+
37
+ ### Warnings (may cause subtle data quality bugs)
38
+
39
+ 10. **No consistency buffer**: For incremental syncs hitting eventually consistent APIs, the cursor should lag behind "now" by 10-60 seconds. Without this, the cursor can advance past records that haven't been indexed by the source API yet — those records are lost permanently since the cursor never resets.
40
+
41
+ 11. **Timestamp cursor without tie-breaking**: If using `updated_at` as a cursor, can multiple records share the same timestamp? If yes (batch imports, bulk updates, low-resolution timestamps), recommend the keyset pattern: `(timestamp, id)` with a query like `WHERE ts > X OR (ts = X AND id > Y)`.
42
+
43
+ 12. **Missing delete handling**: In incremental mode, are deletions handled? Check:
44
+ - Does the source API have a delete signal (audit log, archived filter, events)?
45
+ - If yes, is the sync emitting `{ type: "delete", key }` markers?
46
+ - If the delete signal is on a separate endpoint, is the flip-flop pattern used (alternate streams at cycle boundaries)?
47
+ - If no delete signal exists, should this be a replace-mode sync instead?
48
+
49
+ 13. **Hardcoded secrets**: Are API keys, tokens, or credentials in the code instead of `process.env`? Flag any string that looks like a secret.
50
+
51
+ 14. **Missing error handling on fetch**: Network calls without error handling will crash the sync on any transient failure. Consider whether the sync should catch and handle API errors or let them propagate (the runtime will retry the cycle).
52
+
53
+ ### Output Format
54
+
55
+ For each issue found:
56
+ - **What**: Name the issue and point to the specific code location
57
+ - **Why it matters**: What will happen in production if this isn't fixed
58
+ - **Fix**: Provide a concrete code snippet showing the fix
59
+
60
+ If no issues are found, say so. Don't invent problems.
@@ -0,0 +1,2 @@
1
+ .env
2
+ .env.*
@@ -0,0 +1,2 @@
1
+ .env
2
+ .env.*