Supabase reads and the invisible 1,000-row cap
TL;DR: PostgREST (Supabase’s API) returns at most 1,000 rows per request and does not error past the cap — it silently hands you the first 1,000. Any read that can match more must go throughlib/supabase/fetch-all-pages.ts.
Reviewers: treat an unpaged .select() on a per-holding/per-day table as a
bug, not a style choice.
Why this is dangerous specifically here
This app’s history tables are per-item × per-day: one row per holding (or token, property, metal…) per calendar day. Row counts multiply:
When the cap bites, the failure is the worst kind: wrong numbers, no
error. A truncated chart series under-sums the dates that lost rows (the
chart drifts from the page header — the exact class of incident from
2026-07-28); a truncated change window silently zeroes some holdings’
“today” moves. It also ambushes precisely the best accounts — long
histories, many positions — and only after enough history accumulates,
so it passes every fresh-account test.
The pattern
pageSize outside [1, 1000] up front — above the cap,
every page looks short and the loop would stop after one page, silently
reintroducing the truncation it exists to prevent.
Rules
- Stable order is mandatory. Pages are separate requests; without a
deterministic total order, rows can repeat or vanish between them. If
you need a domain ordering (e.g.
date_synced), keep it and append.order('id', { ascending: true })as a tiebreaker — many rows share a date, and ties make the order non-deterministic across pages. - Build a fresh query inside the callback. PostgREST query builders are one-shot; the helper calls your factory once per page.
- Filters stay identical across pages — never vary anything but the range.
When you do NOT need it
- Bounded-by-construction reads: one row per day per user
(
daily_snapshots_summaryover 7 days = 7 rows), single-entity lookups,count-only queries. - Deliberately-limited reads: a
.limit(50)“recent activity” list is a product decision, not a truncation bug. Say so in a comment, or the next reader can’t tell the difference. - Server-side aggregation: if you only need a SUM per day, a view/RPC
that aggregates in SQL beats shipping thousands of rows to the client —
prefer it for new heavy surfaces;
fetchAllPagesis the client-side answer where per-row data is genuinely needed.
The sibling trap: unbounded .in() lists
Same transport layer, opposite direction. A .in('symbol', hugeArray)
builds one giant GET URL, and past ~a-few-thousand characters the server
answers 414 — failing the whole read. And capping the list instead
silently drops the tail. The fix is chunking (500 per request, the
MAX_LOOKUP_SYMBOLS convention) and merging results — see the
tracked_tickers_latest lookup in use-public-investments-series.ts.
Where it’s already used
Chart series:use-public-investments-series (3 legs + classification),
use-crypto-series, use-property-series, use-precious-metals-series.
Day-change windows: use-holdings-daily-snapshots (3 legs),
use-crypto-daily-snapshots, use-coinbase-daily-snapshots,
use-plaid-crypto-holdings.
Checklist for a new bulk read (agents: apply this on any PR that adds a
.select() over a per-item × per-day table):
- Can this match >1,000 rows for a large account? If yes →
fetchAllPages. - Stable
.order(...)ending in a unique column (id)? - Any
.in(...)list derived from user data? → chunk at 500. - If intentionally limited, is the
.limit()commented as a product choice?