Skip to content

feat: Notion mirror — one-to-one caching proxy of Notion's API - #180

Open
hhff wants to merge 30 commits into
mainfrom
worktree-notion-read-cache
Open

hhff wants to merge 30 commits into
mainfrom
worktree-notion-read-cache

Conversation

@hhff

@hhff hhff commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

Stacks becomes a one-to-one caching reverse proxy of Notion's REST API so stacksbot reads Notion through Stacks instead of spending Notion's per-connection and per-workspace request budget directly.

  • Stacks::Notion upgraded in place: Notion-Version 2026-03-11, HTTParty throughout, class-level pacer (NOTION_RPS), Retry-After handling, HTTP timeout, transport errors normalized to a Notion-shaped 503, and a pacer fail-fast (synthetic 429 with Retry-After) so a cold burst cannot pin Puma threads.
  • Mirror tables holding raw Notion objects: notion_pages extended (database_id, data_source_id, freshness columns), new notion_blocks, notion_data_sources, notion_databases. NotionPage.lead / .human_operating_manual now select on database_id (the new API version reports rows under data_source_id); sync_database reconciles on database_id with soft deletes; dead status_history removed.
  • /api/notion/v1/* proxy (X-Api-Key, same paths/bodies/responses as Notion): cache hits for pages, blocks, data sources, databases, and block-children levels; queries, searches, and writes always pass through (paced) and warm/invalidate the cache; Notion errors pass through unchanged; X-Stacks-Cache / X-Stacks-Fetched-At headers.
  • MCP tools mirroring Notion's MCP names: notion-fetch (markdown via the one renderer), notion-search, notion-query-data-sources.
  • Freshness: stacks:notion:sweep every 10 min (search feed → properties, deadline-bounded tree refresh, minute-resolution recheck), stacks:notion:backfill (one-off, resumable), stacks:notion:reconcile (daily trash / access-lost disambiguation). One shared advisory lock.
  • Live parity test (NOTION_LIVE=1 bin/rails test test/live/notion_parity_test.rb, plus stacks:notion:verify_parity): proxy responses compared with Notion's own for pages, a database row, database, data source, every block-children cursor page cold then warm, a filtered query, a search, and a search-seeded row. Passed 9/9 on four separate live runs; output in docs/notion-mirror-parity-2026-09-10.txt.

Spec: docs/superpowers/specs/2026-09-10-notion-mirror-design.md (with the full deviations list). Plan: docs/superpowers/plans/2026-09-10-notion-mirror.md. Deploy: docs/notion-mirror-deploy.mdmigrate before release, add the two Scheduler jobs, run the backfill to phase == "done", verify the Lead/HOM scope counts, run parity against prod, then switch stacksbot's reads.

Phase 3 (webhook receiver, corpus projection into documents) is a separate plan.

Test plan

  • Full suite locally: bin/rails test --exclude "/EtlRakeTest/" → 1327 runs, 4123 assertions, only the known AdminUserTest 20:00–24:00 ET time-zone flake failing
  • Live parity 9/9 (four runs, latest after the final hardening fixes)
  • Heroku CI green
  • After deploy: NotionPage.lead.count ≈ 1018 and NotionPage.human_operating_manual.count ≈ 94 before and after the first stacks:sync_notion

🤖 Generated with Claude Code

https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V

hhff and others added 30 commits September 10, 2026 18:01
Queries always pass through (no cached query path), ids normalized on
every lookup, paranoid-scope rules, Notion-stamp-based staleness with a
minute-resolution recheck, wall-clock deadlines, class-level pacer,
shared advisory lock, daily reconcile with trash disambiguation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
Paranoid-safe upserts, nil-safe created_at, HTTParty header arrays,
deadline checks after progress, reconcile rewritten, page_title backfill,
Lead.all on the new scope, sync_database empty guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…cer and Retry-After handling

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
Deleting it defeated test_helper's process-wide default for every later test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…rces/databases; scopes on database_id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…or tables

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…d comment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
… replace_level

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
… soft deletes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…ne and minute-resolution recheck

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…Ts, error pass-through

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…ve fill

On a hit, serve the level from NotionBlock rows with local cursor
pagination. On a miss or stale marker, proxy the caller's single cursor
page live (page_size 100) and store it via Mirror.replace_level /
store_blocks, completing the level only on an uncursored, non-paginated
response. Unknown parents pass through live without storing.

The brief's test setup called Mirror.upsert_page with an unbraced
hash-rocket literal, which Ruby 3.1 parses as an empty positional-arg
keyword splat against upsert_page's (obj, fetched_at:) signature and
raises ArgumentError before the controller is ever exercised; wrapped it
in explicit braces so it's a single positional hash, matching every
other caller in the codebase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
… and Fetched-At header

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
Implements query_data_source, search, create_page, update_page,
append_block_children, update_block, and delete_block in
Api::Notion::ProxyController. Queries and search always hit Notion live and
warm the mirror with returned pages/data sources; page writes upsert the
response; block writes mark the owning page's blocks_stale_at. Also rescues
ActionDispatch::Http::Parameters::ParseError so a malformed JSON body with
Content-Type: application/json returns a Notion-style 400 instead of falling
through to the generic 500 handler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…line-bounded tree refresh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…sh/access-lost disambiguation

Stacks::Notion::Backfill does a one-off resumable full load (feed, then
schemas for unfetched data sources, then seed trees), recording phase
progress in SourceSync(:notion_backfill).cursor so a killed run resumes.
Stacks::Notion::Reconcile does a daily full-feed walk and disambiguates
unseen pages/data sources (trashed vs access-lost) within a shared
request budget, and reports sweep drift. Both share Sweep's advisory
lock. Adds backfill/reconcile rake tasks under stacks:notion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
… first-run feed, per-walk deadline

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…ols over the mirror

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…dget test, rake coverage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…fy_parity

Proves the proxy at /api/notion/v1/* mirrors Notion one-to-one: for a fixed
set of real ids, the proxy's cold and warm responses equal Notion's own
(ignoring request_id/request_status), block-children levels match cursor by
cursor, and live query/search results match by id. Ran live against
api.notion.com: 9/9 checks passed, no proxy or mirror fixes needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…tools

Adds docs/notion-mirror-deploy.md covering env vars, deploy order, and
behavioural deviations from a pure Notion mirror (cache headers, blocks
children page_size, cursor restart on mid-pagination staleness, backfill
re-run semantics, sweep access-lost handling, first-sweep feed cap, and
Sentry reporting for 5xx/401).

Also updates McpEndpointTest's tools/list assertion to include the three
notion-fetch/notion-search/notion-query-data-sources tools registered by
this branch — the full suite caught this as a real regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
…rd-deletes what it wrote

delete_all on a paranoid relation only soft-deletes; leftover rows collided
on the unique notion_id index in later test runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
Six review fixes, all about a web request never hanging and never leaking a
non-Notion response out of the proxy:

- TreeFetcher's deadline now bites BETWEEN cursor pages of a level, not only
  between levels. One 1,000-block level is 10 paced requests, which blew past
  notion-fetch's 6s deadline and rack-timeout's 15s. fetch_level returns
  [blocks, requests, complete]; an incomplete level keeps the pages we paid for
  but stamps no marker, so the next walk resumes it.
- Stacks::Notion gets default_timeout 10 and wraps Net::/Errno::/SocketError/
  HTTParty transport failures as one Notion-shaped 503 (idempotent GETs still
  get their one retry first).
- A saturated pacer now fails fast: Stacks::Notion.new(max_wait: 5) refuses to
  claim a slot further out than 5s and raises a synthetic Notion-shaped 429
  with Retry-After instead of pinning a Puma thread. The synthetic 429 is
  flagged so the retry path does not sleep out its own backlog. The proxy and
  the three MCP tools opt in.
- The proxy declares its own rescue_from StandardError (first, so the
  RequestError/ParseError handlers still win) and renders a Notion-shaped 500,
  and parses page_size defensively: page_size[]=1 is an Array with no #to_i.
- A page row with page_fetched_at nil predates this branch, was written by the
  old sync with icon/cover/file payloads stripped, and is therefore NOT
  one-to-one: serve it as a miss so the refetch rewrites it.
- The live parity test's ENV["NOTION_RPS"] ||= "2" was dead against
  test_helper's "1000"; set and restore it explicitly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V
- Deploy doc step 1 gains the scope-switch verification (Leads ~1018, HOM ~94,
  and what a drop to 0 after the first sync_notion means).
- Deploy doc step 4 gains the reason stacksbot's read switch must wait for
  phase == "done": pre-mirror rows serve as misses until the backfill's feed
  walk rewrites them.
- The spec's "Deviations from one-to-one" list is now actually all of them
  (8-16): sweep caps and tiers, access_lost on refresh, reconcile's unseen
  detection, page_size=100 on a miss, cursor shape by cache state, legacy rows,
  the Notion-shaped 500, and the synthetic 429.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWRCs8SgcVwVQBNTQEP59V

This branch has not been deployed

No deployments
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.

1 participant