Skip to content

feat(sync): incremental pull — fetch only changed pages - #6

Merged
NikhilVerma merged 2 commits into
mainfrom
feat/incremental-sync
Aug 4, 2026
Merged

feat(sync): incremental pull — fetch only changed pages#6
NikhilVerma merged 2 commits into
mainfrom
feat/incremental-sync

Conversation

@vizkid

@vizkid vizkid commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #5.

What

sync now diffs the page tree against the index before fetching content, and only fetches pages that actually changed. The tree walk (buildTree) already carries every page's real last_edited_time and costs seconds — the block fetch is the expensive part, and it now runs only for the changed set via the existing fetchBlocksFiltered.

Skip rule (planSync, pure + unit-tested): a page is skipped only when its last_edited_time, title, and computed path all match the index, and the file exists on disk. Edited, renamed, moved (ancestor rename shifts the whole subtree's paths), missing, or new pages all re-fetch. sync --force restores unconditional re-fetch.

The stamping bug: the index recorded lastEdited: new Date() — the sync time — making incremental diffing impossible (every entry in a real index carries the identical timestamp). It now records the page's real last_edited_time (engine + partial sync). Pre-incremental indexes match nothing, so the first run after upgrading re-fetches everything once and heals the index; no migration needed.

Writer: takes skipIds — skipped pages keep their bytes, still reserve filenames for sibling dedup, and still appear in results (written: false) so index bookkeeping and stale-file removal stay correct.

Latent bug found by the new tests

uniqueFilename compared bare filenames against a set of full paths, so same-title sibling leaves never deduped — the second silently overwrote the first. Dedup now runs on the full path (uniqueness per directory). Covered by a regression test.

Tests

bun test: 142 pass / 0 fail (10 new: 8 planner scenarios incl. the pre-incremental-index migration path, plus writer skip-behavior tests against a real temp dir — sentinel bytes survive, mtime untouched, sibling dedup preserved, computeLinkMap paths proven identical to writePageTree output). oxlint and tsc --noEmit clean.

Draft until

Live validation against stance.ai's real 219-page mirror (heal run → no-op run timing) — blocked momentarily on a 1Password unlock; numbers will be posted here before marking ready.

Summary by CodeRabbit

  • New Features

    • Added incremental synchronization to update only new or changed pages.
    • Added a force option to perform a complete refresh.
    • Improved sync reporting with fetch and write counts.
  • Bug Fixes

    • Preserved unchanged files, timestamps, and links during incremental syncs.
    • Improved handling of renamed, missing, stale, and normalized page identifiers.
    • Sync records now use the source page’s actual last-edited time.

Closes #5.

The tree walk (buildTree) already carries every page's real
last_edited_time and costs seconds; the block fetch is the expensive
part. Diff the tree against the index before fetching:

- planSync classifies each page: skip only when last_edited, title, and
  computed path all match the index AND the file exists on disk; anything
  else (edited, renamed, moved via ancestor rename, missing, new)
  re-fetches. fetchBlocksFiltered (already used by --pages) fetches just
  the changed set.
- The writer takes skipIds: skipped pages keep their bytes, still
  reserve filenames for sibling dedup, and still appear in results
  (written: false) so index bookkeeping and stale-removal stay correct.
- The index now records each page's REAL last_edited_time instead of the
  sync time (engine + partial) — the stamping bug that made incremental
  diffing impossible. Pre-incremental indexes match nothing, so the
  first run after upgrading re-fetches everything once and heals.
- sync --force restores unconditional re-fetch.

Also fixes a latent writer bug the new tests exposed: uniqueFilename
compared bare filenames against a set of full paths, so same-title
sibling leaves never deduped and silently overwrote each other; dedup
now runs on the full path (per-directory uniqueness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 36fce6af-f067-4904-a088-e276df927ffe

📥 Commits

Reviewing files that changed from the base of the PR and between f71edc6 and fa18ebd.

📒 Files selected for processing (5)
  • src/markdown/links.ts
  • src/markdown/writer.ts
  • src/sync/__tests__/incremental.test.ts
  • src/sync/engine.ts
  • src/sync/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/markdown/writer.ts
  • src/sync/engine.ts

Walkthrough

The CLI adds --force/-f and forwards it to sync. The sync engine plans changes from page metadata, fetches blocks only for new or changed pages unless forced, and updates the index with actual edit timestamps. The writer computes link maps, avoids duplicate paths, skips unchanged pages, and records link targets. Page ID matching supports dashed and undashed forms. Tests cover planning, writing, path reservation, and link cascades.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: incremental sync that fetches only changed pages.
Linked Issues check ✅ Passed The changes satisfy issue [#5] by adding real edit timestamps, incremental planning, force sync, deletion handling, and targeted fetching.
Out of Scope Changes check ✅ Passed The changes remain within the incremental sync scope, including related link invalidation, path deduplication, tests, and index updates.

Comment @coderabbitai help to get the list of available commands.

@vizkid

vizkid commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Live validation against stance.ai's real mirror (221 pages, scratch copy, real NOTION_TOKEN):

Run Scenario Result Time
1 Pre-incremental index (sync-time stamps) All 221 re-fetched once, index healed — the designed migration path ~3 min (unchanged from today)
2 Nothing changed "Everything up to date — nothing to fetch", 0 written 19 s total (was ~3 min)
3 One file deleted locally + one page edited in Notion mid-run "2 changed, 219 unchanged skipped", deleted file restored byte-correct fetch phase 0.3 s (plus an 11-min Notion Retry-After wait from hammering 4 full syncs in 10 min while testing — the tool honored it; fewer API calls per sync is exactly the point)

Marking ready for review.

@vizkid
vizkid marked this pull request as ready for review August 3, 2026 08:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/sync/__tests__/incremental.test.ts (1)

31-119: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Solid planner coverage.

One gap: no test asserts that a page missing from the tree but present in the index becomes stale under mixed dash formats. That is the findStalePages normalization change, and it touches file deletion. A regression there deletes live docs.

🧪 Suggested extra test
it("treats a dashed index key as live when the tree uses the undashed id", () => {
  const dashed = "2c3e4ea0-0265-8010-980c-d9e2fab0643e";
  const undashed = dashed.replace(/-/g, "");
  // exercise findStalePages via an exported helper or sync-level test
  // expect: no stale entry for `dashed` when results contain `undashed`
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sync/__tests__/incremental.test.ts` around lines 31 - 119, Add regression
coverage for findStalePages to verify dashed and undashed page IDs are
normalized before stale detection: when the index contains the dashed ID and the
tree contains the equivalent undashed ID, the entry must not be marked stale or
deleted. Exercise the exported helper or sync-level path and assert no stale
result for the live dashed entry.
src/markdown/writer.ts (1)

93-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good catch on the full-path dedup.

usedFilenames stores full paths, so the old bare-filename compare never matched. Both passes now agree, which keeps computeLinkMap and writePageTree consistent.

One nit: the leaf path logic is now duplicated in buildLinkMap and writePageRecursive. A shared leafPath(dirPath, title, used) helper would stop the two passes drifting again.

Also applies to: 150-150

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/markdown/writer.ts` around lines 93 - 96, Extract the duplicated
leaf-path construction from buildLinkMap and writePageRecursive into a shared
leafPath(dirPath, title, used) helper. Have both call it with the same
directory, page title, and used-path set, preserving slugification, “.md”
suffixing, and full-path deduplication.
src/sync/engine.ts (1)

171-195: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Index keys drift between formats across runs.

pages is keyed by result.pageId, which is the tree node ID. partial.ts keys entries by the format already present in the index. So a full sync and a partial sync can write two entries for the same page in different dash formats.

findStalePages normalizes, so no file is wrongly deleted. But the index can hold duplicate entries. Normalize the key here to keep one canonical format.

♻️ Normalize index keys
-  for (const [pageId, result] of results) {
+  for (const [rawPageId, result] of results) {
+    const pageId = normalizeId(rawPageId);
     if (result.written) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sync/engine.ts` around lines 171 - 195, Normalize each page ID before
assigning entries in the pages map within the index-update loop, using the same
canonical format as lookupPageState and partial sync. Keep the existing
PageState selection and lastEdited behavior unchanged, but replace the raw
result/page ID key so full and partial syncs cannot create duplicate entries for
one page.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sync/__tests__/incremental.test.ts`:
- Around line 23-25: Reformat the node helper declaration in the node function
so it complies with the repository’s oxfmt line-width rules. Run bun run format
or oxfmt without --check and commit the formatter’s output, without changing the
helper’s behavior.

In `@src/sync/engine.ts`:
- Around line 55-91: Update planSync to account for path changes of existing
pages: identify pages whose computed path differs from oldState.path, then
ensure pages that could contain links to those moved or renamed pages are not
left in unchangedIds (the minimal safe behavior is to promote all otherwise
unchanged pages to changed). Preserve newCount and normal unchanged
classification when no page path has moved.

---

Nitpick comments:
In `@src/markdown/writer.ts`:
- Around line 93-96: Extract the duplicated leaf-path construction from
buildLinkMap and writePageRecursive into a shared leafPath(dirPath, title, used)
helper. Have both call it with the same directory, page title, and used-path
set, preserving slugification, “.md” suffixing, and full-path deduplication.

In `@src/sync/__tests__/incremental.test.ts`:
- Around line 31-119: Add regression coverage for findStalePages to verify
dashed and undashed page IDs are normalized before stale detection: when the
index contains the dashed ID and the tree contains the equivalent undashed ID,
the entry must not be marked stale or deleted. Exercise the exported helper or
sync-level path and assert no stale result for the live dashed entry.

In `@src/sync/engine.ts`:
- Around line 171-195: Normalize each page ID before assigning entries in the
pages map within the index-update loop, using the same canonical format as
lookupPageState and partial sync. Keep the existing PageState selection and
lastEdited behavior unchanged, but replace the raw result/page ID key so full
and partial syncs cannot create duplicate entries for one page.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 220c62ed-f8de-4758-949a-5ca32640e2ab

📥 Commits

Reviewing files that changed from the base of the PR and between 6d8e16c and f71edc6.

📒 Files selected for processing (5)
  • src/cli.ts
  • src/markdown/writer.ts
  • src/sync/__tests__/incremental.test.ts
  • src/sync/engine.ts
  • src/sync/partial.ts

Comment thread src/sync/__tests__/incremental.test.ts Outdated
Comment thread src/sync/engine.ts
A skipped page's rendered content embeds relative paths to the pages it
links to, so it goes stale when a target is renamed/moved (or a
previously-unresolvable target now exists). The writer now records each
page's outgoing notion:// link IDs in the index (PageState.links), and
the planner demotes an otherwise-unchanged page to changed when any of
its targets' paths differ from last sync. Absent on pre-incremental
indexes, which re-fetch everything once anyway.

Also fixes the CI formatting failure on the new test file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vizkid

vizkid commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both findings addressed: formatting fixed, and the stale-outgoing-links edge is closed properly — the index now records each page's outgoing link IDs (PageState.links) at write time, and the planner re-renders any otherwise-unchanged page whose link target's path changed since last sync (covered by three new planner tests: target renamed, previously-unresolved target appearing, and stable-targets keeping the skip). 146 tests green.

@coderabbitai resolve

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

@vizkid Both fixes are acknowledged. The new PageState.links tracking and path-change re-render rule address the stale outgoing-link case.

✅ Action performed

Comments resolved and changes approved.

@NikhilVerma
NikhilVerma merged commit d0c145f into main Aug 4, 2026
7 checks passed
NikhilVerma pushed a commit that referenced this pull request Aug 4, 2026
Real-workspace testing against a live Notion fixture tree surfaced two
pruning bugs from Phase 3:

- shouldTraverseExcludedNode checked "does any include-id exist in the
  source" globally, forcing traversal into every excluded subtree
  (e.g. an unrelated Archive folder) whenever the source had ANY
  include-id override configured elsewhere.
- resolveNodeDecision had no ancestor-exclusion cascade, so a sibling
  of a buried include-override target (with no selector match of its
  own) silently defaulted back to "include" once its excluded parent
  was traversed.

Fix:
- resolveNodeDecision takes an ancestorExcluded flag; an unmatched
  node under an excluded ancestor now defaults to "exclude" instead
  of "include".
- shouldTraverseExcludedNode + a new shared, mutable
  pendingIncludeIds set (computePendingIncludeIds) scope id-based
  override search to targets not yet found, so traversal stops once
  every configured include id has actually been located.
- PageNode gets an `excluded` flag; fetchAllBlocks skips block-fetch
  for excluded nodes, and writer.ts skips writing an excluded node's
  own content while still writing non-excluded descendants nested
  under its directory.

Verified against a real Notion workspace fixture tree (multi-source,
id exclude, glob exclude via defaultExclude, buried-keeper
include-override, sibling non-leak, idempotent re-run).

Autonomous decisions:
- Excluded-but-traversed ancestor still creates its directory (for
  descendant paths) but writes no index.md of its own — matches
  Decision #6's steer toward a separate sources[] entry for genuinely
  clean output, this is the fallback path for buried keepers.
- Skipped a full fake-Client tree.ts integration test in favor of the
  real E2E Notion run (no existing client-mock infra in this repo) —
  selector.ts unit tests cover the pure logic, the live run covers
  end-to-end wiring.
NikhilVerma added a commit that referenced this pull request Aug 4, 2026
…ltering

Merged with conflict resolution against the incremental-sync work (#6)
that landed on main after this PR was opened:

- writer.ts: adapted excluded-node handling to the new options-object
  writePageRecursive signature.
- engine.ts: kept both incremental planning (force/planSync) and
  config-driven options (rootPageId/selectors/maxDepth); excluded nodes
  are filtered out of the sync plan since they're never written/indexed.
- tree.ts: fetchBlocksFiltered now skips excluded nodes, matching
  fetchAllBlocks, so excluded-but-traversed pages don't burn API calls.
- cli.ts: explicit --pages opts out of config auto-discovery so the flag
  isn't silently ignored when a notion-rsync.config.json exists.

Dropped from the contribution (not part of the feature):
- .cursor/environment.json (contributor's personal Cursor setup hook
  that executes a script cloned from an external repo)
- .gitignore un-ignore of docs/ (docs/ is the default sync output dir)
- docs/config-file-support-plan.md (fork-internal planning doc)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NikhilVerma added a commit that referenced this pull request Aug 4, 2026
Minor bump: main gained two features since 0.2.0 — incremental pull (#6)
and config-file multi-source sync (#4) — alongside the post-0.2.0 review
fixes. Also cuts the CHANGELOG for 0.2.0, which shipped without one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incremental sync: skip unchanged pages by diffing real last_edited against the manifest

2 participants