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.
- package/package.json +1 -1
- package/scripts/init.ts +39 -23
- package/scripts/utils/templates.ts +53 -8
- package/sdk-version.json +1 -1
- package/templates/worker/.agents/INSTRUCTIONS.md +593 -0
- package/templates/worker/.agents/skills/auth-guide/SKILL.md +227 -0
- package/templates/worker/.agents/skills/sync/SKILL.md +368 -0
- package/templates/worker/.agents/skills/sync-debug/SKILL.md +101 -0
- package/templates/worker/.agents/skills/sync-guide/SKILL.md +253 -0
- package/templates/worker/.agents/skills/sync-guide/api-pagination-patterns.md +661 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-basic.ts +103 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-bimodal.ts +207 -0
- package/templates/worker/.agents/skills/sync-guide/examples/incremental-events.ts +132 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-paginated.ts +79 -0
- package/templates/worker/.agents/skills/sync-guide/examples/replace-simple.ts +57 -0
- package/templates/worker/.agents/skills/sync-validate/SKILL.md +60 -0
- package/templates/worker/.claudeignore +2 -0
- package/templates/worker/.codexignore +2 -0
- package/templates/worker/.examples/automation-example.ts +60 -0
- package/templates/worker/.examples/oauth-example.ts +79 -0
- package/templates/worker/.examples/sync-example.ts +184 -0
- package/templates/worker/.examples/tool-example.ts +37 -0
- package/templates/worker/.examples/webhook-example.ts +66 -0
- package/templates/worker/README.md +765 -0
- package/templates/worker/_gitignore +6 -0
- package/templates/worker/docs/custom-tool.png +0 -0
- package/templates/worker/notionhq-workers-0.4.0.tgz +0 -0
- package/templates/worker/package.json +25 -0
- package/templates/worker/src/index.ts +8 -0
- package/templates/worker/tsconfig.json +16 -0
- package/templates/worker/views/empty/AGENTS.md +71 -0
- package/templates/worker/views/empty/README.md +10 -0
- package/templates/worker/views/empty/_gitignore +2 -0
- package/templates/worker/views/empty/custom_blocks.json +4 -0
- package/templates/worker/views/empty/index.html +15 -0
- package/templates/worker/views/empty/package.json +23 -0
- package/templates/worker/views/empty/src/index.css +33 -0
- package/templates/worker/views/empty/src/index.tsx +20 -0
- package/templates/worker/views/empty/tsconfig.json +17 -0
- 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.
|