executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
sync-guideComprehensive guide to building Notion Workers syncs — covers the two-sync architecture (backfill+delta), replace mode, pagination, consistency buffers, pacers, deletion strategies, and common pitfalls. Auto-loads when sync-related work is detected.
Independent GitHub snapshot · Automated classification · Original documentation governs
A sync is a recurring execute function that returns data changes to populate a Notion database. The runtime calls execute in a loop:
const db = worker.database("myDb", {
type: "managed",
initialTitle: "My Data",
primaryKeyProperty: "ID",
schema: {
properties: {
Name: Schema.title(),
ID: Schema.richText(),
},
},
})
worker.sync("mySync", {
database: db,
execute: async (state, { notion }) => ({
changes: [
{
type: "upsert",
key: "1",
properties: {
Name: Builder.title("Item 1"),
ID: Builder.richText("1"),
},
},
],
hasMore: false,
nextState: undefined,
}),
})
Each call returns { changes, hasMore, nextState }. If hasMore is true, the runtime calls execute again with nextState. This continues until hasMore is false, completing a cycle. The next cycle begins at the scheduled interval with the state from the end of the previous cycle.
Imports:
import { Worker } from "@notionhq/workers"
import * as Builder from "@notionhq/workers/builder"
import * as Schema from "@notionhq/workers/schema"
The deciding factor is API capability and dataset size. Two tiers:
| Condition | Architecture |
|---|---|
| Small source (<1k records) or API with no change tracking | Simple replace sync — one sync, mode: "replace" |
Everything else (API supports updated_at, change feeds, events) | Backfill + delta pair — two syncs writing to the same database |
Simple replace sync: One sync returns the full dataset each cycle. After the final hasMore: false, any records not seen are deleted automatically. Use when the dataset is small enough to re-fetch entirely.
Backfill + delta pair: Two syncs share a single database. The backfill sync (mode: "replace", schedule: "manual") re-fetches everything when triggered. The delta sync (mode: "incremental", frequent schedule) fetches only changes since the last run. This separates concerns cleanly — no bi-modal state machine, no backfill-to-delta transition bugs.
Initialize the delta cursor before starting the backfill. When both syncs can
write the same record, include upstreamUpdatedAt on each upsert so a stale
backfill response cannot overwrite a newer delta response.
Most APIs require paginating through results. Return batches of ~100 changes. Returning too many changes in one execute call will fail.
Backfill pagination (full dataset load):
endCursor, Stripe starting_after?page=N&limit=100WHERE created_at > X OR (created_at = X AND id > Y) — the gold standard for timestamp-sorted mutable dataDelta pagination (change-only loads, incremental mode):
?updated_since=<cursor> with consistency bufferGET /events?after=<eventId>updated_at, the backfill cursor works for delta tooAPIs tend to be eventually consistent. A record that was just written or updated may not appear in query results immediately. Since the cursor never resets in incremental mode, if it advances past a record that hasn't been indexed yet, that record is skipped permanently. Lag the cursor 10-60 seconds behind "now":
const bufferMs = 15_000
const maxCursor = new Date(Date.now() - bufferMs).toISOString()
function minTimestamp(a: string, b: string): string {
return Date.parse(a) <= Date.parse(b) ? a : b
}
const nextCursor =
records.length > 0 ? minTimestamp(lastRecord.updatedAt, maxCursor) : maxCursor
{ type: "delete", key } markers. If the delete signal comes from a separate endpoint (audit log, archived filter), use the flip-flop pattern: run the main delta stream until caught up (hasMore: false), then switch to the delete stream for a cycle, then back. Both cursors persist in state independently.Simple: fetch everything, return it all, let the runtime handle deletes. Use as a standalone sync for small sources, or as the backfill half of a backfill+delta pair.
const db = worker.database("records", {
type: "managed",
initialTitle: "Records",
primaryKeyProperty: "ID",
schema: {
properties: { Name: Schema.title(), ID: Schema.richText() },
},
})
const apiPacer = worker.pacer("myApi", {
allowedRequests: 10,
intervalMs: 1000,
})
worker.sync("recordsBackfill", {
database: db,
mode: "replace",
schedule: "manual", // trigger manually or on a slow schedule
execute: async (state) => {
const page = state?.page ?? 1
await apiPacer.wait()
const { items, totalPages } = await fetchPage(page, 100)
const hasMore = page < totalPages
return {
changes: items.map((item) => ({
type: "upsert" as const,
key: item.id,
properties: {
Name: Builder.title(item.name),
ID: Builder.richText(item.id),
},
})),
hasMore,
nextState: hasMore ? { page: page + 1 } : undefined,
}
},
})
See examples/replace-simple.ts and examples/replace-paginated.ts for complete working examples.
The delta sync fetches only changes since the last run. When paired with a replace-mode backfill sync on the same database, this replaces the old bi-modal single-sync pattern.
// Reuses the same `db` and `apiPacer` from above
worker.sync("recordsDelta", {
database: db,
mode: "incremental",
schedule: "5m",
execute: async (state: { cursor: string } | undefined) => {
const cursor = state?.cursor ?? new Date(0).toISOString()
const bufferTs = new Date(Date.now() - 15_000).toISOString()
await apiPacer.wait()
// fetchChanges must apply bufferTs as an upstream upper bound.
const { items, nextCursor } = await fetchChanges(cursor, bufferTs)
const done = !nextCursor
return {
changes: items.map(toUpsert),
hasMore: !done,
nextState: {
// This example assumes nextCursor is an ISO timestamp. Opaque cursors
// must not be compared with the timestamp buffer.
cursor: done
? minTimestamp(nextCursor ?? cursor, bufferTs)
: nextCursor,
},
}
},
})
Key points:
db handle.See examples/incremental-basic.ts, examples/incremental-bimodal.ts, and examples/incremental-events.ts for complete patterns.
Define the Notion database shape with Schema types and build values with Builder:
| Schema type | Builder value | Notes |
|---|---|---|
Schema.title() | Builder.title("text") | Primary display field. Every schema needs exactly one. |
Schema.richText() | Builder.richText("text") | Text content, IDs |
Schema.url() | Builder.url("https://...") | URL field |
Schema.email() | Builder.email("a@b.com") | Email field |
Schema.phoneNumber() | Builder.phoneNumber("+1...") | Phone field |
Schema.checkbox() | Builder.checkbox(true) | Boolean |
Schema.file() | Builder.file("https://...", "name") | File URL + optional display name |
Schema.number() | Builder.number(42) | Number. Optional format: Schema.number("percent") |
Schema.date() | Builder.date("2024-01-15") | Date (YYYY-MM-DD). Also: Builder.dateTime("2024-01-15T10:30:00Z"), Builder.dateRange(start, end) |
Schema.select([...]) | Builder.select("Option A") | Single select. Define options: Schema.select([{ name: "A" }, { name: "B" }]). Options must have non-empty name values — Schema.select([]) and { name: "" } are not supported. |
Schema.multiSelect([...]) | Builder.multiSelect("A", "B") | Multi select |
Schema.status(...) | Builder.status("Done") | Status with groups |
Schema.people() | Builder.people("email@co.com") | People by email |
Schema.place() | Builder.place({ lat: 40.7, lon: -74.0 }) | Geographic location |
Schema.relation("databaseKey") | [Builder.relation("pk")] | Relation to another managed database. Value is an array. |
Relations use the related database key. Two-way relations are configured the same way:
Schema.relation("otherDatabase", {
twoWay: true,
relatedPropertyName: "Back Link",
})
Row-level icons and page content:
changes: [
{
type: "upsert",
key: "1",
properties: {
Name: Builder.title("Example"),
// ... other properties ...
},
icon: Builder.emojiIcon("🎯"), // or Builder.notionIcon("rocket", "blue")
pageContentMarkdown: "## Details\nSome text", // Markdown body for the page
},
]
execute should be preceded by await apiPacer.wait(). Without it, syncs will hit rate limits and fail.nextState changes between iterations.state is undefined on first call. Use state?.cursor ?? null.worker.database() handle and the same key/properties shape.schedule: "manual" won't run automatically. Trigger it on deploy or periodically to clean up deleted records.Schema.select() requires at least one option with a non-empty name. Schema.select([]) and { name: "" } are not supported.# Deploy
ntn workers deploy
# Preview (test without writing)
ntn workers sync trigger <key> --preview
ntn workers sync trigger <key> --preview --context '<json>' # continue pagination
# Trigger a sync run
ntn workers sync trigger <key>
# Check sync status
ntn workers sync status
# View run logs
ntn workers runs list
ntn workers runs list --plain | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
# Reset state (full re-backfill)
ntn workers sync state reset <key>
# Manage secrets
ntn workers env set KEY=value
ntn workers env push
See api-pagination-patterns.md for detailed strategies drawn from production syncs with Salesforce, Stripe, HubSpot, GitHub, and ServiceNow.