feat: head-side dual-frontier sync contract (Phase A.1)#50
Merged
Conversation
Extend the fetchPage contract to FetchContext ({ cursor, sinceItemId,
phase }) and implement the full head-side frontier logic:
- A.1.1: FetchContext interface, updated Connector.fetchPage signature
- A.1.2: Forward only writes tailCursor during initial sync (scope fix)
- A.1.3: headItemId written only on page 0 of fresh forward, monotonic
- A.1.4: headCursor activated for forward interrupt/resume
- A.1.5: Engine early-exit when page contains sinceItemId
- A.1.6: Anchor invalidation recovery (clear headItemId on miss)
- A.1.7: Twitter connector adapted to FetchContext (ignores new fields)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 tests covering all A.1.x behaviors: - FetchContext passing (sinceItemId, phase, cursor) - tailCursor scope (initial sync handoff vs subsequent cycles) - headItemId write timing (page 0 only, monotonic, skip on resume) - headCursor resume (clear on completion, preserve on interrupt) - Early-exit on sinceItemId (reached_since stop reason) - Anchor invalidation (clear headItemId when anchor not hit) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- fetchPage signature updated to FetchContext in all code examples - SyncState comments describe headCursor/headItemId semantics - Stop conditions include reached_since and interrupt/resume behavior - Checkpoint section reflects headCursor crash recovery - Runtime lifecycle updated with forward resume and anchor invalidation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Spool's connector system was designed with a dual-frontier sync model (head + tail frontiers advancing independently), but the initial implementation in PR #39 only landed the tail side (backfill). The head side had four problems:
fetchPagecontract lacked context: The signature was justcursor: string | null— the engine had no way to passsinceItemId(the newest known item) to the connector. Forward sync couldn't stop efficiently: it relied on stale-page detection (3 consecutive pages of all-duplicates) instead of precise anchor matching (stop on 1 page).headItemIdwritten on every page: Each page overwrote it with that page's first item. After 20 pages,headItemIdpointed to "the 380th newest item" instead of "the 1st newest item".headCursornever activated: The architecture doc and DB schema both reserved a forward-resume cursor, but no code ever read or wrote it. In long-offline scenarios, accumulated new data couldn't be fetched incrementally across cycles.tailCursor: The initial-sync "handoff to backfill" logic lacked scope guards. On subsequent cycles, forward overwrote backfill's deep progress with a shallow position near the newest end, permanently stalling history completion.What this PR does
Implements Phase A.1 (head-side frontier complete landing), in dependency order:
FetchContextinterface{ cursor, sinceItemId, phase }, changefetchPagesignature to(ctx: FetchContext)tailCursorduring initial sync (tailCursor === null && headCursor === null)headItemIdonly written on page 0 + not resuming + monotonic-forward checkheadCursor: written during forward, cleared on normal completion, preserved on interruptionplatformId === sinceItemId, stop on hitheadItemIdFetchContext, destructures{ cursor }and ignores new fieldsForward stop conditions (after this PR)
reached_sincecaught_upend_of_datatimeout/cancelled/errorAnchor invalidation recovery
If the user un-bookmarks the item that
headItemIdpoints to, it disappears from the platform API. Forward runs a full pass without hittingsinceItemId. The engine then clearsheadItemIdautomatically — next forward starts fresh from null and re-establishes the anchor from page 0. Cost: one degraded cycle (falls back to stale-page detection), but only happens once.Tests
15 new tests in
sync-engine.test.tsusing in-memory SQLite + mock connector, covering all A.1.x behaviors:Docs
connector-sync-architecture.md: SyncState field comments, stop conditions, checkpoint & crash recovery, Writing a Connector exampleconnector-developer-guide.md: FetchContext interface, fetchPage documentation, code examples, runtime lifecycleNot in this PR
last_error_atcolumn) — decoupled from head-side, separate PRpageDelayMsdefault consistency) — separate PRTest plan
npx tsc --noEmitpassesreached_sinceand stops in 1 page on incremental sync🤖 Generated with Claude Code