overengineeringstudio / overengineeringstudio/effect-utils
Epic: Notion API limitations workarounds in @overeng/notion-react
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 82
- Forks
- 2
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 121
Description
Epic: Notion API limitations workarounds in @overeng/notion-react
This epic is the canonical reference for the Notion public-API constraints that shape the incremental-sync design of @overeng/notion-react. Every item below is an inherent upstream limitation we do not control — the package is built to ingest these gracefully rather than to fix them. New workarounds in the renderer should link back to the relevant section here, and existing code/test comments referencing "Notion rejects…" / "Notion has no …" should cite this issue.
It is an epic rather than a bug because the scope is cross-cutting (append planning, diffing, rich-text flattening, upload lifecycle, rate-limit handling) and the list is expected to grow as more Notion-API edges are discovered.
1. column_list / column must be created atomically with all descendants inlined
What Notion does: "When creating a column_list block using Append block children, the column_list must have at least two columns, and each column must have at least one child." Post-hoc appends against a bare column_list are rejected with body.children[N].column.children should be defined.
Impact on incremental sync: The natural "emit parent, then children" order a React reconciler produces is invalid. Column reorders / additions / removals cannot be expressed as incremental ops against an existing column_list — Notion provides no API surface for it.
Our handling: ATOMIC_CONTAINERS set in render-to-notion.ts folds every descendant append under a column_list into one nested create body. FULL_REBUILD_ON_SUBTREE_CHANGE in sync-diff.ts forces remove+recreate of the whole column_list on any subtree change.
packages/@overeng/notion-react/src/renderer/render-to-notion.ts(ATOMIC_CONTAINERS, ~L29)packages/@overeng/notion-react/src/renderer/sync-diff.ts(FULL_REBUILD_ON_SUBTREE_CHANGE, ~L214)
Reference: https://developers.notion.com/reference/block#column-list-and-column-blocks
2. table must be created with rows inlined (same atomic contract as column_list)
What Notion does: A bare table create (no table.children) is rejected with body.children[N].table.children should be defined. Rows can be appended post-hoc, but the initial create must include some.
Impact on incremental sync: Same as column_list on creation — the parent-then-children emit order is invalid. Unlike column_list, subsequent row appends are permitted, so tables are atomic on create but incremental after.
Our handling: table is included in ATOMIC_CONTAINERS (but not in FULL_REBUILD_ON_SUBTREE_CHANGE), so the initial rows ship inlined while later row diffs flow through normal append/remove ops.
packages/@overeng/notion-react/src/renderer/render-to-notion.ts(~L29-L37)
Reference: https://developers.notion.com/reference/block#table-blocks
3. 100-children-per-append cap (applies to top-level appends AND nested children arrays)
What Notion does: "There is a limit of 100 block children that can be appended by a single API request." The same cap applies to any children array inside a create body (e.g. table.children, column.children).
Impact on incremental sync: A column_list with a column holding >100 children cannot be created in a single request, and atomic containers leave no room for a follow-up append at the nested level.
Our handling: MAX_CHILDREN_PER_APPEND = 100 in render-to-notion.ts. Top-level append runs are chunked into ⌈N/100⌉ calls. Nested-level overflow inside an atomic container throws loudly (see comment at ~L155) — the shape is rare and per-level chunking is intentionally not implemented.
packages/@overeng/notion-react/src/renderer/render-to-notion.ts(~L27, L155, L464)packages/@overeng/notion-react/src/renderer/sync-diff.ts(~L365,⌈N/100⌉)
Reference: https://developers.notion.com/reference/patch-block-children (Limits section)
4. No move/reorder API for blocks
What Notion does: The blocks API exposes create (append), update (content only), and delete (archive). There is no "move block B after block C" endpoint.
Impact on incremental sync: Every reorder compiles to delete-then-reinsert. For tree-heavy diffs this materially inflates the op count and the effective API cost.
Our handling: sync-diff.ts treats a key present in the old cache but not retained in the new output as a removal, and emits a new insert at the target position (see comment ~L349 "Notion has no move API; reorder = remove + re-insert").
packages/@overeng/notion-react/src/renderer/sync-diff.ts(~L306, L349)
Reference: https://developers.notion.com/reference/patch-block-children (no move verb documented alongside append)
5. No type-change PATCH
What Notion does: PATCH /v1/blocks/{id} updates type-specific content fields (rich_text, checked, language, etc.) but cannot convert a block from one type to another.
Impact on incremental sync: A paragraph → heading_1 edit at the same logical position cannot be expressed as an update — it must be remove + insert, which in turn loses any stable block id downstream consumers might have captured.
Our handling: sync-diff.ts treats same-key type changes as remove + insert and carefully recomputes hasRetainedAfter so the run planner batches correctly (see comment ~L129).
packages/@overeng/notion-react/src/renderer/sync-diff.ts(~L129)
Reference: https://developers.notion.com/reference/update-a-block (type-specific field schema; no conversion semantics)
6. Archived blocks cannot be edited
What Notion does: Once archived: true (trashed), a block's update endpoint returns validation_error with a message indicating the block is archived. Retrieves of trashed blocks can also return 404.
Impact on incremental sync: A retry after a partial flush may rediscover that a block was archived out of band (another client, previous failed flush). Naïve retries then cascade through validation errors and corrupt the cache's expected-id set.
Our handling: sync.ts has a dedicated alreadyGone branch that matches validation_error + /archived/i and treats a remove-op against an archived block as a no-op success (note: 'already-archived').
packages/@overeng/notion-react/src/renderer/sync.ts(~L33-L39, L695, L730, L911, L1012)
Reference: https://developers.notion.com/reference/update-a-block (returns 404 for "in the trash"), https://developers.notion.com/reference/errors (validation_error)
7. file_upload_id has an expiry
What Notion does: POST /v1/file_uploads returns an expiry_time on the created upload, and the upload has a lifecycle state that transitions to expired. Attaching an expired file_upload_id fails at attach time.
Impact on incremental sync: Long-running flushes or resumed flushes after a restart can race the expiry. A content-hash-keyed upload cache that outlives the expiry window would hand out dead ids.
Our handling: upload-registry.ts exposes useUploadRef(hash, factory) — consumers inject a registry that is responsible for honoring upload lifecycle. The package itself deliberately does not cache uploads across sessions.
packages/@overeng/notion-react/src/renderer/upload-registry.ts
Reference: https://developers.notion.com/reference/create-a-file-upload (expiry_time field, expired status)
8. No batch / transaction API
What Notion does: Every block change is a single HTTP call. Append block children is the only operation that accepts multiple items, and only as direct children of one parent (≤100). There is no atomic multi-parent, multi-op transaction.
Impact on incremental sync: A non-trivial diff compiles to N serialized HTTP calls. Partial failure is the norm and must be planned for. Rollback of already-applied ops is not possible.
Our handling: sync.ts flushes ops in planned order with explicit idempotency: successful ops update the working cache incrementally, and failures abort the remaining plan so the next run re-diffs against ground truth rather than a speculative state.
packages/@overeng/notion-react/src/renderer/sync.ts
Reference: Compare https://developers.notion.com/reference/patch-block-children, https://developers.notion.com/reference/update-a-block, https://developers.notion.com/reference/delete-a-block — all single-target.
9. Rate limits are loose and not guaranteed
What Notion does: "The rate limit for incoming requests per integration is an average of three requests per second." Bursts are tolerated but 429s are returned with a Retry-After header when exceeded. The exact burst budget is undocumented.
Impact on incremental sync: A large diff (hundreds of ops) will blow past 3 rps without throttling. Retry/backoff on 429 is required.
Our handling: Rate-limit handling is delegated to the Notion client layer consumed by sync.ts (the renderer assumes the client respects Retry-After). Flush batching at 100 children per append reduces wall-clock request count.
packages/@overeng/notion-react/src/renderer/sync.ts
Reference: https://developers.notion.com/reference/request-limits (Rate limits section)
10. 2000-character cap per rich_text text object
What Notion does: A single rich_text text item caps text.content at 2000 characters. Longer content must be split into multiple items with shared annotations.
Impact on incremental sync: A React text node longer than 2000 chars cannot be projected 1:1 onto a rich_text element — naive projection yields a validation error.
Our handling: flatten-rich-text.ts exports RICH_TEXT_MAX_LEN = 2000 and splitIntoChunks with surrogate-pair-safe splitting, so long runs are emitted as multiple text items sharing annotations/link.
packages/@overeng/notion-react/src/renderer/flatten-rich-text.ts(~L86-L128)
Reference: https://developers.notion.com/reference/request-limits (rich text 2000 chars, arrays 100 elements, payload 500KB/1000 blocks)
11. Rich-text array cap (100 items) and payload cap (500KB / 1000 blocks)
What Notion does: A rich_text array is capped at 100 elements; overall payload at 1000 block elements and 500KB.
Impact on incremental sync: Aggressive splitting for the 2000-char cap (item #10) interacts with the 100-element rich_text cap — a single 200 000-char paragraph cannot be expressed at all and must be split across block boundaries.
Our handling: Currently relies on the 2000-char splitter producing reasonable output for human-scale text. No explicit guard for the 100-element cap — flagged here for follow-up.
packages/@overeng/notion-react/src/renderer/flatten-rich-text.ts
Reference: https://developers.notion.com/reference/request-limits
12. No bulk subtree retrieval (paginated per level)
What Notion does: GET /v1/blocks/{id}/children returns only the first level of children, paginated (default 50, start_cursor-based). Recursive retrieval is the caller's responsibility via has_children.
Impact on incremental sync: Warming the local cache for a non-trivial page requires O(pages × depth) requests. Cache invalidation (e.g. "some block was edited out of band") forces a partial re-walk.
Our handling: Cache population and warm-up live in the ingest/backend layer rather than the renderer. The renderer assumes the cache it receives is consistent with server state at flush time and validates via hashing (stableStringify / djb2 hashProjected in sync-diff.ts).
packages/@overeng/notion-react/src/renderer/sync-diff.ts(~L35-L46)
Reference: https://developers.notion.com/reference/get-block-children (pagination + "Returns only the first level of children")
Not fixable by us
All twelve items above are inherent constraints of the Notion public API. Nothing in @overeng/notion-react can change the server-side behavior; we can only ingest it. This issue is therefore a living catalogue, not a TODO list.
Potential future improvements if Notion API gains features
- A real
moveverb would collapse reorder into one op instead of remove+insert (items #4, #5). - A block-type-change PATCH would avoid losing stable ids on
paragraph→heading_1edits (item #5). - A transactional multi-op endpoint would let us retry with stronger guarantees and remove the speculative-cache rollback complexity (item #8).
- A recursive subtree fetch would materially reduce cache-warm latency (item #12).
- A higher documented burst budget or a standard throttling envelope would simplify client-side rate-limit handling (item #9).
Verification notes
- Items #1–#4, #8, #10, #11, #12 are confirmed against the linked Notion docs pages.
- Item #6 (archived error exact wording) is confirmed in code via observed
validation_errorresponses with/archived/imatch; the public errors page documents thevalidation_errorcode but not the specific "Can't edit block that is archived" message. - Item #7 (expired
file_upload_id→ 400) is confirmed via theexpiry_time/expiredstatus fields in the create-file-upload docs; the exact attach-time error shape is not documented upstream and was observed empirically.
Acceptance criteria
- Issue body is kept current as new Notion-API edges are discovered in the renderer.
- Existing/future
// Notion …comments inpackages/@overeng/notion-react/src/renderer/can link to the section numbers here instead of restating the upstream behavior. - Each test that asserts "we work around Notion limitation X" references the matching section number.
Rationale
Centralizing these in one issue keeps the renderer code free of long "why" comments, preserves the upstream URLs alongside the behavior, and makes it obvious which complexity in the package is existential (driven by Notion) vs. accidental (ours to fix).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the referenced renderer files: render-to-notion.ts, sync-diff.ts, sync.ts, upload-registry.ts, and flatten-rich-text.ts. Compare each documented Notion limitation with its cited handling and tests or comments where available. Done means keeping this canonical reference accurate and linking new workaround comments or follow-up work back to the relevant section.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend, documentation
- Issue type
- Documentation
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100