はじめに
運用して数か月たったある日、グラフから最新のデータだけが消えていました。
エラーは出ていません。ログにも何も残っていません。データベースを直接見ると、データはちゃんと入っています。
原因は、Supabase(PostgREST)が 1回のリクエストで返す行数に上限があることでした。limit を付けていなくても、上限を超えた分は何も言わずに切り捨てられます。
この記事は、Supabase を supabase-js で使っている人に向けて書いています。特に、ログ・記録・履歴のようにデータがじわじわ増えていくアプリの人に読んでほしい内容です。
読み終えると、自分のコードのどこが危ないかを見つけられます。また、コピペで使える全件取得関数とテストを持ち帰れます。
TL;DR
- Supabase の select は、既定で1リクエスト最大1000行。
limitなしでも黙って切れる - 危ない取得は「最悪のとき何件になるか」を見積もって見つける
-
.range()で1000件ずつ取る関数を1つだけ作り、危ない取得は全部そこを通す - 並び順は主キーのタイブレーカーで一意にする。そうしないとページの境目で重複・欠落する
- そもそも全件を取らずに済む設計(期間で絞る・DB で集計する)を先に考える
何が起きたか
全期間のデータを古い順(昇順)に取ってきて、グラフにしていました。
// 危ない例:limit を付けていないので全件返ってくる……と思っていた
const { data, error } = await supabase
.from('records')
.select('*')
.eq('owner_id', ownerId)
.order('created_at', { ascending: true })
件数が1000件を超えたあたりから、最新の記録がグラフに出なくなりました。
PostgREST には db-max-rows という設定があり、1回に返す行数の上限を決めています。公式ドキュメントでは、うっかりしたリクエストや悪意のあるリクエストで、返すデータが大きくなりすぎるのを防ぐための「上限」として説明されています。
Supabase ではこれが max_rows という名前で用意されていて、既定値は 1000 です(Supabase CLI の設定リファレンスの api.max_rows)。
ここで怖いのは、次の3点です。
-
エラーにならない。
errorはnullで、dataに1000件入って返ってくる - どちらの端が欠けるかは並び順で決まる。昇順なら最新側が、降順なら古い側が欠ける
- 開発中は絶対に気づかない。データが少ないうちは上限に届かず、運用して数か月後に突然起きる
上限はダッシュボードの API 設定(Max rows)で変えられます。ただし、上限を上げても「いつかまた超える」問題は解決しません。1回で送るデータ量が増えるだけなので、根本的な対策にはなりません。
危ない取得の見つけ方
コードの中の select を1つずつ見て、「最悪のとき、この取得は1000件に届くか」を見積もります。
| 判定 | 取得の例 |
|---|---|
| 危ない | 期間の条件がない取得(全期間) |
| 危ない | 長い期間 × 全種類の取得(例: 1か月以上の全データ) |
| 大丈夫 | 1日分など、明らかに上限より少ない取得 |
| 大丈夫 | わざと limit を付けている取得(検索結果・最新 N 件など) |
| 大丈夫 | もともと件数が少ない種類だけに絞った取得 |
ログ・記録・履歴のように増え続けるデータは、「今は少ない」が一番危ないです。今日の件数ではなく、1年後の件数で考えます。
全件取得関数を作る
方針:関数は1つだけにする
画面ごとにページングのループを手書きすると、どこかで書き漏らします。
全件取得の関数を1つだけ作り、危ない取得は必ずそこを通すようにします。
考え方はシンプルです。.range(from, to) で1000件ずつ取り、返ってきた件数が1000件より少なくなったら終わりにします。
.range() の from と to は0始まりで、両端を含みます。range(0, 999) で先頭から1000件です。
コード
/** Supabase の1リクエストあたり最大返却行数(プロジェクト既定の max-rows) */
export const SUPABASE_MAX_ROWS = 1000
export type PageResult<T, E = unknown> = { data: T[] | null; error: E | null }
/**
* fetchPage(from, to) を from=0 から pageSize 刻みで呼び、全ページを連結して返す。
* 返ってきた件数が pageSize 未満(0件・null 含む)になった時点で終わりとみなす。
* エラーが出たら打ち切り、error とそれまでの取得分を返す。
*
* 【必須】fetchPage 内のクエリには主キーのタイブレーカーを付けること:
* fetchAllPages((from, to) =>
* supabase.from('records').select('id, created_at')
* .order('created_at').order('id').range(from, to))
*/
export async function fetchAllPages<T, E = unknown>(
fetchPage: (from: number, to: number) => PromiseLike<PageResult<T, E>>,
pageSize: number = SUPABASE_MAX_ROWS,
): Promise<{ data: T[]; error: E | null }> {
const rows: T[] = []
const seenIds = new Set<unknown>()
for (let from = 0; ; from += pageSize) {
const { data, error } = await fetchPage(from, from + pageSize - 1)
if (error) return { data: rows, error }
const page = data ?? []
for (const row of page) {
// 取得の合間に先頭側へ挿入されると、ページ境目の行が次ページに再登場する。
// id を持つ行はここで重複を除く。
const id = rowId(row)
if (id !== undefined) {
if (seenIds.has(id)) continue
seenIds.add(id)
}
rows.push(row)
}
if (page.length < pageSize) return { data: rows, error: null }
}
}
function rowId(row: unknown): unknown {
return typeof row === 'object' && row !== null && 'id' in row
? (row as { id: unknown }).id
: undefined
}
使い方
const { data, error } = await fetchAllPages((from, to) =>
supabase
.from('records')
.select('id, created_at, value') // 必要な列だけ
.eq('owner_id', ownerId)
.gte('created_at', rangeStart) // できれば期間で絞る
.order('created_at')
.order('id') // タイブレーカー(必須)
.range(from, to),
)
if (error) {
// 途中までのデータを使うかどうかは、ここで決める
}
ここからは、この関数で気をつけたポイントを順に説明します。
ポイント1:ちょうど1000件のときは、もう1回取りに行く
終わりの判定は「返ってきた件数がページサイズより少ないか」です。
ちょうど1000件のときは、まだ続きがあるかもしれません。なので、2ページ目を取りに行き、0件が返ってきたところで終わります。1回ぶん余計に通信しますが、取りこぼすよりはずっと安全です。
ポイント2:並び順を一意にしないと、ページの境目で壊れる
公式リファレンスにも、.range() は並び順に従うこと、order がないと思わぬ動きになりうることが書かれています。
さらに、order を付けていても、並び順が一意でなければ危ないです。
たとえば created_at が同じ行が何件もあると、その行どうしの順番はリクエストのたびに変わってもかまわない扱いになります。すると、ページの境目で同じ行が2回出たり、1行が消えたりします。
対策は、並び替えの最後に主キーでのタイブレーカーを付けることです。
// 昇順
.order('created_at').order('id')
// 降順
.order('created_at', { ascending: false }).order('id', { ascending: false })
ポイント3:取得の合間に行が増えると、同じ行が2回返る
.range() は「先頭から何件目か」で位置を決める方式(offset 方式)です。
1ページ目と2ページ目を取る間に、先頭側へ1件追加されると、全体が1つずつ後ろにずれます。その結果、ページの境目にあった行が次のページにもう一度出てきます。
複数人で同じデータを触るアプリや、リアルタイムで更新されるアプリで起きます。
そこで、関数の中で id を見て重複を取り除いています。これで React の key の重複や、集計の二重計上を防げます。
逆に、取得の合間に行が削除されると、1行欠けることがあります。これは offset 方式の限界です。受け入れるか、後述の keyset ページングに切り替えます。
ポイント4:エラーが出たら、そこまでの分とエラーを返す
途中でエラーが出たら打ち切り、{ data: それまでに取れた分, error } を返します。
途中までのデータを使うかどうかは、呼び出す側が決めます。
- データの取り込みや集計など、正確さが必要な処理 → エラーなら
throwして止める - 画面に表示するだけ → 取れた分だけ表示して、エラーを知らせる
ポイント5:Supabase に直接依存させない
関数は Supabase クライアントを直接受け取らず、「(from, to) を受け取って1ページ分を返す関数」を受け取る形にしています。
こうしておくと、モックだけでユニットテストが書けます。
テスト
fetchPage を「配列を slice(from, to + 1) で切り出して返すモック」にして書きます(Vitest)。
import { describe, expect, it, vi } from 'vitest'
import { fetchAllPages, type PageResult } from './fetchAllPages'
type Row = { id: number }
/** 配列を range(from, to) で切り出して返すモック */
const mockFetcher = (rows: Row[]) =>
vi.fn(async (from: number, to: number): Promise<PageResult<Row>> => ({
data: rows.slice(from, to + 1),
error: null,
}))
const makeRows = (n: number): Row[] =>
Array.from({ length: n }, (_, i) => ({ id: i + 1 }))
describe('fetchAllPages', () => {
it('0件なら空配列を返し、1回だけ取得する', async () => {
const fetchPage = mockFetcher([])
const result = await fetchAllPages(fetchPage)
expect(result).toEqual({ data: [], error: null })
expect(fetchPage).toHaveBeenCalledTimes(1)
})
it('1ページ未満なら1回で終わる', async () => {
const fetchPage = mockFetcher(makeRows(999))
const { data } = await fetchAllPages(fetchPage)
expect(data).toHaveLength(999)
expect(fetchPage).toHaveBeenCalledTimes(1)
})
it('ちょうど1000件なら2ページ目(空)を取得して終わる', async () => {
const fetchPage = mockFetcher(makeRows(1000))
const { data } = await fetchAllPages(fetchPage)
expect(data).toHaveLength(1000)
expect(fetchPage).toHaveBeenCalledTimes(2)
expect(fetchPage).toHaveBeenLastCalledWith(1000, 1999)
})
it('1000件を超えたら複数ページを連結し、最新側を取りこぼさない', async () => {
const fetchPage = mockFetcher(makeRows(2500))
const { data } = await fetchAllPages(fetchPage)
expect(data).toHaveLength(2500)
expect(data.at(-1)).toEqual({ id: 2500 })
expect(fetchPage.mock.calls).toEqual([
[0, 999],
[1000, 1999],
[2000, 2999],
])
})
it('ページサイズを指定できる', async () => {
const fetchPage = mockFetcher(makeRows(25))
const { data } = await fetchAllPages(fetchPage, 10)
expect(data).toHaveLength(25)
expect(fetchPage).toHaveBeenCalledTimes(3)
})
it('途中ページでエラーが出たら打ち切り、エラーとそれまでの取得分を返す', async () => {
const rows = makeRows(2500)
const fetchPage = vi.fn(async (from: number, to: number) =>
from === 1000
? { data: null, error: new Error('network') }
: { data: rows.slice(from, to + 1), error: null },
)
const result = await fetchAllPages(fetchPage)
expect(result.data).toHaveLength(1000)
expect(result.error).toEqual(new Error('network'))
expect(fetchPage).toHaveBeenCalledTimes(2)
})
it('1ページ目でエラーなら空配列とエラーを返す', async () => {
const fetchPage = vi.fn(async () => ({ data: null, error: 'boom' }))
expect(await fetchAllPages(fetchPage)).toEqual({ data: [], error: 'boom' })
})
it('エラーなしで data が null なら終わりとして扱う', async () => {
const fetchPage = vi.fn(async () => ({ data: null, error: null }))
expect(await fetchAllPages(fetchPage)).toEqual({ data: [], error: null })
expect(fetchPage).toHaveBeenCalledTimes(1)
})
it('取得の合間の挿入でページ境目の行が再度返っても、id で重複を除く', async () => {
// 新しい順(降順)に並べたデータ
let rows: Row[] = makeRows(1500).reverse()
const fetchPage = vi.fn(async (from: number, to: number) => {
const page = rows.slice(from, to + 1)
// 1ページ目を返した直後に、先頭へ新しい行が1件挿入される
if (from === 0) rows = [{ id: 1501 }, ...rows]
return { data: page, error: null }
})
const { data } = await fetchAllPages(fetchPage)
const ids = data.map((r) => r.id)
expect(new Set(ids).size).toBe(ids.length)
expect(data).toHaveLength(1500)
})
it('id を持たない行(列を絞った取得)は重複除去せずにそのまま連結する', async () => {
const rows = [{ v: 1 }, { v: 1 }, { v: 1 }]
const fetchPage = vi.fn(async (from: number, to: number) => ({
data: rows.slice(from, to + 1),
error: null,
}))
const { data } = await fetchAllPages(fetchPage, 2)
expect(data).toHaveLength(3)
})
})
特に大事なのは次の2つです。
- ちょうど1000件:境目のケース。ここを間違えると、1000件ぴったりのときだけ無限ループしたり、続きを取りこぼしたりする
- 1000件超えで最後の行が最新:今回の事故そのもの。この1本があれば、同じ事故は二度と起きない
そもそも全件を取らない設計にする
ここまで作っておいて何ですが、この関数は正しく取るための道具で、速く取るための道具ではありません。
件数が増えるほど、通信の回数・送るデータ量・端末のメモリ使用量がそのまま増えていきます。
なので、画面を開くたびに走る取得には、先に次の考え方を当てはめます。
- 取る範囲を区切る:期間か件数で上限を付ける。例: グラフに表示している期間の分だけ取る
-
必要な列だけ取る:
select('*')や、行ごとに関連テーブルを埋め込む書き方をやめる - 集計は DB でやる:全行を取ってきて画面側で数えるのではなく、Postgres 関数(RPC)で集計して結果だけを返す
- 全期間・全件の取得は「たまにしか走らない処理」に限る:エクスポートやバッチ処理など
集計を DB に任せる場合は、たとえばこんな形です。
-- 日ごとの件数を DB 側で集計して返す
create or replace function daily_counts(p_owner_id uuid, p_from timestamptz)
returns table (day date, count bigint)
language sql stable
as $$
select created_at::date as day, count(*) as count
from records
where owner_id = p_owner_id and created_at >= p_from
group by 1
order by 1
$$;
const { data, error } = await supabase.rpc('daily_counts', {
p_owner_id: ownerId,
p_from: rangeStart,
})
365日分の集計でも返るのは最大365行なので、上限の心配がなくなり、送るデータ量もぐっと減ります。
RPC 関数を作るときは、権限(GRANT)と RLS の効き方もあわせて確認してください。language sql の関数は既定で呼び出したユーザーの権限で動くので、records の RLS がそのまま効きます。
設計の段階で「件数が10倍になったらどうなるか」を見積もるくせを付けておくと、この手の事故はかなり防げます。
ほかの選択肢
keyset(カーソル)ページング
「前のページの最後の値より後ろ」を取る方式です。
const { data } = await supabase
.from('records')
.select('id, created_at')
.gt('id', lastId)
.order('id')
.limit(1000)
位置を「何件目か」ではなく「値」で決めるので、途中で行が追加・削除されても重複や欠落が起きにくいです。
ただし、created_at と id の組み合わせのように並び替えのキーが複数になると、条件の書き方が少し複雑になります。
先に件数だけ数える
const { count, error } = await supabase
.from('records')
.select('id', { count: 'exact', head: true })
.eq('owner_id', ownerId)
head: true にするとデータ本体は返さず、件数だけを取れます。上限を超えそうかどうかの判定や、監視に使えます。
max rows を上げる
手軽ですが、先に書いたとおり根本的な対策にはなりません。「一時しのぎ」と割り切って使うものです。
まとめ
- Supabase の select は既定で1リクエスト最大1000行。
limitなしでも、エラーなしで黙って切れる - 昇順なら最新側、降順なら古い側が欠ける。データが少ない開発中には気づけない
- 危ない取得は「最悪のとき何件になるか」で見つける。増え続けるデータは「今は少ない」が一番危ない
-
.range()で1000件ずつ取る関数を1つだけ作る。主キーのタイブレーカーとid での重複除去を忘れない - ただし全件取得は最後の手段。範囲を区切る・必要な列だけ取る・DB で集計するを先に考える