diff --git a/.changeset/add-supabase-db-collection.md b/.changeset/add-supabase-db-collection.md
new file mode 100644
index 0000000000..657bc8f933
--- /dev/null
+++ b/.changeset/add-supabase-db-collection.md
@@ -0,0 +1,7 @@
+---
+'@tanstack/supabase-db-collection': minor
+---
+
+Add `@tanstack/supabase-db-collection`, a collection that syncs TanStack DB with Supabase through PostgREST queries, optimistic mutations, and Realtime subscriptions. It replaces `@supabase-labs/tanstack-db@0.1.0` ([supabase/tanstack-db@3503c451](https://github.com/supabase/tanstack-db/commit/3503c451b2f2550ab4069067673881f39342b588)).
+
+Compared with that package, it adds a `syncMode` option (`'on-demand'` by default, or `'eager'` to load the whole table), `parse` and `serialize` column conversions, a `defaultStringCollation` option, and materialization from a `DbClient`. Composite collection keys are now JSON arrays. `queryOnce` is now `experimental_queryOnce`, which runs only `count`, `sum`, and `avg` of plain columns on the server and always resolves to TanStack DB's `queryOnce` result.
diff --git a/.changeset/pre.json b/.changeset/pre.json
index b0c1ce4f14..d84538bfde 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -36,6 +36,7 @@
"@tanstack/react-router-with-db": "1.0.0-rc.0",
"@tanstack/rxdb-db-collection": "1.0.0-rc.0",
"@tanstack/solid-db": "1.0.0-rc.0",
+ "@tanstack/supabase-db-collection": "1.0.0-rc.0",
"@tanstack/svelte-db": "1.0.0-rc.0",
"@tanstack/tauri-db-sqlite-persistence": "1.0.0-rc.0",
"@tanstack/tauri-db-sqlite-persistence-e2e-app": "0.0.49",
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 486eb1e720..59ca56c439 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- group: [browser, desktop, cloud-mobile]
+ group: [browser, desktop, cloud-mobile, supabase]
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
@@ -76,6 +76,25 @@ jobs:
--filter '@tanstack/react-native-db-sqlite-persistence...'
build
+ - name: Setup Supabase CLI
+ if: matrix.group == 'supabase'
+ uses: supabase/setup-cli@689adcfb80d947100a1eab127289b97d1fb07b13 # v3.2.0
+ with:
+ version: 2.116.0
+
+ - name: Start Supabase
+ if: matrix.group == 'supabase'
+ # The collection needs only the database, PostgREST, Realtime and the
+ # API gateway (Kong). Skipping the other services saves image pulls and
+ # startup time. The e2e global setup reuses this running stack.
+ working-directory: packages/supabase-db-collection
+ run: supabase start -x studio,mailpit,storage-api,imgproxy,postgres-meta,edge-runtime,logflare,vector,supavisor
+
+ - name: Run Supabase E2E tests
+ if: matrix.group == 'supabase'
+ working-directory: packages/supabase-db-collection
+ run: pnpm test:e2e
+
- name: Run Electric E2E tests
if: matrix.group == 'browser'
run: |
@@ -233,6 +252,11 @@ jobs:
cd packages/db-collection-e2e/docker
docker compose down -v
+ - name: Stop Supabase
+ if: always() && matrix.group == 'supabase'
+ working-directory: packages/supabase-db-collection
+ run: supabase stop --no-backup
+
- name: Upload test results
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml
index 62a0098ea5..a6180e34f2 100644
--- a/_artifacts/skill_tree.yaml
+++ b/_artifacts/skill_tree.yaml
@@ -35,7 +35,8 @@ skills:
description: >
Creating typed collections with createCollection, queryCollectionOptions,
electricCollectionOptions, powerSyncCollectionOptions, rxdbCollectionOptions,
- trailbaseCollectionOptions, localOnlyCollectionOptions, localStorageCollectionOptions.
+ trailbaseCollectionOptions, supabaseCollectionOptions, localOnlyCollectionOptions,
+ localStorageCollectionOptions.
CollectionConfig (getKey, schema, sync, gcTime, autoIndex, syncMode).
StandardSchema validation with Zod/Valibot/ArkType. Collection lifecycle
(idle/loading/ready/error/cleaned-up). Adapter-specific sync patterns.
@@ -49,6 +50,7 @@ skills:
- 'TanStack/db:docs/collections/powersync-collection.md'
- 'TanStack/db:docs/collections/rxdb-collection.md'
- 'TanStack/db:docs/collections/trailbase-collection.md'
+ - 'TanStack/db:docs/collections/supabase-collection.md'
- 'TanStack/db:docs/collections/local-only-collection.md'
- 'TanStack/db:docs/collections/local-storage-collection.md'
- 'TanStack/db:packages/db/src/collection/index.ts'
@@ -58,6 +60,7 @@ skills:
- 'PowerSync adapter'
- 'RxDB adapter'
- 'TrailBase adapter'
+ - 'Supabase adapter'
- 'Local-only'
- 'localStorage'
references:
@@ -66,6 +69,7 @@ skills:
- 'references/powersync-adapter.md'
- 'references/rxdb-adapter.md'
- 'references/trailbase-adapter.md'
+ - 'references/supabase-adapter.md'
- 'references/local-adapters.md'
- 'references/schema-patterns.md'
diff --git a/docs/collections/supabase-collection.md b/docs/collections/supabase-collection.md
new file mode 100644
index 0000000000..1889e1c735
--- /dev/null
+++ b/docs/collections/supabase-collection.md
@@ -0,0 +1,457 @@
+---
+title: Supabase Collection
+---
+
+Supabase collections provide seamless integration between TanStack DB and [Supabase](https://supabase.com), keeping your UI in sync with Postgres through PostgREST queries, mutations, and Realtime subscriptions.
+
+> **Experimental.** The Realtime integration is still being stabilized and may consume more Realtime messages than expected.
+>
+> Test in orgs on the free plan or with a spend cap. Set `realtime: false` on individual collections to opt out. To narrow a subscription to only send the needed messages and cut Realtime traffic, try the experimental `realtimeUseFilter: true` option (see [Real-time Subscriptions](#real-time-subscriptions)).
+
+## Overview
+
+[Supabase](https://supabase.com) is an open source Postgres development platform with auto-generated REST APIs (PostgREST), Realtime, Auth, and Row Level Security (RLS).
+
+The `@tanstack/supabase-db-collection` package allows you to create collections that:
+
+- Load data from your Supabase tables, pushing `where`, `orderBy`, and `limit` down to PostgREST
+- Apply inserts, updates, and deletes optimistically, then roll back automatically if the server rejects them
+- Receive changes made by other clients through Supabase Realtime, with no subscription code
+- Derive their types from a [Standard Schema](https://standardschema.dev) (e.g., Zod, Valibot), so queries and mutations are type-checked end to end
+
+Collections work with your Supabase project as it is and respect Row Level Security. Your Supabase database remains the source of truth: Postgres, RLS, Auth, and the rest of your stack stay unchanged, and no migration is required.
+
+To see a working app, open the [example Todo app](https://todomvc-supabase-tanstack-db.vercel.app/).
+
+## Installation
+
+```bash
+npm install @tanstack/supabase-db-collection @tanstack/react-db @tanstack/query-core @supabase/supabase-js
+```
+
+`@supabase/supabase-js` and `@tanstack/query-core` are peer dependencies. You also need an existing Supabase project with the client library and environment variables configured. If you have not set that up yet, follow the [Supabase getting started guide](https://supabase.com/docs/guides/getting-started).
+
+The examples on this page use React. TanStack DB also supports Vue, Angular, Solid, Svelte, and vanilla JavaScript; swap `@tanstack/react-db` for the package that matches your framework.
+
+> **Tip:** The [Supabase UI Library](https://supabase.com/ui/docs/nextjs/tanstack-db) provides a TanStack DB block that generates zod schemas and collections for every table in your database, so you do not have to write them by hand.
+
+## Basic Usage
+
+### 1. Enable Realtime for synced tables
+
+Realtime is optional, but required if you want changes from other clients to appear automatically.
+
+Enable Realtime for your table in the Supabase Table Editor, or run this in the SQL Editor:
+
+```sql
+alter publication supabase_realtime add table "public"."todos";
+```
+
+### 2. Define a collection
+
+In TanStack DB, a collection corresponds to a Postgres table. Create one collection for each table you want to access from your frontend:
+
+```typescript
+import { createCollection } from '@tanstack/react-db'
+import { supabaseCollectionOptions } from '@tanstack/supabase-db-collection'
+import { createClient } from '@supabase/supabase-js'
+import { z } from 'zod'
+
+const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
+
+const todoSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ completed: z.boolean(),
+})
+
+export const todosCollection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ schema: todoSchema,
+ keys: ['id'], // should match your primary key(s)
+ supabase,
+ realtime: true,
+ })
+)
+```
+
+### 3. Use the collection in components
+
+Query collections with `useLiveQuery`. Mutations are methods on the collection, so call `collection.insert`, `collection.update`, or `collection.delete` directly. No hooks or mutation objects are required.
+
+```tsx
+import { useLiveQuery, eq } from '@tanstack/react-db'
+import { todosCollection } from './collections'
+
+function Todos() {
+ const { data: todos, isLoading } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => eq(todo.completed, false)),
+ })
+
+ // These apply instantly in the UI and roll back if the server rejects them
+ const insertTodo = (title: string) => {
+ todosCollection.insert({ id: crypto.randomUUID(), title, completed: false })
+ }
+
+ const completeTodo = (id: string) => {
+ todosCollection.update(id, (draft) => {
+ draft.completed = true
+ })
+ }
+
+ const deleteTodo = (id: string) => {
+ todosCollection.delete(id)
+ }
+
+ if (isLoading) return
Loading...
+
+ return (
+
+
+
+ {todos.map((todo) => (
+
+ {todo.title}
+
+
+
+ ))}
+
+
+ )
+}
+```
+
+## Configuration Options
+
+The `supabaseCollectionOptions` function accepts the following options:
+
+### Required Options
+
+- `tableName`: Name of the Postgres table. Maps to the PostgREST endpoint
+- `schema`: [Standard Schema](https://standardschema.dev) compatible schema (e.g., Zod, Valibot) describing a single row
+- `keys`: Column or columns that uniquely identify a row. Should match the primary key(s) on your table. Used to derive the collection key and to match rows on update and delete. The collection key is built from the string form of each key column. With a single column, it is that value as a string, so `6` becomes `'6'`. With several columns, it is a JSON array of those strings, such as `'["a","b"]'`. Pass this key to `get`, `update`, and `delete`
+- `supabase`: Supabase client instance used for queries, mutations, and the Realtime subscription
+
+### Optional Options
+
+| Option | Type | Default | Description |
+| ------------------- | ------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `syncMode` | `'on-demand'` \| `'eager'` | `'on-demand'` | Controls what is loaded into the collection. `'on-demand'` loads only the rows that live queries need, pushing `where`, `orderBy`, and `limit` to PostgREST. `'eager'` loads the whole table up front, paging past the PostgREST row cap, and evaluates queries against the local copy. See [How Sync Works](#how-sync-works). |
+| `realtime` | `boolean` | `false` | When `true`, subscribes to Postgres changes and reconciles inserts, updates, and deletes from other clients into the collection. |
+| `realtimeUseFilter` | `boolean` | `false` | **Experimental.** Only applies when `realtime` is `true`. When `true`, each active query's `WHERE` clause is pushed to the Realtime subscription as a `postgres_changes` filter, so the channel only receives changes those queries care about. When `false`, the collection subscribes to every change on the table and filters client-side. Queries whose `WHERE` cannot be expressed as a Realtime filter (e.g. `or(...)`) transparently fall back to the unfiltered subscription. |
+| `queryClient` | `QueryClient` | shared client | TanStack Query client. If omitted, the browser uses one shared client and the server creates a new client for each collection. When a [`DbClient`](#use-with-dbclient) materializes the collection, the client's `queryClient` dependency is used instead. |
+| `defaultStringCollation` | `StringCollationConfig` | TanStack DB default | Controls how string columns are compared when ordering, so the local order can match your database collation. `StringCollationConfig` is exported by `@tanstack/db`. |
+| `parse` | `{ [column]: (value) => … }` | none | Converts a column value received from PostgREST or Realtime into the value your schema describes. PostgREST speaks JSON, so a `timestamptz` arrives as a string: use `parse` to turn it into a `Date`. Applies to rows read, rows returned by inserts and updates, and Realtime payloads. Not called for `null` values. |
+| `serialize` | `{ [column]: (value) => … }` | none | The reverse of `parse`: converts a column value held by the collection into the JSON value sent to PostgREST in insert and update bodies, and used to match rows by key on update and delete. Not called for `null` values. |
+
+PostgREST returns a `bigint` as a JSON number, which JavaScript rounds to the nearest double above `Number.MAX_SAFE_INTEGER` (9007199254740991). Values that large cannot round-trip through a `bigint` column; store them in a `text` column and convert them with `parse` and `serialize`.
+
+```ts
+supabaseCollectionOptions({
+ tableName: 'events',
+ keys: ['id'],
+ schema: eventSchema, // startsAt: z.date()
+ supabase,
+ parse: { startsAt: (value) => new Date(value as string) },
+ serialize: { startsAt: (value) => value.toISOString() },
+})
+```
+
+The `onInsert`, `onUpdate`, and `onDelete` handlers are provided by the adapter and write through PostgREST; they are not configurable.
+
+## How Sync Works
+
+Supabase collections are built on top of [Query Collection](./query-collection.md). Each live query is translated into PostgREST requests, the results are cached with TanStack Query, and Realtime events are merged into the same collection.
+
+### Sync modes
+
+- **`'on-demand'` (default):** the collection does not load anything until a live query subscribes. Each query fetches only the rows it needs, with filters, ordering, and limits sent to PostgREST. Use this for large tables or when each screen only shows a small slice of the data.
+- **`'eager'`:** the whole table is loaded when the collection starts syncing, paging past the PostgREST row cap (see [Row cap and pagination](#row-cap-and-pagination)). Queries then run against the local copy. Use this for small, read-mostly tables that many queries touch.
+
+### Pushed to PostgREST
+
+Most query operations are translated to PostgREST filters and run server-side. A handful of operations run client-side instead.
+
+| Operation | Notes |
+| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `FROM` | Maps to the PostgREST table endpoint. |
+| `WHERE` (`eq`, `gt`, `gte`, `lt`, `lte`, `inArray`, `like`, `ilike`, `not`, `isNull`) | Translated to PostgREST filter syntax. |
+| `AND` (multiple conditions or chained `.where`) | Translated to PostgREST filter syntax. A conjunct that cannot be pushed is dropped on its own; the request returns a superset and the client re-filters it. |
+| `OR` and nested `AND`/`OR` | Translated to PostgREST's `or=(…)` syntax. All-or-nothing: if any branch cannot be pushed the whole `or` is dropped, because dropping one branch would narrow the result and lose matching rows. |
+| `ORDER BY` (on source columns) | Translated to PostgREST ordering. |
+| `LIMIT` | Translated to PostgREST limiting. |
+| `JOIN` | Each table is fetched separately. The join key is pushed as an `in` filter on the second query. |
+
+`like` and `ilike` use TanStack's SQL wildcards: `%` matches any sequence and `_` matches one character (for example, `ilike(user.name, '%alice%')`). Patterns containing a literal `*` are evaluated client-side because PostgREST interprets `*` as a wildcard. Filter values containing commas, parentheses, quotes, or backslashes are escaped automatically.
+
+### Evaluated client-side
+
+These operations fetch the required rows and process them in memory:
+
+- `SELECT` column subsets, renaming, and computed fields (`upper`, `lower`, `concat`, `length`, `add`, `coalesce`)
+- Aggregate functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` (except an aggregate-only `COUNT`, `SUM`, and `AVG` in [`experimental_queryOnce`](#experimental_queryonce))
+- `GROUP BY` and `HAVING`
+- `DISTINCT`
+- `ORDER BY` on computed fields
+
+### Row cap and pagination
+
+PostgREST caps every response at its `db-max-rows` setting (1000 by default on Supabase). Collection reads, including those made by [`experimental_queryOnce`](#experimental_queryonce), page past this cap automatically: the adapter loops with `offset`, using a one-time `count=exact` on the first request to fetch the complete matching set (or the caller's `limit`).
+
+Known limitations of the paging loop:
+
+- **Ordering.** Pages are stitched together with `offset`, and PostgreSQL does not guarantee a consistent row order across separate `LIMIT`/`OFFSET` queries unless the sort is total. When a read needs more than one page, the adapter therefore reads every page, including the first, ordered by the collection's `keys` after any `orderBy` of the query, so the pages come from one sequence. A read that fits in a single page is sent as written.
+- **Concurrent writes.** Offset paging is not keyset-stable: a row inserted or deleted by another client while a multi-page read is in flight shifts the remaining rows by one offset, so a row can be missed or duplicated until the next refetch.
+- **Count cost.** Every collection read issues one `Prefer: count=exact` on its first request so the loop knows when to stop. On very large tables, or under heavy RLS, that is a full `count(*)` per read.
+
+## Real-time Subscriptions
+
+With `realtime: true`, the collection subscribes to Postgres changes for its table and reconciles inserts, updates, and deletes made by other clients. The table must be added to the `supabase_realtime` publication (see [Basic Usage](#1-enable-realtime-for-synced-tables)).
+
+```typescript
+const todosCollection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ schema: todoSchema,
+ keys: ['id'],
+ supabase,
+ realtime: true,
+ // Experimental: only receive changes that match active queries
+ realtimeUseFilter: true,
+ })
+)
+```
+
+Row Level Security applies to Realtime, so clients only receive changes they are allowed to read. If the Supabase client has no `channel` method, `realtime` is a no-op.
+
+Known limitation: the initial fetch does not wait for the Realtime channel to finish subscribing. A row written in the window between the fetch and the subscription going live can be missed by both.
+
+## Mutations and Optimistic Updates
+
+`insert`, `update`, and `delete` on the collection apply to the UI immediately, then are sent to Supabase through PostgREST. The adapter owns the persistence handlers:
+
+- **Insert** sends a `POST` and writes the row returned by the server back into the collection, so database defaults (generated ids, timestamps) appear without a refetch.
+- **Update** sends a `PATCH` matched on the `keys` columns and writes back the returned row.
+- **Delete** sends a `DELETE` matched on the `keys` columns.
+
+If the server rejects a mutation (for example, because an RLS policy denies it), the optimistic change is rolled back automatically.
+
+```typescript
+// Applies instantly; rolls back if Postgres or RLS rejects it
+todosCollection.update(todoId, (draft) => {
+ draft.completed = true
+})
+```
+
+See [Optimistic Mutations](../guides/mutations.md) for more on mutation lifecycles, transactions, and error handling.
+
+## Use with `DbClient`
+
+A `DbClient` gives each request, test, or tenant its own collections and its own `QueryClient`. Wrap the options in `collectionOptions` to create a descriptor, then materialize it from a client. A Supabase collection reads through the client's `queryClient` dependency, so its queries live in that client's cache.
+
+```typescript
+import { QueryClient } from '@tanstack/query-core'
+import { DbClient, collectionOptions } from '@tanstack/db'
+import { supabaseCollectionOptions } from '@tanstack/supabase-db-collection'
+
+export const todosDescriptor = collectionOptions(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ schema: todoSchema,
+ keys: ['id'],
+ supabase,
+ })
+)
+
+export function createRequestClients() {
+ const queryClient = new QueryClient()
+ const dbClient = new DbClient({ queryClient })
+ return { queryClient, dbClient }
+}
+
+const { dbClient } = createRequestClients()
+const todos = dbClient.collection(todosDescriptor)
+await todos.preload()
+```
+
+`dbClient.collection(descriptor)` returns one collection per descriptor and client. A second `DbClient` materializes its own collection with its own `QueryClient`. See [Request-scoped QueryClient](./query-collection.md#request-scoped-queryclient) and [SSR and Hydration](../guides/ssr.md) for how descriptors and clients fit together.
+
+## experimental_queryOnce
+
+`experimental_queryOnce(callback, supabase)` runs a TanStack DB query once against Supabase and resolves with the result. It is non-reactive: it does not subscribe to changes. Use it for one-shot fetches such as API routes or form submissions where live updates are not needed.
+
+It is experimental because it reads TanStack DB's internal query representation, which can change between releases.
+
+```typescript
+import { count, eq } from '@tanstack/db'
+import { experimental_queryOnce } from '@tanstack/supabase-db-collection'
+
+const [summary] = await experimental_queryOnce(
+ (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => eq(todo.completed, true))
+ .select(({ todo }) => ({ completed: count(todo.id) })),
+ supabase
+)
+
+// `summary` is undefined when no todo matches
+const completedCount = summary?.completed ?? 0
+```
+
+- `callback`: Builds the query using the same API as `useLiveQuery`
+- `supabase`: Supabase client used to run the server request described below
+
+It returns a `Promise` that resolves to exactly what TanStack DB's own `queryOnce` resolves to for the same query, typed from the query you built.
+
+### Server aggregates
+
+When the query only computes `count`, `sum`, or `avg`, the server computes it in a single PostgREST request. This applies when all of these are true:
+
+- the source is a Supabase collection, or a subquery that only filters one
+- the `select` holds only `count`, `sum`, or `avg` of a single plain column
+- a `sum` or `avg` is not over a column read through `parse`
+- every `WHERE` expression can be pushed to PostgREST
+- the query has no `groupBy`, `having`, join, `orderBy`, `limit`, `offset`, `distinct`, or functional clause
+
+This path needs PostgREST aggregate functions, which are off by default. Enable them once per project:
+
+```sql
+alter role authenticator set pgrst.db_aggregates_enabled = 'true';
+notify pgrst, 'reload config';
+```
+
+The result follows TanStack DB's aggregate rules, not SQL's:
+
+- A query that matches no rows resolves to `[]`, or to `undefined` with `findOne`.
+- A `sum` over only `null` values is `0`.
+- An `avg` divides the sum by the number of matching rows, including rows where the value is `null`.
+
+### Everything else
+
+Any other query, including `min`, `max`, `groupBy`, and `having`, runs through TanStack DB's `queryOnce` over the collections. Their loads push filters, ordering, and `limit` to PostgREST, and the remaining operations run client-side. The `supabase` argument is not used on this path.
+
+## Complete Example
+
+```tsx
+import { createCollection, useLiveQuery, eq } from '@tanstack/react-db'
+import { supabaseCollectionOptions } from '@tanstack/supabase-db-collection'
+import { createClient } from '@supabase/supabase-js'
+import { z } from 'zod'
+
+const supabase = createClient(
+ import.meta.env.VITE_SUPABASE_URL,
+ import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
+)
+
+// Define the schema for a single row
+const todoSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ completed: z.boolean(),
+})
+
+type Todo = z.infer
+
+// Create the collection
+export const todosCollection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ schema: todoSchema,
+ keys: ['id'],
+ supabase,
+ syncMode: 'on-demand',
+ realtime: true,
+ })
+)
+
+// Use in a component
+function TodoList() {
+ const { data: todos, isLoading } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => eq(todo.completed, false))
+ .orderBy(({ todo }) => todo.title, 'asc'),
+ })
+
+ const addTodo = (title: string) => {
+ const todo: Todo = { id: crypto.randomUUID(), title, completed: false }
+ todosCollection.insert(todo)
+ }
+
+ const toggleTodo = (todo: Todo) => {
+ todosCollection.update(todo.id, (draft) => {
+ draft.completed = !draft.completed
+ })
+ }
+
+ const removeTodo = (id: string) => {
+ todosCollection.delete(id)
+ }
+
+ if (isLoading) return
Loading...
+
+ return (
+
+
+
+ {todos.map((todo) => (
+
+ toggleTodo(todo)}
+ />
+ {todo.title}
+
+
+ ))}
+
+
+ )
+}
+```
+
+## Migrating from `@supabase-labs/tanstack-db`
+
+- **Package name.** Replace `@supabase-labs/tanstack-db` with `@tanstack/supabase-db-collection`.
+- **`queryOnce`.** It is now `experimental_queryOnce`. Its server path is narrower: only `count`, `sum`, and `avg` of plain columns run on the server. Everything else runs through TanStack DB's `queryOnce`. See [experimental_queryOnce](#experimental_queryonce).
+- **Composite keys.** A collection key for several `keys` columns is now a JSON array of strings such as `'["a","b"]'`, not a `-` join. Update any code that builds a key by hand for `get`, `update`, or `delete`.
+- **`syncMode`.** New option. The default, `'on-demand'`, keeps the old loading behavior. Set `syncMode: 'eager'` to load the whole table up front.
+- **React hooks.** `useLiveQuery` takes `{ query }` and no dependency arrays. See the [1.0 RC migration guide](../guides/migrate-to-1-0-rc.md#update-react-live-query-hooks).
+
+## FAQ
+
+**Will this work with my RLS policies?**
+
+Yes. Supabase collections go through PostgREST and Realtime, so your existing RLS policies apply automatically.
+
+**What if I do not use RLS?**
+
+Without RLS, all Realtime changes broadcast to every client. Depending on whether your app is public or private, that may not be what you want.
+
+**Can I use a Supabase collection and `supabase-js` in parallel?**
+
+Yes. The collection uses `supabase-js` under the hood, so they are compatible. Data fetched directly through `supabase-js` will not appear in the collection.
+
+Use `supabase-js` as a fallback for features TanStack DB does not cover, such as database functions through `.rpc` or complex `GROUP BY` queries.
+
+**Will this work with a custom API server?**
+
+This package targets Supabase and PostgREST tables. For custom backends, use [Query Collection](./query-collection.md) or write your own collection with the [Collection Options Creator guide](../guides/collection-options-creator.md).
+
+## Learn More
+
+- [Supabase Documentation](https://supabase.com/docs)
+- [Supabase Realtime](https://supabase.com/docs/guides/realtime)
+- [Supabase UI Library: TanStack DB block](https://supabase.com/ui/docs/nextjs/tanstack-db)
+- [Example Todo app](https://todomvc-supabase-tanstack-db.vercel.app/)
+- [Query Collection](./query-collection.md)
+- [Optimistic Mutations](../guides/mutations.md)
+- [Live Queries](../guides/live-queries.md)
diff --git a/docs/config.json b/docs/config.json
index 1bc477dd48..a987456847 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -87,6 +87,10 @@
"label": "TrailBase Collection",
"to": "collections/trailbase-collection"
},
+ {
+ "label": "Supabase Collection",
+ "to": "collections/supabase-collection"
+ },
{
"label": "RxDB Collection",
"to": "collections/rxdb-collection"
@@ -217,6 +221,18 @@
{
"label": "indexedDBCollectionOptions",
"to": "reference/functions/indexedDBCollectionOptions"
+ },
+ {
+ "label": "Supabase DB Collection",
+ "to": "reference/supabase-db-collection/index"
+ },
+ {
+ "label": "supabaseCollectionOptions",
+ "to": "reference/supabase-db-collection/functions/supabaseCollectionOptions"
+ },
+ {
+ "label": "experimental_queryOnce",
+ "to": "reference/supabase-db-collection/functions/experimental_queryOnce"
}
],
"frameworks": [
diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md
index 4dea3ac188..c09e253c49 100644
--- a/docs/contributing/oracle-coverage.md
+++ b/docs/contributing/oracle-coverage.md
@@ -2089,7 +2089,8 @@ generic visitor behavior outside the named collectors.
| SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. |
| Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. |
| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation; an empty key counts as none, and a nonempty key runs the query. React no longer accepts dependency arrays. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React retains two shared-suite registrations, but both now call the same derived-identity API; the registration still named `react (dependency list)` does not establish a second production path. The earlier five failures from skipping a dependency-list check at first compile were evidence for the removed API. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. The former React dependency-list/`DbClient` witness is historical; it does not establish current reuse or hydration behavior. The shared conformance rejection scenarios currently check that, after a rejected rerender, two reads each throw or hold no rows. Reactive drivers accept an unhashable query, as described below. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in fixed [descriptor release tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release.svelte.test.ts) (no oracle owner; tracked in #2264). A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) owns the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React exercises applicable causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query rejected during compilation, a single-result infinite query, and a render abandoned by a sibling throw or suspension) through live, infinite, and Suspense hooks with derived identity or changed explicit `queryKey`, plus ordinary-hook configurations that disable reclamation, at initial mounts or rerenders and through four readers. Explicit keys bypass source-ID and derived-IR rejection; single-result rejection applies only to infinite hooks, and abandoned-after-hook histories exclude Suspense because its preload already starts the sources. Svelte crosses collision, unhashable, factory, compile, and abandoned-setup causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isLoadingError`, fails all six collision cells. The earlier dependency-list Suspense cache-claim failures are historical. Current ordinary [source-ID tests](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse.test.tsx) check separate client-scoped Suspense hooks and an initial suspended retry with different same-ID sources; they use the current derived-identity API. These fixed histories are supplemental tests, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. React ordinary/infinite hooks and Svelte's compiled query release at their render boundaries. The current React Suspense rejection histories fail within its shared ordinary-hook preparation; removing only the outer Suspense wrapper's catch is an unreached mutant, not a demonstrated kill. The former dependency-array SSR history was removed because its API is absent from the RC; it had executed no cells in the preceding grammar. Routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for the current React derived-identity path and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Preact, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. |
-| Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. |
+| Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts); [Supabase registration](https://github.com/TanStack/db/blob/main/packages/supabase-db-collection/e2e/supabase.e2e.test.ts) | Electric, Query, TrailBase, and Supabase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. Supabase runs against a local Supabase stack (PostgREST and Realtime) and skips `should maintain query state during data changes`: local Realtime adds about 500 ms per change, so the case outlives the five-second cleanup of a live query without subscribers. These are receiving paths for core query and Collection laws, not framework render cuts. |
+| Supabase `experimental_queryOnce` server aggregates | [server-aggregate oracle](https://github.com/TanStack/db/blob/main/packages/supabase-db-collection/e2e/query-once-aggregate-oracle.e2e.test.ts) | Differential law (IFCS-154): `experimental_queryOnce` resolves to what TanStack DB's `queryOnce` resolves to over an eager Collection of the same table. Generated histories against a local Supabase stack with PostgREST aggregates enabled vary the rows (nulls, an empty table, text, a timestamptz and two numbers stored as text read with `parse`), `count`/`sum`/`avg`/`min`/`max`, pushable and unpushable filters, a filter-only subquery source, `limit`/`offset`, and `findOne`. Reach credits the server route with an empty match, a null value, `findOne`, and a subquery source, and the TanStack DB route. The comparison projects each row to the select's aliases and drops the virtual fields `$key`, `$origin`, `$hasPendingWrites`, and `$collectionId` (maintainer decision, Ivan Vasilov, 2026-11-11): they describe the temporary live-query Collection that `queryOnce` creates, which the server path does not create. Limits: `min`/`max` are generated only over columns that hold no text, because TanStack DB's own result there depends on row order (see the reusable-law backlog); values are small integers and decimals that doubles represent exactly; one local stack and one table; no grouped, joined, or computed queries. |
| Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. |
| Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. |
| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The utility owner additionally generates widths 6–12 of ambiguous object-valued Sets. An independent count/capacity model judges equal reordered bags, late unmatched prototypes, multiplicity deficits, and a generic plain/class bridge that needs reassignment. Row comparison requires exact shape/value counts; generic equality permits keyed class/plain/null bridges. General and row comparison paths run in both directions, with acyclic members or links back to the Set. Separate cubic scalar-field-read and scoped native Map/Set-operation ceilings reject repeated-comparison and cached-candidate factorial searches without a timing assertion. Empty and singleton neighbors use the same model. Thirteen declared states each reach 200 histories. A paired-input lane adds widths 1,024–3,072, distinct/interchangeable members, and root-linked cycles. Its eight states each reach 20 histories; linear scalar-read and native-operation ceilings protect this equal corresponding-member family. Arbitrary unordered inputs retain the polynomial fallback; arbitrary graph topology and nested-value cost remain outside these bounded work laws. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that `sameRowValue` preserves record prototypes (maintainer decision 2026-11-03), while exported `deepEquals` retains its established keyed-class/plain/null/cross-realm bridge, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. |
@@ -4325,6 +4326,18 @@ boundary. Track the generalized repairs here instead of accumulating isolated
regressions. Completion requires an executable owner, a production-path witness,
a hostile wrong-answer control, and an explicit statement of remaining limits.
+- [ ] **Order-dependent `min`/`max` over text with nulls.** TanStack DB maps
+ a null to `0` before `min` and `max` compare values with `<`, and
+ JavaScript orders text and `0` inconsistently (`'a' < 0` and `0 < 'a'`
+ are both false). Over labels `'a'`, `null`, `null` an ungrouped
+ `min(label)` returns `'a'` or `0` depending on the order in which rows
+ reach the reduction. The Supabase server-aggregate oracle found it on
+ November 11: the same query over an on-demand and an eager Collection of
+ the same table disagreed. Owner: Aggregate selects
+ (`packages/db/tests/query/aggregate-select-oracle.property.test.ts`).
+ Needed witness: `min`/`max` over a text column with nulls, compared
+ across two arrival orders of the same rows. The Supabase oracle generates
+ `min`/`max` only over columns that hold no text until this is decided.
- [ ] **Unchanged `NaN` keys at optimistic mutation admission.** With an eager
Collection keyed by `row.id`, preload a row whose `id` is `NaN`, then
call `update(NaN, row => { row.label = "held" })`. Admission throws
diff --git a/docs/installation.md b/docs/installation.md
index 070a005f8e..405f9df71a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -129,6 +129,16 @@ npm install @tanstack/trailbase-db-collection
Use `trailBaseCollectionOptions` to sync records from TrailBase's Record APIs with built-in subscription support.
+#### Supabase Collection
+
+For syncing with [Supabase](https://supabase.com) tables through PostgREST and Realtime:
+
+```sh
+npm install @tanstack/supabase-db-collection @tanstack/query-core @supabase/supabase-js
+```
+
+Use `supabaseCollectionOptions` to load rows from Supabase tables, apply optimistic mutations, and receive changes from other clients through Realtime. Row Level Security is respected.
+
#### PowerSync Collection
For offline-first sync with [PowerSync](https://powersync.com):
diff --git a/docs/overview.md b/docs/overview.md
index 66c02463b8..2acd515d35 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -225,6 +225,8 @@ TanStack DB provides several built-in collection types for different data source
- **[TrailBaseCollection](./collections/trailbase-collection.md)** — Sync data into collections using TrailBase's self-hosted backend with real-time subscriptions.
+- **[SupabaseCollection](./collections/supabase-collection.md)** — Sync data into collections from Supabase tables using PostgREST queries and Realtime subscriptions, respecting Row Level Security.
+
- **[RxDBCollection](./collections/rxdb-collection.md)** — Integrate with RxDB for offline-first local persistence with powerful replication and sync capabilities.
- **[PowerSyncCollection](./collections/powersync-collection.md)** — Sync with PowerSync's SQLite-based database for offline-first persistence with real-time synchronization with PostgreSQL, MongoDB, and MySQL backends.
@@ -291,7 +293,7 @@ The collection will use the schema for its type inference. If you provide a sche
You can create your own collection types by implementing the `Collection` interface found in [`../packages/db/src/collection/index.ts`](https://github.com/TanStack/db/blob/main/packages/db/src/collection/index.ts).
-See the existing implementations in [`../packages/db`](https://github.com/TanStack/db/tree/main/packages/db), [`../packages/query-db-collection`](https://github.com/TanStack/db/tree/main/packages/query-db-collection), [`../packages/electric-db-collection`](https://github.com/TanStack/db/tree/main/packages/electric-db-collection), [`../packages/trailbase-db-collection`](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection) and [`../packages/powersync-db-collection`](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection) for reference. Also see the [Collection Options Creator guide](./guides/collection-options-creator.md) for a pattern to create reusable collection configuration factories.
+See the existing implementations in [`../packages/db`](https://github.com/TanStack/db/tree/main/packages/db), [`../packages/query-db-collection`](https://github.com/TanStack/db/tree/main/packages/query-db-collection), [`../packages/electric-db-collection`](https://github.com/TanStack/db/tree/main/packages/electric-db-collection), [`../packages/trailbase-db-collection`](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection), [`../packages/supabase-db-collection`](https://github.com/TanStack/db/tree/main/packages/supabase-db-collection) and [`../packages/powersync-db-collection`](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection) for reference. Also see the [Collection Options Creator guide](./guides/collection-options-creator.md) for a pattern to create reusable collection configuration factories.
### Live queries
diff --git a/domain_map.yaml b/domain_map.yaml
index 1d91bc7abf..0751efa0c2 100644
--- a/domain_map.yaml
+++ b/domain_map.yaml
@@ -52,6 +52,7 @@ skills:
- 'queryCollectionOptions (@tanstack/query-db-collection)'
- 'electricCollectionOptions (@tanstack/electric-db-collection)'
- 'trailbaseCollectionOptions (@tanstack/trailbase-db-collection)'
+ - 'supabaseCollectionOptions (@tanstack/supabase-db-collection)'
- 'powerSyncCollectionOptions (@tanstack/powersync-db-collection)'
- 'rxdbCollectionOptions (@tanstack/rxdb-db-collection)'
- 'localOnlyCollectionOptions'
@@ -92,6 +93,9 @@ skills:
- name: 'TrailBase adapter'
package: '@tanstack/trailbase-db-collection'
config_surface: 'recordApi, conversions'
+ - name: 'Supabase adapter'
+ package: '@tanstack/supabase-db-collection'
+ config_surface: 'tableName, keys, schema, supabase, queryClient, realtime, realtimeUseFilter, syncMode (on-demand default/eager), defaultStringCollation, parse, serialize'
- name: 'Local-only'
package: '@tanstack/db'
config_surface: 'getKey, schema, initialData'
diff --git a/packages/db/skills/db-core/SKILL.md b/packages/db/skills/db-core/SKILL.md
index 496c7405bc..b0b6873f0c 100644
--- a/packages/db/skills/db-core/SKILL.md
+++ b/packages/db/skills/db-core/SKILL.md
@@ -3,7 +3,8 @@ name: db-core
description: >
TanStack DB core concepts: createCollection with queryCollectionOptions,
electricCollectionOptions, powerSyncCollectionOptions, rxdbCollectionOptions,
- trailBaseCollectionOptions, localOnlyCollectionOptions. Live queries via
+ trailBaseCollectionOptions, supabaseCollectionOptions,
+ localOnlyCollectionOptions. Live queries via
query builder (from, where, join, select, groupBy, orderBy, limit). Optimistic
mutations with draft proxy (collection.insert, collection.update,
collection.delete). createOptimisticAction, createTransaction,
diff --git a/packages/db/skills/db-core/collection-setup/SKILL.md b/packages/db/skills/db-core/collection-setup/SKILL.md
index 0d672663e4..48b68f6c0e 100644
--- a/packages/db/skills/db-core/collection-setup/SKILL.md
+++ b/packages/db/skills/db-core/collection-setup/SKILL.md
@@ -5,8 +5,8 @@ description: >
queryCollectionOptions (REST/TanStack Query), electricCollectionOptions
(ElectricSQL real-time sync), powerSyncCollectionOptions (PowerSync SQLite),
rxdbCollectionOptions (RxDB), trailBaseCollectionOptions (TrailBase),
- localOnlyCollectionOptions, localStorageCollectionOptions,
- indexedDBCollectionOptions. CollectionConfig
+ supabaseCollectionOptions (Supabase), localOnlyCollectionOptions,
+ localStorageCollectionOptions, indexedDBCollectionOptions. CollectionConfig
options: getKey, schema, sync, gcTime, autoIndex (default off), defaultIndexType,
syncMode (eager/on-demand, plus progressive for Electric). StandardSchema validation
with Zod/Valibot/ArkType. Collection lifecycle (idle/loading/ready/error).
@@ -25,6 +25,7 @@ sources:
- 'TanStack/db:docs/collections/rxdb-collection.md'
- 'TanStack/db:docs/collections/trailbase-collection.md'
- 'TanStack/db:docs/collections/indexed-db-collection.md'
+ - 'TanStack/db:docs/collections/supabase-collection.md'
- 'TanStack/db:packages/db/src/collection/index.ts'
---
@@ -87,6 +88,7 @@ const todoCollection = createCollection(
| PowerSync (SQLite offline) | `powerSyncCollectionOptions` | `@tanstack/powersync-db-collection` |
| RxDB (reactive database) | `rxdbCollectionOptions` | `@tanstack/rxdb-db-collection` |
| TrailBase (event streaming) | `trailBaseCollectionOptions` | `@tanstack/trailbase-db-collection` |
+| Supabase (Postgres + Realtime) | `supabaseCollectionOptions` | `@tanstack/supabase-db-collection` |
| No backend (UI state) | `localOnlyCollectionOptions` | `@tanstack/db` |
| Browser localStorage | `localStorageCollectionOptions` | `@tanstack/db` |
| Browser IndexedDB | `indexedDBCollectionOptions` | `@tanstack/db` |
@@ -464,6 +466,7 @@ Source: docs/collections/query-collection.md
- [PowerSync adapter](references/powersync-adapter.md)
- [RxDB adapter](references/rxdb-adapter.md)
- [TrailBase adapter](references/trailbase-adapter.md)
+- [Supabase adapter](references/supabase-adapter.md)
- [Local adapters (local-only, localStorage)](references/local-adapters.md)
- [Schema validation patterns](references/schema-patterns.md)
diff --git a/packages/db/skills/db-core/collection-setup/references/supabase-adapter.md b/packages/db/skills/db-core/collection-setup/references/supabase-adapter.md
new file mode 100644
index 0000000000..37f569fe79
--- /dev/null
+++ b/packages/db/skills/db-core/collection-setup/references/supabase-adapter.md
@@ -0,0 +1,151 @@
+# Supabase Adapter Reference
+
+## Install
+
+```bash
+pnpm add @tanstack/supabase-db-collection @tanstack/react-db @tanstack/query-core @supabase/supabase-js
+```
+
+## Required Config
+
+```typescript
+import { createCollection } from '@tanstack/react-db'
+import { supabaseCollectionOptions } from '@tanstack/supabase-db-collection'
+import { createClient } from '@supabase/supabase-js'
+import { z } from 'zod'
+
+const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
+
+const todoSchema = z.object({
+ id: z.string(),
+ title: z.string(),
+ completed: z.boolean(),
+})
+
+const todosCollection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ keys: ['id'],
+ schema: todoSchema,
+ supabase,
+ }),
+)
+```
+
+- `tableName` -- Postgres table name (PostgREST endpoint); also used as the collection id
+- `keys` -- column(s) that uniquely identify a row; match the primary key(s). The collection key is the column value as a string (`6` becomes `'6'`); composite keys are a JSON array of the string values, such as `'["a","b"]'`. Pass that key to `get`, `update`, and `delete`
+- `schema` -- Standard Schema (Zod, Valibot, ...) describing one row
+- `supabase` -- `SupabaseClient` used for queries, mutations, and Realtime
+
+## Optional Config
+
+| Option | Default | Description |
+| ------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `syncMode` | `'on-demand'` | `'on-demand'` loads only what live queries need (pushes where/orderBy/limit to PostgREST); `'eager'` loads the whole table, paging past `db-max-rows` |
+| `realtime` | `false` | Subscribe to Postgres changes and reconcile other clients' inserts/updates/deletes |
+| `realtimeUseFilter` | `false` | Experimental. Push each query's WHERE to Realtime as a `postgres_changes` filter to cut message volume |
+| `queryClient` | shared client | TanStack Query client used for the cache. Shared in the browser, new per call on the server; a `DbClient`'s `queryClient` dependency takes precedence |
+| `defaultStringCollation` | TanStack DB default | `StringCollationConfig` from `@tanstack/db`; orders strings to match the database collation |
+| `parse` / `serialize` | none | Per-column converters between PostgREST JSON and the schema type (e.g. `timestamptz` string to `Date`); skipped for `null` |
+
+## Real-time Subscriptions
+
+Add the table to the `supabase_realtime` publication first:
+
+```sql
+alter publication supabase_realtime add table "public"."todos";
+```
+
+Then set `realtime: true`. RLS applies to Realtime. Without RLS every change is broadcast to every client. Realtime can consume more messages than expected; test with a spend cap.
+
+## Persistence Handlers
+
+The adapter owns `onInsert`, `onUpdate`, and `onDelete`: they write through PostgREST (`POST`/`PATCH`/`DELETE`, matching on the `keys` columns), write the server-returned row back into the collection, and roll back the optimistic change if the server rejects it. Custom mutation handlers are not part of the options.
+
+## Query Pushdown
+
+Pushed to PostgREST: `where` (`eq`, `gt`, `gte`, `lt`, `lte`, `inArray`, `like`, `ilike`, `not`, `isNull`, `and`, `or`), `orderBy` on source columns, `limit`, and join keys (`in` filter). Evaluated client-side: computed selects, aggregates, `groupBy`, `having`, `distinct`, except an aggregate-only `count`/`sum`/`avg` in `experimental_queryOnce`. Reads page past PostgREST's `db-max-rows` (1000 by default) with `offset`; a multi-page read also orders by the `keys` columns so the pages form one sequence.
+
+## experimental_queryOnce
+
+```typescript
+import { eq } from '@tanstack/react-db'
+import { experimental_queryOnce } from '@tanstack/supabase-db-collection'
+
+const completed = await experimental_queryOnce(
+ (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => eq(todo.completed, true)),
+ supabase,
+)
+```
+
+One-shot, non-reactive fetch. It always resolves to what TanStack DB's `queryOnce` returns, and it is experimental because it reads TanStack DB's internal query IR.
+
+A query runs as one PostgREST request only when its select holds just `count`, `sum`, or `avg` of a plain column of one Supabase table (not `sum`/`avg` over a `parse` column), every filter can be pushed, and it has no `groupBy`, `having`, join, `orderBy`, `limit`, `offset`, `distinct`, or functional clause. That path needs PostgREST aggregates:
+
+```sql
+alter role authenticator set pgrst.db_aggregates_enabled = 'true';
+notify pgrst, 'reload config';
+```
+
+Every other query, including `min`, `max`, `groupBy`, and `having`, runs through TanStack DB's `queryOnce` over the collections, which push filters, order, and limit to PostgREST. TanStack DB's aggregate rules apply: an empty match gives `[]` (`undefined` for `findOne`), and an all-null `sum` gives `0`.
+
+## Use with DbClient
+
+Under a `DbClient`, wrap the options in `collectionOptions` and materialize with `dbClient.collection`. The client's `queryClient` dependency is used for the cache.
+
+```typescript
+import { QueryClient } from '@tanstack/query-core'
+import { DbClient, collectionOptions } from '@tanstack/db'
+
+const todosDescriptor = collectionOptions(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ keys: ['id'],
+ schema: todoSchema,
+ supabase,
+ }),
+)
+
+const dbClient = new DbClient({ queryClient: new QueryClient() })
+const todos = dbClient.collection(todosDescriptor)
+```
+
+## Complete Example
+
+```tsx
+import { createCollection, useLiveQuery, eq } from '@tanstack/react-db'
+import { supabaseCollectionOptions } from '@tanstack/supabase-db-collection'
+
+const todosCollection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'todos',
+ keys: ['id'],
+ schema: todoSchema,
+ supabase,
+ syncMode: 'on-demand',
+ realtime: true,
+ }),
+)
+
+function Todos() {
+ const { data } = useLiveQuery({
+ query: (q) =>
+ q
+ .from({ todo: todosCollection })
+ .where(({ todo }) => eq(todo.completed, false)),
+ })
+
+ const add = (title: string) =>
+ todosCollection.insert({ id: crypto.randomUUID(), title, completed: false })
+
+ const complete = (id: string) =>
+ todosCollection.update(id, (draft) => {
+ draft.completed = true
+ })
+
+ // ...
+}
+```
diff --git a/packages/supabase-db-collection/e2e/e2e.utils.ts b/packages/supabase-db-collection/e2e/e2e.utils.ts
new file mode 100644
index 0000000000..660669a967
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/e2e.utils.ts
@@ -0,0 +1,164 @@
+/* eslint-disable no-empty-pattern -- vitest fixtures without dependencies must destructure an empty context object */
+import { createClient } from '@supabase/supabase-js'
+import { createCollection, createLiveQueryCollection } from '@tanstack/db'
+import { QueryClient } from '@tanstack/query-core'
+import { test as baseTest, expect, inject, vi } from 'vitest'
+import { supabaseCollectionOptions } from '../src/index'
+import { realtimeChannelTopicPrefix } from '../src/realtime'
+import { usersSchema } from '../tests/test.utils'
+import { makeAdminClient, resetE2e } from './setup'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+// Shared polling window for awaiting async PostgREST/realtime propagation.
+export const WAIT = { timeout: 15_000, interval: 200 } as const
+
+// A fresh Supabase client using the anon key, exactly like a browser would.
+const makeSupabase = (): SupabaseClient =>
+ createClient(inject('supabaseUrl'), inject('supabaseAnonKey'))
+
+// Each collection gets its own QueryClient: the realtime channel registry in
+// src/db.ts is keyed by QueryClient, so a fresh one per collection prevents
+// channels leaking across tests.
+export const makeUsersCollection = ({
+ realtime = false,
+ realtimeUseFilter = false,
+} = {}) => {
+ const supabase = makeSupabase()
+ const collection = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'users',
+ keys: ['id'],
+ schema: usersSchema,
+ supabase,
+ queryClient: new QueryClient(),
+ realtime,
+ realtimeUseFilter,
+ }),
+ )
+ return { collection, supabase }
+}
+
+// A realtime collection that pushes each live query's WHERE down to the
+// server-side postgres_changes filter (see realtimeUseFilter in src/db.ts).
+export const makeFilteredRealtimeUsers = () =>
+ makeUsersCollection({ realtime: true, realtimeUseFilter: true })
+
+type UsersContext = ReturnType
+type UsersCollection = UsersContext['collection']
+
+// An on-demand collection only loads when a live query drives demand. This live
+// query also creates the active query that the realtime channel attaches to.
+const liveUsers = (base: UsersCollection) =>
+ createLiveQueryCollection((q) =>
+ q.from({ row: base }).select(({ row }) => ({
+ id: row.id,
+ name: row.name,
+ email: row.email,
+ active: row.active,
+ })),
+ )
+
+type LiveUsersCollection = ReturnType
+
+// Waits until one of the adapter's realtime channels for the table is actually
+// joined, so changes written afterwards are guaranteed to be captured. The
+// adapter numbers its channel topics (see realtimeChannelTopicPrefix in
+// src/db.ts), and supabase-js exposes them under its own "realtime:" prefix.
+export const waitForChannel = (supabase: SupabaseClient, table: string) =>
+ vi.waitFor(() => {
+ const prefix = `realtime:${realtimeChannelTopicPrefix(table)}`
+ const joined = supabase
+ .getChannels()
+ .some((c) => c.topic.startsWith(prefix) && c.state === 'joined')
+ expect(joined).toBe(true)
+ }, WAIT)
+
+// A live query and its base collection both expose these; the helper below only
+// needs to drive their lifecycle, not their query shape.
+type Preloadable = {
+ preload: () => Promise
+ cleanup: () => Promise
+}
+
+// Preloads one or more filtered realtime live queries over a shared collection
+// and blocks until the channel has actually joined — which only happens once the
+// server accepted the filters these queries emit, so it doubles as proof the
+// emitted filter syntax is valid. Returns a cleanup to await in the test's
+// `finally`, tearing down the live queries, the base collection, and the
+// client's channels.
+export const startFilteredRealtime = async (
+ ctx: UsersContext,
+ ...lives: Array
+) => {
+ await Promise.all(lives.map((live) => live.preload()))
+ await waitForChannel(ctx.supabase, 'users')
+ return async () => {
+ await Promise.all(lives.map((live) => live.cleanup()))
+ await ctx.collection.cleanup()
+ await ctx.supabase.removeAllChannels()
+ }
+}
+
+const preloadSeeded = async (live: LiveUsersCollection) => {
+ await live.preload()
+ // The reset_e2e() seed always yields exactly Alice and Bob.
+ await vi.waitFor(() => expect(live.size).toBe(2), WAIT)
+}
+
+// Fixtures are lazy: a test only pays for what it destructures. Teardown runs
+// in reverse initialization order and ALWAYS runs, even when the test fails —
+// unlike trailing cleanup calls in a test body.
+export const test = baseTest.extend<{
+ /** Base users collection + its anon client. */
+ users: UsersContext
+ /** Preloaded live query over `users`, seeded rows awaited. */
+ usersLive: LiveUsersCollection
+ /** Like `users` but with realtime enabled. */
+ rtUsers: UsersContext
+ /** Preloaded live query over `rtUsers`, realtime channel joined. */
+ rtUsersLive: LiveUsersCollection
+ /** A second anon client acting as "another user" for realtime writes. */
+ other: SupabaseClient
+ /** Resets the users tables to the Alice/Bob fixture before every test. */
+ resetDatabase: void
+}>({
+ resetDatabase: [
+ async ({}, use) => {
+ await resetE2e(makeAdminClient())
+ await use()
+ },
+ { auto: true },
+ ],
+ users: async ({}, use) => {
+ const ctx = makeUsersCollection()
+ await use(ctx)
+ await ctx.collection.cleanup()
+ await ctx.supabase.removeAllChannels()
+ },
+ usersLive: async ({ users }, use) => {
+ const live = liveUsers(users.collection)
+ await preloadSeeded(live)
+ await use(live)
+ await live.cleanup()
+ },
+ rtUsers: async ({}, use) => {
+ const ctx = makeUsersCollection({ realtime: true })
+ await use(ctx)
+ await ctx.collection.cleanup()
+ await ctx.supabase.removeAllChannels()
+ },
+ rtUsersLive: async ({ rtUsers }, use) => {
+ const live = liveUsers(rtUsers.collection)
+ await preloadSeeded(live)
+ // Wait until the realtime channel is joined before the test writes,
+ // otherwise a change could be published before the subscription exists.
+ await waitForChannel(rtUsers.supabase, 'users')
+ await use(live)
+ await live.cleanup()
+ },
+ other: async ({}, use) => {
+ const client = makeSupabase()
+ await use(client)
+ await client.removeAllChannels()
+ },
+})
diff --git a/packages/supabase-db-collection/e2e/filters.e2e.test.ts b/packages/supabase-db-collection/e2e/filters.e2e.test.ts
new file mode 100644
index 0000000000..db0e283094
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/filters.e2e.test.ts
@@ -0,0 +1,171 @@
+import {
+ IR,
+ and,
+ createLiveQueryCollection,
+ eq,
+ ilike,
+ inArray,
+ like,
+ not,
+ or,
+ upper,
+} from '@tanstack/db'
+import { expect } from 'vitest'
+import { experimental_queryOnce } from '../src/index'
+import { executeQuery } from '../src/query-once'
+import { makeUsersCollection, test } from './e2e.utils'
+
+const name = new IR.PropRef([`user`, `name`])
+const active = new IR.PropRef([`user`, `active`])
+const id = new IR.PropRef([`user`, `id`])
+
+// Each case reads the same filter on three paths, and every path asserts the
+// rows it returns, not the shape of a URL:
+//
+// - An on-demand live query sends its WHERE to PostgREST and becomes ready
+// once that read has finished, so its rows are the server's answer filtered
+// again on the client.
+// - `experimental_queryOnce` runs the same one-shot query through TanStack
+// DB's `queryOnce`.
+// - `executeQuery` sends a serialized query IR straight to PostgREST, through
+// the translation that server-side aggregates use. Nothing filters its rows
+// again, so a filter the server applies too loosely shows up here.
+//
+// A filter the server applies too strictly loses rows, which the client
+// cannot add back. Each call reads through a fresh collection, so rows an
+// earlier query loaded cannot hide the loss.
+const withUsers = async (
+ read: (users: ReturnType) => Promise,
+): Promise => {
+ const users = makeUsersCollection()
+ try {
+ return await read(users)
+ } finally {
+ await users.collection.cleanup()
+ await users.supabase.removeAllChannels()
+ }
+}
+
+const sortedNames = (rows: Array) =>
+ rows.map((row) => (row as { name: string }).name).sort()
+
+const liveNames = (where: IR.BasicExpression) =>
+ withUsers(async ({ collection }) => {
+ const live = createLiveQueryCollection((q) =>
+ q.from({ user: collection }).where(() => where),
+ )
+ try {
+ await live.preload()
+ return sortedNames(live.toArray)
+ } finally {
+ await live.cleanup()
+ }
+ })
+
+const queryOnceNames = (where: IR.BasicExpression) =>
+ withUsers(async ({ collection, supabase }) =>
+ sortedNames(
+ await experimental_queryOnce(
+ (q) => q.from({ user: collection }).where(() => where),
+ supabase,
+ ),
+ ),
+ )
+
+const serverNames = (where: IR.BasicExpression) =>
+ withUsers(async ({ supabase }) =>
+ sortedNames(
+ await executeQuery(supabase, {
+ from: { type: `table`, name: `users`, alias: `user` },
+ where: [where],
+ }),
+ ),
+ )
+
+const expectNamesOnEveryPath = async (
+ where: IR.BasicExpression,
+ expected: ReadonlyArray,
+) => {
+ const label = JSON.stringify(where)
+ expect(await liveNames(where), `live query ${label}`).toEqual(expected)
+ expect(await queryOnceNames(where), `queryOnce ${label}`).toEqual(expected)
+ expect(await serverNames(where), `executeQuery ${label}`).toEqual(expected)
+}
+
+const cases = [
+ [`OR`, or(eq(name, `Alice`), eq(name, `Nobody`)), [`Alice`]],
+ [
+ `nested AND/OR`,
+ or(and(eq(active, true), eq(name, `Bob`)), eq(id, 2)),
+ [`Bob`],
+ ],
+ [`NOT AND`, not(and(eq(active, true), eq(name, `Alice`))), [`Bob`]],
+ [`NOT OR`, not(or(eq(active, true), eq(name, `Nobody`))), [`Bob`]],
+ [`double NOT`, not(not(or(eq(active, true), eq(name, `Nobody`)))), [`Alice`]],
+ [`LIKE`, like(name, `A_i%`), [`Alice`]],
+ [`ILIKE`, ilike(name, `%ALI%`), [`Alice`]],
+ [`NOT LIKE`, not(like(name, `A%`)), [`Bob`]],
+ [
+ `OR ILIKE`,
+ or(ilike(name, `%ali%`), ilike(name, `%bOB%`)),
+ [`Alice`, `Bob`],
+ ],
+ [`overlapping IN`, and(inArray(id, [1, 2]), inArray(id, [2, 3])), [`Bob`]],
+ [`empty IN`, inArray(id, []), []],
+ [`NOT empty IN`, not(inArray(id, [])), [`Alice`, `Bob`]],
+ [`NOT IN with null`, not(inArray(name, [`Alice`, null])), [`Bob`]],
+] as const
+
+for (const [label, where, expected] of cases) {
+ test(`${label} returns matching rows on every query path`, async () => {
+ await expectNamesOnEveryPath(where, expected)
+ })
+}
+
+for (const value of [``, ` a `, `a,b(c)`, `a"b\\c`, `a.b:c&d=1+%`, `{a,b}`]) {
+ test(`reserved value ${JSON.stringify(value)} round-trips through filters`, async ({
+ other,
+ }) => {
+ const { error } = await other
+ .from(`users`)
+ .insert({ name: value, email: `special@test.com` } as unknown as never)
+ expect(error).toBeNull()
+ for (const where of [
+ eq(name, value),
+ or(eq(name, value), eq(id, -1)),
+ inArray(name, [value]),
+ ]) {
+ await expectNamesOnEveryPath(where, [value])
+ }
+ })
+}
+
+test(`LIKE treats backslashes literally on every query path`, async ({
+ other,
+}) => {
+ const value = `a\\path`
+ const { error } = await other
+ .from(`users`)
+ .insert({ name: value, email: `slash@test.com` } as unknown as never)
+ expect(error).toBeNull()
+ for (const where of [
+ like(name, `a\\%`),
+ or(like(name, `a\\%`), eq(id, -1)),
+ ]) {
+ await expectNamesOnEveryPath(where, [value])
+ }
+})
+
+// executeQuery is left out here: it returns PostgREST's rows without
+// filtering them again, so a filter PostgREST cannot express has no exact
+// answer on that path.
+test(`unsupported OR and negated patterns retain rows for client filtering`, async () => {
+ for (const read of [liveNames, queryOnceNames]) {
+ expect(
+ await read(or(eq(name, `Nobody`), eq(upper(name), `ALICE`))),
+ ).toEqual([`Alice`])
+ // PostgREST would interpret * as a wildcard and remove Alice here, whereas
+ // TanStack treats it literally and should keep both seeded names.
+ expect(await read(not(like(name, `*Ali*`)))).toEqual([`Alice`, `Bob`])
+ }
+})
diff --git a/packages/supabase-db-collection/e2e/global-setup.ts b/packages/supabase-db-collection/e2e/global-setup.ts
new file mode 100644
index 0000000000..7a71deeca6
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/global-setup.ts
@@ -0,0 +1,173 @@
+import { execFile } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { createClient } from '@supabase/supabase-js'
+import type { TestProject } from 'vitest/node'
+
+const execFileAsync = promisify(execFile)
+
+// The Supabase project (config.toml and migrations) lives in this package,
+// regardless of the directory vitest was launched from.
+const PACKAGE_DIR = fileURLToPath(new URL(`..`, import.meta.url))
+
+// Generous enough for a cold CI runner to pull the container images.
+const START_TIMEOUT_MS = 600_000
+const STOP_TIMEOUT_MS = 120_000
+
+// Realtime warm-up: a freshly started realtime container needs a moment before
+// its Postgres replication is fully wired, so the first subscription can miss
+// changes. We subscribe to the dedicated e2e_warmup scratch table and upsert
+// rows into it until an event is actually delivered, guaranteeing realtime is
+// live before any test runs - without ever touching the tables tests read.
+const WARMUP_TIMEOUT_MS = 60_000
+const WARMUP_ATTEMPTS = 40
+const WARMUP_POKE_INTERVAL_MS = 1000
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+
+// Values published to the e2e test workers via `inject(...)` (see setup.ts).
+declare module 'vitest' {
+ export interface ProvidedContext {
+ supabaseAnonKey: string
+ supabaseServiceRoleKey: string
+ supabaseUrl: string
+ }
+}
+
+interface SupabaseStatus {
+ API_URL?: string
+ ANON_KEY?: string
+ SERVICE_ROLE_KEY?: string
+}
+
+// The CLI invocation, e.g. `supabase` (default) or `pnpm dlx supabase@2.116.0`.
+const cliCommand = (): [string, Array] => {
+ const [command, ...args] = (process.env.SUPABASE_CLI ?? `supabase`)
+ .split(` `)
+ .filter(Boolean)
+ return [command ?? `supabase`, args]
+}
+
+const runSupabase = (args: Array, timeout: number) => {
+ const [command, baseArgs] = cliCommand()
+ return execFileAsync(command, [...baseArgs, ...args], {
+ cwd: PACKAGE_DIR,
+ timeout,
+ })
+}
+
+// Returns the parsed `supabase status` JSON, or null when no stack is running.
+const readStatus = async (): Promise => {
+ try {
+ const { stdout } = await runSupabase(
+ [`status`, `-o`, `json`],
+ STOP_TIMEOUT_MS,
+ )
+ return JSON.parse(stdout.slice(stdout.indexOf(`{`))) as SupabaseStatus
+ } catch (error) {
+ // A missing CLI binary is a setup problem, not a stopped stack - surface it
+ // instead of falling through to a confusing `supabase start`.
+ if ((error as NodeJS.ErrnoException).code === `ENOENT`) {
+ throw new Error(
+ `Could not run the Supabase CLI. Install it, or point SUPABASE_CLI at it (e.g. SUPABASE_CLI="pnpm dlx supabase@2.116.0").`,
+ )
+ }
+ // Non-zero exit (stack not running) or non-JSON output: treat as no stack.
+ return null
+ }
+}
+
+const warmUpRealtime = async (url: string, anonKey: string): Promise => {
+ const client = createClient(url, anonKey)
+ let received = false
+
+ // Upserts never conflict with rows left over from a previous run of the same
+ // stack, and every upsert produces a WAL change (INSERT or UPDATE) for the
+ // listener below. supabase-js resolves with { error } instead of throwing.
+ const poke = async (): Promise => {
+ for (let attempt = 0; attempt < WARMUP_ATTEMPTS && !received; attempt++) {
+ await client.from(`e2e_warmup`).upsert({ id: attempt })
+ await delay(WARMUP_POKE_INTERVAL_MS)
+ }
+ }
+
+ try {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () =>
+ reject(
+ new Error(
+ `Realtime warm-up timed out; is the realtime container healthy?`,
+ ),
+ ),
+ WARMUP_TIMEOUT_MS,
+ )
+ client
+ .channel(`e2e-warmup`)
+ .on(
+ `postgres_changes`,
+ { event: `*`, schema: `public`, table: `e2e_warmup` },
+ () => {
+ received = true
+ clearTimeout(timer)
+ resolve()
+ },
+ )
+ .subscribe((status) => {
+ if (status === `SUBSCRIBED`) {
+ poke().catch(() => undefined)
+ }
+ })
+ })
+ } finally {
+ await client.removeAllChannels()
+ }
+}
+
+export default async function setup({ provide }: TestProject) {
+ let startedByUs = false
+ let url = process.env.SUPABASE_URL
+ let anonKey = process.env.SUPABASE_ANON_KEY
+ let serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
+
+ // Only touch Docker when creds were not already supplied by the environment.
+ if (!(url && anonKey && serviceRoleKey)) {
+ let status = await readStatus()
+
+ if (!status) {
+ // Boots only the containers the adapter needs (db, kong, rest, realtime,
+ // auth); the rest are disabled in supabase/config.toml.
+ await runSupabase([`start`], START_TIMEOUT_MS)
+ startedByUs = true
+ status = await readStatus()
+ }
+
+ if (!(status?.API_URL && status.ANON_KEY && status.SERVICE_ROLE_KEY)) {
+ throw new Error(
+ `Could not resolve local Supabase credentials. Is Docker running? Try \`supabase start\` in packages/supabase-db-collection.`,
+ )
+ }
+
+ url = status.API_URL
+ anonKey = status.ANON_KEY
+ serviceRoleKey = status.SERVICE_ROLE_KEY
+ }
+
+ // Confirm realtime is actually delivering before tests rely on it.
+ await warmUpRealtime(url, anonKey)
+
+ provide(`supabaseUrl`, url)
+ provide(`supabaseAnonKey`, anonKey)
+ provide(`supabaseServiceRoleKey`, serviceRoleKey)
+
+ return async () => {
+ // Only stop the stack if this setup started it; never tear down a stack the
+ // developer (or CI) started separately.
+ if (startedByUs) {
+ await runSupabase([`stop`], STOP_TIMEOUT_MS)
+ }
+ }
+}
diff --git a/packages/supabase-db-collection/e2e/mutations.e2e.test.ts b/packages/supabase-db-collection/e2e/mutations.e2e.test.ts
new file mode 100644
index 0000000000..3da74d1a9e
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/mutations.e2e.test.ts
@@ -0,0 +1,116 @@
+import { expect, vi } from 'vitest'
+import { keyColumnsToSearch } from '../src/postgrest-filters'
+import { postgrestRequest } from '../src/postgrest-request'
+import { WAIT, test } from './e2e.utils'
+
+test('update and delete filters preserve timestamp key milliseconds', async ({
+ other,
+}) => {
+ const timestamp = new Date('2024-01-01T12:30:00.123Z')
+ const neighbor = '2024-01-01T12:30:00.456Z'
+ const { error: insertError } = await other.from('timestamp_keys').insert([
+ { recorded_at: timestamp.toISOString(), label: 'target' },
+ { recorded_at: neighbor, label: 'neighbor' },
+ ])
+ expect(insertError).toBeNull()
+
+ await postgrestRequest(other, 'timestamp_keys', {
+ method: 'PATCH',
+ search: keyColumnsToSearch(['recorded_at'], { recorded_at: timestamp }),
+ body: { label: 'updated' },
+ })
+ const { data: updated, error: updateError } = await other
+ .from('timestamp_keys')
+ .select('label')
+ .order('recorded_at')
+ expect(updateError).toBeNull()
+ expect(updated).toEqual([{ label: 'updated' }, { label: 'neighbor' }])
+
+ await postgrestRequest(other, 'timestamp_keys', {
+ method: 'DELETE',
+ search: keyColumnsToSearch(['recorded_at'], { recorded_at: timestamp }),
+ })
+ const { data: remaining, error: deleteError } = await other
+ .from('timestamp_keys')
+ .select('label')
+ expect(deleteError).toBeNull()
+ expect(remaining).toEqual([{ label: 'neighbor' }])
+})
+
+test('inserts a row through PostgREST and reflects the server row', async ({
+ users,
+ usersLive,
+}) => {
+ const tx = users.collection.insert({
+ id: 100,
+ name: 'Carol',
+ email: 'carol@test.com',
+ active: true,
+ })
+ await tx.when('settled')
+
+ await vi.waitFor(
+ () =>
+ expect(usersLive.toArray.some((user) => user.name === 'Carol')).toBe(
+ true,
+ ),
+ WAIT,
+ )
+ expect(usersLive.toArray.find((user) => user.name === 'Carol')?.id).toBe(100)
+
+ // The row really exists in the database.
+ const { data, error } = await users.supabase
+ .from('users')
+ .select('*')
+ .eq('email', 'carol@test.com')
+ .single()
+ expect(error).toBeNull()
+ expect(data?.name).toBe('Carol')
+})
+
+test('updates a row through PostgREST and reflects the server row', async ({
+ users,
+ usersLive,
+}) => {
+ const alice = usersLive.toArray.find((user) => user.name === 'Alice')
+
+ const tx = users.collection.update(String(alice?.id), (draft) => {
+ draft.name = 'Alice Updated'
+ })
+ await tx.when('settled')
+
+ await vi.waitFor(
+ () =>
+ expect(
+ usersLive.toArray.find((user) => user.id === alice?.id)?.name,
+ ).toBe('Alice Updated'),
+ WAIT,
+ )
+
+ const { data, error } = await users.supabase
+ .from('users')
+ .select('*')
+ .eq('id', alice?.id)
+ .single()
+ expect(error).toBeNull()
+ expect(data?.name).toBe('Alice Updated')
+})
+
+test('deletes a row through PostgREST', async ({ users, usersLive }) => {
+ const bob = usersLive.toArray.find((user) => user.name === 'Bob')
+
+ const tx = users.collection.delete(String(bob?.id))
+ await tx.when('settled')
+
+ await vi.waitFor(
+ () =>
+ expect(usersLive.toArray.some((user) => user.id === bob?.id)).toBe(false),
+ WAIT,
+ )
+
+ const { data } = await users.supabase
+ .from('users')
+ .select('*')
+ .eq('id', bob?.id)
+ expect(data).toEqual([])
+})
diff --git a/packages/supabase-db-collection/e2e/query-once-aggregate-oracle.e2e.test.ts b/packages/supabase-db-collection/e2e/query-once-aggregate-oracle.e2e.test.ts
new file mode 100644
index 0000000000..e0f885cdb1
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/query-once-aggregate-oracle.e2e.test.ts
@@ -0,0 +1,642 @@
+/**
+ * Server-aggregate oracle for `experimental_queryOnce`.
+ *
+ * Law: for every query it accepts, `experimental_queryOnce(query, supabase)`
+ * resolves to the same result as TanStack DB's `queryOnce(query)` over the
+ * same table state. Routing a query to PostgREST is an optimization; it must
+ * not change the answer. Authority: IFCS-154 and the function's contract.
+ * Queries that are not aggregate-only "fall back to TanStack DB's own
+ * `queryOnce`", and the server path returns a result "shaped like TanStack's
+ * result", so a caller cannot choose a route and must not see one.
+ *
+ * Why generated histories: an aggregate-only select over one Supabase table is
+ * the only query the server path computes. Its answer depends on details that
+ * fixed examples rarely combine:
+ *
+ * - empty and all-null inputs, where SQL aggregates return `null` or `0`;
+ * - columns the collection reads through `parse`, which PostgREST never sees;
+ * - `min`/`max`, which TanStack DB computes with JavaScript comparisons;
+ * - `limit`, `offset`, and `findOne` on the single aggregate row;
+ * - a filter PostgREST cannot express, such as `eq(upper(label), 'A')`;
+ * - a filter-only subquery source, whose `where` is pushed down too.
+ *
+ * Limits:
+ *
+ * - one local Supabase stack (PostgREST with `db_aggregates_enabled`) and one
+ * table;
+ * - the aggregates `count`, `sum`, `avg`, `min`, and `max`, with `min` and
+ * `max` only over columns that hold no text (see ORDERABLE_COLUMNS);
+ * - small integers and decimals that doubles represent exactly, so the
+ * comparison is exact and does not round averages;
+ * - no grouped, joined, or computed queries. Those take TanStack DB's path on
+ * both sides, so this oracle says nothing about them.
+ */
+import { createClient } from '@supabase/supabase-js'
+import {
+ and,
+ avg,
+ count,
+ createCollection,
+ eq,
+ gt,
+ inArray,
+ isNull,
+ max,
+ min,
+ not,
+ or,
+ queryOnce,
+ sum,
+ upper,
+} from '@tanstack/db'
+import { QueryClient } from '@tanstack/query-core'
+import fc from 'fast-check'
+import { expect, inject, it } from 'vitest'
+import { z } from 'zod'
+import { runOracleCampaign } from '../../db/tests/oracle-campaign'
+import { oracleRuns } from '../../db/tests/oracle-config'
+import { declareOracle } from '../../db/tests/oracle-declaration'
+import { experimental_queryOnce, supabaseCollectionOptions } from '../src/index'
+import { makeAdminClient } from './setup'
+import type { InitialQueryBuilder } from '@tanstack/db'
+
+// ── History grammar ─────────────────────────────────────────────────
+//
+// A history is one table state, written through the service-role client, and
+// one to three aggregate queries run against it. Values come from small
+// domains so that ties, nulls, and empty matches are common:
+//
+// - `score` is a small integer, so sums and averages stay exact in a double;
+// - `label` mixes case and the empty string, which text collations order
+// differently;
+// - `recorded_at` is a timestamptz, read into a `Date` with `parse`;
+// - `big_count` is a bigint stored as text and read with `parse`, so `10`
+// orders after `9` in the collection but before it in Postgres;
+// - `amount` is a decimal stored as text and read with `parse`, so the
+// collection can sum it while Postgres cannot sum text.
+//
+// Only aggregate-only selects over the `query_once_rows` table are generated,
+// so every query is one the server path accepts. Illegal histories (a
+// duplicate id, `sum` over text) are not generated.
+
+const COLUMNS = [
+ `id`,
+ `score`,
+ `label`,
+ `recorded_at`,
+ `big_count`,
+ `amount`,
+] as const
+type Column = (typeof COLUMNS)[number]
+// TanStack DB orders a null as 0 in `min` and `max`, and JavaScript compares
+// text with 0 inconsistently (`'a' < 0` and `0 < 'a'` are both false), so its
+// result over a text column with nulls depends on row order. That is a
+// @tanstack/db defect recorded in docs/contributing/oracle-coverage.md, so
+// `min` and `max` are generated only over columns that hold no text.
+const ORDERABLE_COLUMNS = COLUMNS.filter((column) => column !== `label`)
+const CONVERTED_COLUMNS: ReadonlyArray = [
+ `recorded_at`,
+ `big_count`,
+ `amount`,
+]
+
+interface SeedRow {
+ id: number
+ score: number | null
+ label: string | null
+ recorded_at: string | null
+ big_count: string | null
+ amount: string | null
+}
+
+type Aggregate =
+ | { fn: `count` | `min` | `max`; column: Column }
+ | { fn: `sum` | `avg`; column: `id` | `score` | `amount` }
+
+type Filter =
+ | { kind: `gt`; value: number }
+ | { kind: `eq`; value: string }
+ | { kind: `upper`; value: string }
+ | { kind: `isNull`; column: Column }
+ | { kind: `in`; ids: Array }
+ | { kind: `not`; inner: Filter }
+ | { kind: `and` | `or`; left: Filter; right: Filter }
+
+interface AggregateQuery {
+ aggregates: Array
+ where?: Filter
+ /** A filter applied inside a subquery source instead of the outer query */
+ sourceWhere?: Filter
+ limit?: number
+ offset?: number
+ findOne: boolean
+}
+
+const seedRows = fc
+ .uniqueArray(fc.integer({ min: 1, max: 12 }), { maxLength: 8 })
+ .chain((ids) =>
+ fc.tuple(
+ ...ids.map((id) =>
+ fc.record({
+ id: fc.constant(id),
+ score: fc.option(fc.integer({ min: -3, max: 3 }), { freq: 2 }),
+ label: fc.option(fc.constantFrom(`a`, `A`, `b`, `B`, ``, `a b`), {
+ freq: 2,
+ }),
+ recorded_at: fc.option(
+ fc.constantFrom(
+ `2026-01-01T00:00:00+00:00`,
+ `2026-01-02T12:00:00+00:00`,
+ `2025-12-31T23:59:59+00:00`,
+ ),
+ ),
+ big_count: fc.option(
+ fc.constantFrom(`9`, `10`, `-1`, `9007199254740993`),
+ ),
+ amount: fc.option(fc.constantFrom(`1.5`, `2`, `-0.25`)),
+ }),
+ ),
+ ),
+ )
+
+// Weights keep most queries on the server path (count, sum, and avg with
+// pushable filters and no paging), while every other branch still appears in
+// each campaign. Reach below counts both.
+const aggregate: fc.Arbitrary = fc.oneof(
+ {
+ weight: 2,
+ arbitrary: fc.record({
+ fn: fc.constant(`count` as const),
+ column: fc.constantFrom(...COLUMNS),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ fn: fc.constantFrom(`sum` as const, `avg` as const),
+ // `score` is the nullable plain column, so it carries the null rules.
+ column: fc.oneof(
+ { weight: 2, arbitrary: fc.constant(`score` as const) },
+ {
+ weight: 1,
+ arbitrary: fc.constantFrom(`id` as const, `amount` as const),
+ },
+ ),
+ }),
+ },
+ {
+ weight: 1,
+ arbitrary: fc.record({
+ fn: fc.constantFrom(`min` as const, `max` as const),
+ column: fc.constantFrom(...ORDERABLE_COLUMNS),
+ }),
+ },
+)
+
+const filter: fc.Arbitrary = fc.letrec<{ filter: Filter }>((tie) => ({
+ filter: fc.oneof(
+ { depthSize: `small`, withCrossShrink: true },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constant(`gt` as const),
+ value: fc.integer({ min: -3, max: 3 }),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constant(`eq` as const),
+ value: fc.constantFrom(`a`, `B`, ``),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constant(`isNull` as const),
+ column: fc.constantFrom(...COLUMNS),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constant(`in` as const),
+ ids: fc.uniqueArray(fc.integer({ min: 1, max: 12 }), { maxLength: 3 }),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constant(`not` as const),
+ inner: tie(`filter`),
+ }),
+ },
+ {
+ weight: 3,
+ arbitrary: fc.record({
+ kind: fc.constantFrom(`and` as const, `or` as const),
+ left: tie(`filter`),
+ right: tie(`filter`),
+ }),
+ },
+ {
+ // Rarer than the pushable leaves: one unpushable leaf anywhere in a
+ // query sends it to TanStack DB.
+ weight: 1,
+ arbitrary: fc.record({
+ kind: fc.constant(`upper` as const),
+ value: fc.constantFrom(`A`, `B`),
+ }),
+ },
+ ),
+})).filter
+
+/** `undefined` in three of four cases */
+const rarely = (arbitrary: fc.Arbitrary) =>
+ fc.oneof(
+ { weight: 3, arbitrary: fc.constant(undefined) },
+ { weight: 1, arbitrary },
+ )
+
+const aggregateQuery: fc.Arbitrary = fc
+ .record({
+ aggregates: fc.array(aggregate, { minLength: 1, maxLength: 3 }),
+ where: fc.option(filter, { nil: undefined, freq: 1 }),
+ sourceWhere: fc.option(filter, { nil: undefined, freq: 1 }),
+ paging: rarely(
+ fc.record(
+ { limit: fc.constantFrom(0, 1, 5), offset: fc.constantFrom(0, 1) },
+ { requiredKeys: [] },
+ ),
+ ),
+ findOne: fc.boolean(),
+ })
+ .map(({ paging, ...query }) => ({ ...query, ...paging }))
+
+type History = typeof history extends fc.Arbitrary ? T : never
+
+const history = fc.record({
+ rows: seedRows,
+ queries: fc.array(aggregateQuery, { minLength: 1, maxLength: 3 }),
+})
+
+// ── Production driver ───────────────────────────────────────────────
+//
+// Both sides read the same table through a Supabase collection created by
+// this package, with the same `parse` conversions:
+//
+// - The production side runs `experimental_queryOnce` over an on-demand
+// collection (the default). Its Supabase client records every request, so
+// the driver can tell whether PostgREST computed the aggregate.
+// - The reference side runs TanStack DB's `queryOnce` over an eager collection,
+// which loads every row and computes the aggregate in TanStack DB. It never
+// asks PostgREST to filter or aggregate, so it shares no translation with
+// the server path.
+
+const rowSchema = z.object({
+ id: z.number(),
+ score: z.number().nullable(),
+ label: z.string().nullable(),
+ recorded_at: z.date().nullable(),
+ big_count: z.bigint().nullable(),
+ amount: z.number().nullable(),
+})
+
+const conversions = {
+ parse: {
+ recorded_at: (value: unknown) => new Date(value as string),
+ big_count: (value: unknown) => BigInt(value as string),
+ amount: (value: unknown) => Number(value),
+ },
+ serialize: {
+ recorded_at: (value: Date) => value.toISOString(),
+ big_count: (value: bigint) => value.toString(),
+ amount: (value: number) => value.toString(),
+ },
+}
+
+function makeRowsCollection(syncMode: `eager` | `on-demand`) {
+ const requests: Array = []
+ const supabase = createClient(
+ inject(`supabaseUrl`),
+ inject(`supabaseAnonKey`),
+ {
+ global: {
+ fetch: (input, init) => {
+ requests.push(new URL(input instanceof Request ? input.url : input))
+ return fetch(input, init)
+ },
+ },
+ },
+ )
+ const collection = createCollection(
+ supabaseCollectionOptions({
+ tableName: `query_once_rows`,
+ keys: [`id`],
+ schema: rowSchema,
+ supabase,
+ queryClient: new QueryClient(),
+ syncMode,
+ ...conversions,
+ }),
+ )
+ return { collection, supabase, requests }
+}
+
+type RowsCollection = ReturnType[`collection`]
+
+// The builders are typed for the collection's row refs; the generated query
+// picks columns at run time, so refs are passed as `never`.
+type RowRef = Record
+
+/** Renders a generated filter with TanStack DB's expression builders. */
+function where(row: RowRef, spec: Filter): ReturnType {
+ switch (spec.kind) {
+ case `gt`:
+ return gt(row.score, spec.value)
+ case `eq`:
+ return eq(row.label, spec.value)
+ case `upper`:
+ return eq(upper(row.label), spec.value)
+ case `isNull`:
+ return isNull(row[spec.column])
+ case `in`:
+ return inArray(row.id, spec.ids)
+ case `not`:
+ return not(where(row, spec.inner))
+ case `and`:
+ return and(where(row, spec.left), where(row, spec.right))
+ case `or`:
+ return or(where(row, spec.left), where(row, spec.right))
+ }
+}
+
+const AGGREGATES: Record unknown> = {
+ count,
+ sum,
+ avg,
+ min,
+ max,
+}
+
+/**
+ * The rows a generated query aggregates: the table, or a subquery that only
+ * filters it, then the outer filter.
+ */
+function filteredRows(
+ q: InitialQueryBuilder,
+ collection: RowsCollection,
+ spec: AggregateQuery,
+) {
+ const { sourceWhere, where: outerWhere } = spec
+ const source =
+ sourceWhere === undefined
+ ? collection
+ : q
+ .from({ inner: collection })
+ .where(({ inner }) => where(inner as unknown as RowRef, sourceWhere))
+ const query = q.from({ row: source as RowsCollection })
+ return outerWhere === undefined
+ ? query
+ : query.where(({ row }) => where(row as unknown as RowRef, outerWhere))
+}
+
+const aliasOf = (agg: Aggregate, index: number) =>
+ `${agg.fn}_${agg.column}_${index}`
+
+/**
+ * Builds the generated query. Its select holds only aggregates, so both sides
+ * receive a query the server path accepts.
+ */
+function buildQuery(collection: RowsCollection, spec: AggregateQuery) {
+ return (q: InitialQueryBuilder) => {
+ let selected = filteredRows(q, collection, spec).select(
+ ({ row }) =>
+ Object.fromEntries(
+ spec.aggregates.map((agg, index) => [
+ aliasOf(agg, index),
+ AGGREGATES[agg.fn]((row as unknown as RowRef)[agg.column]),
+ ]),
+ ) as Record>,
+ )
+ if (spec.limit !== undefined) selected = selected.limit(spec.limit)
+ if (spec.offset !== undefined) selected = selected.offset(spec.offset)
+ return spec.findOne ? selected.findOne() : selected
+ }
+}
+
+// ── Refinement check ────────────────────────────────────────────────
+//
+// Checkpoint: the settled promise of each query. Both results must be equal
+// as values: the same number of rows, or `undefined` for `findOne` with no
+// row, and in each row the same value of every selected alias. A `Date`
+// equals only a `Date` at the same instant, a `bigint` only a `bigint`, and
+// `null` only `null`. An error must match too: if the reference rejects,
+// production must reject.
+//
+// The comparison projects each row to the select's aliases. TanStack DB's rows
+// also carry the virtual fields `$key`, `$origin`, `$hasPendingWrites`, and
+// `$collectionId`, which describe the temporary live-query Collection that
+// `queryOnce` creates; `$collectionId` differs on every call. Rows that
+// PostgREST computes have no such Collection. Authority: maintainer decision
+// (Ivan Vasilov, 2026-11-11), recorded in docs/contributing/oracle-coverage.md.
+
+type Outcome = { ok: true; value: unknown } | { ok: false; error: string }
+
+async function settle(run: () => Promise): Promise {
+ try {
+ return { ok: true, value: await run() }
+ } catch (error) {
+ return { ok: false, error: String(error) }
+ }
+}
+
+function projectToAliases(value: unknown, aliases: Array): unknown {
+ if (Array.isArray(value)) {
+ return value.map((row) => projectToAliases(row, aliases))
+ }
+ if (value === undefined) return value
+ const row = value as Record
+ return Object.fromEntries(aliases.map((alias) => [alias, row[alias]]))
+}
+
+function expectSameOutcome(
+ production: Outcome,
+ reference: Outcome,
+ aliases: Array,
+ label: string,
+): void {
+ if (!reference.ok) {
+ expect(production.ok, `${label}: reference rejected`).toBe(false)
+ return
+ }
+ expect(production.ok, `${label}: production rejected`).toBe(true)
+ if (!production.ok) return
+ expect(projectToAliases(production.value, aliases), label).toStrictEqual(
+ projectToAliases(reference.value, aliases),
+ )
+}
+
+declareOracle({ check: [expectSameOutcome], grammar: [history] })
+
+// ── Reach ───────────────────────────────────────────────────────────
+//
+// Each state is credited after the comparison passed, from an observation
+// rather than from the generated intent. The server-route states are the
+// ones where SQL and TanStack DB aggregates differ, so the server path must
+// translate:
+//
+// - `server-route`: production sent an aggregate select to PostgREST;
+// - `server-empty-match`: that query matched no rows, where SQL still
+// returns one aggregate row;
+// - `server-null-value`: an aggregated column held a null in a matched row,
+// which SQL skips and TanStack DB counts as 0;
+// - `server-find-one`: that query used `findOne`;
+// - `server-subquery-source`: that query read a filter-only subquery, whose
+// filter PostgREST applies too.
+//
+// The other states witness queries the server path must leave to TanStack DB:
+//
+// - `client-route`: production sent no aggregate select;
+// - `converted-column`: an aggregate reads a parsed column with values;
+// - `limit-or-offset`: the query set `limit` or `offset`;
+// - `unpushable-filter`: a filter used `upper`, which PostgREST cannot apply.
+
+const REACH_STATES = [
+ `server-route`,
+ `server-empty-match`,
+ `server-null-value`,
+ `server-find-one`,
+ `server-subquery-source`,
+ `client-route`,
+ `converted-column`,
+ `limit-or-offset`,
+ `unpushable-filter`,
+] as const
+type ReachState = (typeof REACH_STATES)[number]
+
+const usesUpper = (spec: Filter | undefined): boolean => {
+ switch (spec?.kind) {
+ case `upper`:
+ return true
+ case `not`:
+ return usesUpper(spec.inner)
+ case `and`:
+ case `or`:
+ return usesUpper(spec.left) || usesUpper(spec.right)
+ default:
+ return false
+ }
+}
+
+const isAggregateSelect = (url: URL) =>
+ /\.(count|sum|avg|min|max)\(\)/.test(url.searchParams.get(`select`) ?? ``)
+
+async function withCleanup(
+ body: () => Promise,
+ ...cleanups: Array<() => Promise>
+): Promise {
+ let primary: { error: unknown } | undefined
+ try {
+ await body()
+ } catch (error) {
+ primary = { error }
+ }
+ const errors: Array = []
+ for (const cleanup of cleanups) {
+ try {
+ await cleanup()
+ } catch (error) {
+ errors.push(error)
+ }
+ }
+ if (primary && errors.length > 0) {
+ throw new AggregateError(
+ [primary.error, ...errors],
+ `Comparison and cleanup failed`,
+ { cause: primary.error },
+ )
+ }
+ if (primary) throw primary.error
+ if (errors.length > 0) {
+ throw new AggregateError(errors, `Cleanup failed`, { cause: errors[0] })
+ }
+}
+
+async function runHistory(
+ { rows, queries }: History,
+ reached: (state: ReachState) => void,
+): Promise {
+ const { error } = await makeAdminClient().rpc(`reset_query_once_rows`, {
+ rows: rows satisfies Array,
+ })
+ if (error) throw new Error(`seeding failed: ${error.message}`)
+
+ const production = makeRowsCollection(`on-demand`)
+ const reference = makeRowsCollection(`eager`)
+ await withCleanup(
+ async () => {
+ for (const [index, spec] of queries.entries()) {
+ const label = `query ${index}: ${JSON.stringify(spec)}`
+ production.requests.length = 0
+ const actual = await settle(() =>
+ experimental_queryOnce(
+ buildQuery(production.collection, spec),
+ production.supabase,
+ ),
+ )
+ const expected = await settle(() =>
+ queryOnce(buildQuery(reference.collection, spec)),
+ )
+ expectSameOutcome(actual, expected, spec.aggregates.map(aliasOf), label)
+
+ const matched: Array> = await queryOnce((q) =>
+ filteredRows(q, reference.collection, spec),
+ )
+ const aggregated = spec.aggregates.map(({ column }) =>
+ matched.map((row) => row[column]),
+ )
+ if (production.requests.some(isAggregateSelect)) {
+ reached(`server-route`)
+ if (matched.length === 0) reached(`server-empty-match`)
+ if (aggregated.some((values) => values.includes(null)))
+ reached(`server-null-value`)
+ if (spec.findOne) reached(`server-find-one`)
+ if (spec.sourceWhere !== undefined) reached(`server-subquery-source`)
+ } else {
+ reached(`client-route`)
+ }
+ if (
+ spec.aggregates.some(
+ ({ column }, position) =>
+ CONVERTED_COLUMNS.includes(column) &&
+ aggregated[position]!.some((value) => value !== null),
+ )
+ )
+ reached(`converted-column`)
+ if (spec.limit !== undefined || spec.offset !== undefined)
+ reached(`limit-or-offset`)
+ if ([spec.where, spec.sourceWhere].some(usesUpper))
+ reached(`unpushable-filter`)
+ }
+ },
+ () => production.collection.cleanup(),
+ () => reference.collection.cleanup(),
+ )
+}
+
+it(`experimental_queryOnce matches TanStack DB's queryOnce for generated aggregate queries`, async () => {
+ await runOracleCampaign(
+ `SUPABASE_QUERY_ONCE_AGGREGATE_ORACLE`,
+ REACH_STATES,
+ async (reach, parameters) => {
+ await fc.assert(
+ fc.asyncProperty(history, async (generated) => {
+ reach.beginHistory()
+ await runHistory(generated, (state) => reach.reach(state))
+ }),
+ { numRuns: oracleRuns(40), ...parameters },
+ )
+ },
+ )
+}, 300_000)
diff --git a/packages/supabase-db-collection/e2e/reads.e2e.test.ts b/packages/supabase-db-collection/e2e/reads.e2e.test.ts
new file mode 100644
index 0000000000..8315255297
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/reads.e2e.test.ts
@@ -0,0 +1,134 @@
+import { createLiveQueryCollection, eq, inArray } from '@tanstack/db'
+import { expect, vi } from 'vitest'
+import { experimental_queryOnce } from '../src/index'
+import { WAIT, test } from './e2e.utils'
+
+test('reads seeded rows through PostgREST', ({ usersLive }) => {
+ const names = usersLive.toArray.map((user) => user.name).sort()
+ expect(names).toEqual(['Alice', 'Bob'])
+})
+
+test('pushes a WHERE filter down to PostgREST', async ({ users }) => {
+ // Seeded fixture: Alice is active, Bob is not — so active=true returns one row.
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: users.collection })
+ .where(({ row }) => eq(row.active, true))
+ .select(({ row }) => ({ id: row.id, name: row.name })),
+ )
+
+ // finally (not fixture teardown) so this live query is cleaned up before the
+ // `users` fixture tears down the base collection it depends on.
+ try {
+ await live.preload()
+ await vi.waitFor(() => expect(live.size).toBe(1), WAIT)
+ expect(live.toArray[0]?.name).toBe('Alice')
+ } finally {
+ await live.cleanup()
+ }
+})
+
+test('queryOnce runs a one-shot filtered query', async ({ users }) => {
+ const rows = await experimental_queryOnce(
+ (q) =>
+ q
+ .from({ user: users.collection })
+ .where(({ user }) => eq(user.active, true)),
+ users.supabase,
+ )
+
+ expect(rows).toHaveLength(1)
+ expect(rows[0]?.name).toBe('Alice')
+})
+
+test('reads a set larger than the server row cap across pages', async ({
+ users,
+ other,
+}) => {
+ // config.toml caps responses at 2 rows; reset_e2e seeds Alice + Bob, so three
+ // more rows push the matching set past the cap and force the paging loop.
+ const { error } = await other.from('users').insert([
+ { name: 'Carol', email: 'carol@test.com', active: true },
+ { name: 'Dave', email: 'dave@test.com', active: true },
+ { name: 'Erin', email: 'erin@test.com', active: true },
+ ] as unknown as never)
+ expect(error).toBeNull()
+
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: users.collection })
+ .select(({ row }) => ({ id: row.id, name: row.name })),
+ )
+
+ try {
+ await live.preload()
+ await vi.waitFor(() => expect(live.size).toBe(5), WAIT)
+ // The full set comes back despite the cap, with each row exactly once.
+ expect(live.toArray.map((row) => row.name).sort()).toEqual([
+ 'Alice',
+ 'Bob',
+ 'Carol',
+ 'Dave',
+ 'Erin',
+ ])
+ expect(new Set(live.toArray.map((row) => row.id)).size).toBe(5)
+ } finally {
+ await live.cleanup()
+ }
+})
+
+// Empirically confirmed against the local stack (Kong): a raw request with
+// 1,500 sequential ids renders to ~9.4 KB and comes back `414 Request-URI Too
+// Large` with an `error.message` mentioning the URI length. 500-1,200 ids
+// (~3-7.3 KB) still succeed, so the failure is specifically the request-line
+// limit this feature works around, not some unrelated cap.
+test('a raw oversized IN filter is rejected by the gateway (baseline)', async ({
+ other,
+}) => {
+ const ids = Array.from({ length: 1500 }, (_, i) => i + 1)
+ const { data, error, status } = await other
+ .from('users')
+ .select()
+ .in('id', ids)
+
+ expect(data).toBeNull()
+ expect(status).toBe(414)
+ expect(error?.message).toMatch(/too long/i)
+})
+
+test('the same oversized IN filter comes back complete through a collection', async ({
+ users,
+ other,
+}) => {
+ // Seed enough matching rows that the split spans more than one chunk: Alice
+ // (id 1) and Bob (id 2) from reset_e2e, plus 50 more (ids 3-52).
+ const extras = Array.from({ length: 50 }, (_, i) => ({
+ name: `Extra ${i}`,
+ email: `extra${i}@test.com`,
+ active: true,
+ }))
+ const { error: insertError } = await other
+ .from('users')
+ .insert(extras as unknown as never)
+ expect(insertError).toBeNull()
+
+ // Same size (and shape) as the raw request above, which the gateway
+ // rejected outright — this is the request the adapter must split.
+ const ids = Array.from({ length: 1500 }, (_, i) => i + 1)
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: users.collection })
+ .where(({ row }) => inArray(row.id, ids))
+ .select(({ row }) => ({ id: row.id, name: row.name })),
+ )
+
+ try {
+ await live.preload()
+ await vi.waitFor(() => expect(live.size).toBe(52), WAIT)
+ // Every matching row exactly once, despite coming back over several
+ // concatenated chunk requests.
+ expect(new Set(live.toArray.map((row) => row.id)).size).toBe(52)
+ } finally {
+ await live.cleanup()
+ }
+})
diff --git a/packages/supabase-db-collection/e2e/realtime-filters.e2e.test.ts b/packages/supabase-db-collection/e2e/realtime-filters.e2e.test.ts
new file mode 100644
index 0000000000..f9521af911
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/realtime-filters.e2e.test.ts
@@ -0,0 +1,513 @@
+import {
+ and,
+ createLiveQueryCollection,
+ eq,
+ gt,
+ gte,
+ inArray,
+ isNull,
+ lt,
+ lte,
+ not,
+ or,
+} from '@tanstack/db'
+import { expect, vi } from 'vitest'
+import {
+ WAIT,
+ makeFilteredRealtimeUsers,
+ startFilteredRealtime,
+ test,
+} from './e2e.utils'
+
+// These tests drive the realtimeUseFilter path end to end: a live query's WHERE
+// becomes a server-side postgres_changes filter via realtimeFiltersToSearch.
+// Unit tests only prove we format the filter string correctly against a mock
+// channel; these prove the REAL Supabase Realtime server both accepts that
+// syntax and evaluates it — when the server rejects a filter the adapter
+// silently falls back to catch-all, so a broken operator would pass unit tests
+// yet stop filtering in production. Each test's waitForChannel (inside
+// startFilteredRealtime) only resolves once the server accepted the filter.
+
+// Builds a filtered realtime users row payload. Ids stay above the Alice/Bob
+// seed (1, 2) so inserts never collide with the reset_e2e() identity sequence.
+const userRow = (
+ id: number,
+ over: Partial<{ name: string; email: string; active: boolean }> = {},
+) => ({ id, name: `u${id}`, email: `u${id}@test.com`, active: true, ...over })
+
+// ── Value quoting (reserved characters + whitespace) ──────────────────
+// Names deliberately exercise quoteValue's branches: a comma (reserved) and
+// surrounding whitespace (only quoted since realtimeFiltersToSearch adopted the
+// shared quoteValue). Both must round-trip through the real server.
+const QUOTED_NAMES = ['Doe, Jane', ' spaced '] as const
+
+for (const value of QUOTED_NAMES) {
+ test(`realtime filter on ${JSON.stringify(value)} delivers matching inserts`, async ({
+ other,
+ }) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.name, value))
+ .select(({ row }) => ({ id: row.id, name: row.name })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([
+ { ...userRow(300), name: value },
+ userRow(301, { name: 'Someone Else' }),
+ ] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.map((row) => row.name)).toEqual([value]),
+ WAIT,
+ )
+ expect(ctx.collection.toArray.some((r) => r.id === 301)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+ })
+}
+
+// ── Operator matrix: does the real server accept each emitted operator? ─
+// Every case inserts one matching and one non-matching row and asserts only the
+// match is delivered through the filtered subscription. Membership checks (not
+// exact arrays) keep the assertions robust against seed rows a filter may also
+// select (e.g. lt over ids includes Alice/Bob).
+const COMPARISONS = [
+ { op: 'gt', pivot: 100, match: 101, miss: 99 },
+ { op: 'gte', pivot: 100, match: 100, miss: 99 },
+ { op: 'lt', pivot: 500, match: 400, miss: 600 },
+ { op: 'lte', pivot: 400, match: 400, miss: 600 },
+] as const
+
+for (const c of COMPARISONS) {
+ test(`realtime filter ${c.op} is accepted and evaluated by the server`, async ({
+ other,
+ }) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => {
+ switch (c.op) {
+ case 'gt':
+ return gt(row.id, c.pivot)
+ case 'gte':
+ return gte(row.id, c.pivot)
+ case 'lt':
+ return lt(row.id, c.pivot)
+ default:
+ return lte(row.id, c.pivot)
+ }
+ })
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(c.match), userRow(c.miss)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === c.match)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === c.miss)).toBe(false)
+ // Under a correct server-side filter the excluded row is never delivered,
+ // so it must not reach the BASE collection either. A silent fallback to a
+ // catch-all subscription would sync it here, which the live query's own
+ // client-side re-filtering would otherwise hide.
+ expect(ctx.collection.toArray.some((row) => row.id === c.miss)).toBe(
+ false,
+ )
+ } finally {
+ await cleanup()
+ }
+ })
+}
+
+test('realtime filter neq (boolean) delivers only non-matching-value rows', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => not(eq(row.active, true)))
+ .select(({ row }) => ({ id: row.id, active: row.active })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([
+ userRow(300, { active: false }),
+ userRow(301, { active: true }),
+ ] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 300)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === 301)).toBe(false)
+ // A silent fallback to catch-all would sync the excluded row into the base.
+ expect(ctx.collection.toArray.some((row) => row.id === 301)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+test('realtime filter not.gt (negated comparison) is accepted server-side', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => not(gt(row.id, 100)))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(99), userRow(101)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 99)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === 101)).toBe(false)
+ // A silent fallback to catch-all would sync the excluded row into the base.
+ expect(ctx.collection.toArray.some((row) => row.id === 101)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+test('realtime filter in.(...) is accepted and matches list members', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => inArray(row.id, [301, 302]))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(301), userRow(303)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 301)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === 303)).toBe(false)
+ // A silent fallback to catch-all would sync the excluded row into the base.
+ expect(ctx.collection.toArray.some((row) => row.id === 303)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+test('realtime filter not.in excludes list members', async ({ other }) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => not(inArray(row.id, [301])))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(302), userRow(301)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 302)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === 301)).toBe(false)
+ // A silent fallback to catch-all would sync the excluded row into the base.
+ expect(ctx.collection.toArray.some((row) => row.id === 301)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+// The seed schema has no nullable column (name is NOT NULL), so a matching row
+// for `is.null` cannot exist. We therefore only smoke-test `not.is.null`: it
+// proves the server accepts the is/not.is syntax and still delivers. The
+// positive `is.null` match stays unit-tested.
+test('realtime filter not.is.null is accepted and delivers rows', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => not(isNull(row.name)))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(300)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 300)).toBe(true),
+ WAIT,
+ )
+ } finally {
+ await cleanup()
+ }
+})
+
+test('realtime filter accepts a 100-member in.(...) list', async ({
+ other,
+}) => {
+ const ids = Array.from({ length: 100 }, (_, i) => 300 + i)
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => inArray(row.id, ids))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(350), userRow(999)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 350)).toBe(true),
+ WAIT,
+ )
+ expect(live.toArray.some((row) => row.id === 999)).toBe(false)
+ // A silent fallback to catch-all would sync the excluded row into the base.
+ expect(ctx.collection.toArray.some((row) => row.id === 999)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+// A composite AND becomes a single comma-joined condition list
+// (`id=gt.300,id=lt.500`). Unit tests prove we format it; this proves the real
+// server accepts a multi-condition filter and evaluates every condition rather
+// than silently rejecting it (which would fall back to catch-all).
+test('realtime filter with two AND-ed conditions is accepted and evaluated', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => and(gt(row.id, 300), lt(row.id, 500)))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(400), userRow(600)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () => expect(live.toArray.some((row) => row.id === 400)).toBe(true),
+ WAIT,
+ )
+ // 600 satisfies gt.300 but not lt.500: it must be excluded, proving the
+ // server honours both conditions and did not fall back to catch-all.
+ expect(live.toArray.some((row) => row.id === 600)).toBe(false)
+ expect(ctx.collection.toArray.some((row) => row.id === 600)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+// ── Filter-window UPDATE / DELETE correctness ─────────────────────────
+// A row that leaves the filter window disappears from the live query (which
+// re-filters client-side) but must stay fresh in the BASE collection, kept
+// current by the extra unfiltered UPDATE listener. So these assert on the base
+// collection, not the live query.
+test('UPDATE moving a row out of the filter window keeps it fresh', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.active, true))
+ .select(({ row }) => ({ id: row.id, active: row.active })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ const aliceInBase = () => ctx.collection.toArray.find((r) => r.id === 1)
+ try {
+ // Seed: only Alice (id 1) is active, so she is synced into the base.
+ await vi.waitFor(() => expect(aliceInBase()).toBeDefined(), WAIT)
+
+ // Move Alice out of the active=true window.
+ const { error } = await other
+ .from('users')
+ .update({ active: false } as unknown as never)
+ .eq('id', 1)
+ expect(error).toBeNull()
+
+ // The unfiltered UPDATE listener still applied the change instead of
+ // leaving a stale active=true row behind. Applying it is a manual write,
+ // which makes query-db-collection revalidate its on-demand queries; the
+ // active=true query then no longer owns Alice, so she may also be dropped
+ // from the base entirely. Either outcome is fresh; only active=true is stale.
+ await vi.waitFor(
+ () => expect(aliceInBase()?.active ?? false).toBe(false),
+ WAIT,
+ )
+ } finally {
+ await cleanup()
+ }
+})
+
+test('UPDATE moving a row into the filter window delivers it', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.active, true))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ // Seed: Bob (id 2) is inactive, so he starts outside the window.
+ await vi.waitFor(
+ () => expect(live.toArray.some((r) => r.id === 2)).toBe(false),
+ WAIT,
+ )
+
+ const { error } = await other
+ .from('users')
+ .update({ active: true } as unknown as never)
+ .eq('id', 2)
+ expect(error).toBeNull()
+
+ // The filtered UPDATE listener upserts the row now that it matches.
+ await vi.waitFor(
+ () => expect(live.toArray.some((r) => r.id === 2)).toBe(true),
+ WAIT,
+ )
+ } finally {
+ await cleanup()
+ }
+})
+
+test('DELETE is delivered even while a server-side filter is active', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.active, true))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ const aliceInBase = () => ctx.collection.toArray.some((r) => r.id === 1)
+ try {
+ await vi.waitFor(() => expect(aliceInBase()).toBe(true), WAIT)
+
+ const { error } = await other.from('users').delete().eq('id', 1)
+ expect(error).toBeNull()
+
+ // Deletes are never filtered; the unfiltered DELETE listener removes it.
+ await vi.waitFor(() => expect(aliceInBase()).toBe(false), WAIT)
+ } finally {
+ await cleanup()
+ }
+})
+
+// ── Multiple active queries & catch-all fallback ──────────────────────
+test('two filtered live queries over one collection stay isolated', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ const liveX = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.id, 301))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const liveY = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => eq(row.id, 302))
+ .select(({ row }) => ({ id: row.id })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, liveX, liveY)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([userRow(301), userRow(302), userRow(303)] as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(() => {
+ expect(liveX.toArray.map((r) => r.id)).toEqual([301])
+ expect(liveY.toArray.map((r) => r.id)).toEqual([302])
+ }, WAIT)
+ // 303 matches neither filtered subscription, so it never syncs.
+ expect(ctx.collection.toArray.some((r) => r.id === 303)).toBe(false)
+ } finally {
+ await cleanup()
+ }
+})
+
+test('an unsupported WHERE falls back to catch-all but still syncs', async ({
+ other,
+}) => {
+ const ctx = makeFilteredRealtimeUsers()
+ // OR is not pushable to a Realtime filter, so realtimeFiltersToSearch yields a
+ // catch-all (unfiltered) subscription; the live query re-filters client-side.
+ const live = createLiveQueryCollection((q) =>
+ q
+ .from({ row: ctx.collection })
+ .where(({ row }) => or(eq(row.name, 'Xavier'), eq(row.name, 'Yolanda')))
+ .select(({ row }) => ({ id: row.id, name: row.name })),
+ )
+ const cleanup = await startFilteredRealtime(ctx, live)
+ try {
+ const { error } = await other
+ .from('users')
+ .insert([
+ userRow(300, { name: 'Xavier' }),
+ userRow(301, { name: 'Zoe' }),
+ ] as unknown as never)
+ expect(error).toBeNull()
+
+ // The matching row arrives through the degraded (unfiltered) subscription
+ // and passes the client-side OR filter.
+ await vi.waitFor(
+ () => expect(live.toArray.map((r) => r.name)).toEqual(['Xavier']),
+ WAIT,
+ )
+ } finally {
+ await cleanup()
+ }
+})
diff --git a/packages/supabase-db-collection/e2e/realtime.e2e.test.ts b/packages/supabase-db-collection/e2e/realtime.e2e.test.ts
new file mode 100644
index 0000000000..3bc86c6a14
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/realtime.e2e.test.ts
@@ -0,0 +1,64 @@
+import { expect, vi } from 'vitest'
+import { WAIT, test } from './e2e.utils'
+
+// The rtUsersLive fixture guarantees the realtime channel is joined before the
+// test body runs, so writes from the `other` client always arrive as events.
+
+test('receives realtime inserts made by another client', async ({
+ rtUsersLive,
+ other,
+}) => {
+ const draft = { id: 200, name: 'Zoe', email: 'zoe@test.com', active: true }
+ const { error } = await other.from('users').insert(draft as unknown as never)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () =>
+ expect(rtUsersLive.toArray.some((user) => user.name === 'Zoe')).toBe(
+ true,
+ ),
+ WAIT,
+ )
+})
+
+test('receives realtime updates made by another client', async ({
+ rtUsersLive,
+ other,
+}) => {
+ const alice = rtUsersLive.toArray.find((user) => user.name === 'Alice')
+
+ const { error } = await other
+ .from('users')
+ .update({ name: 'Alice Realtime' })
+ .eq('id', alice?.id)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () =>
+ expect(
+ rtUsersLive.toArray.find((user) => user.id === alice?.id)?.name,
+ ).toBe('Alice Realtime'),
+ WAIT,
+ )
+})
+
+// Exercises the delete path, where the adapter derives the collection key from
+// the realtime payload's `old`. That works here because the collection key is
+// the primary key, which the default replica identity always includes.
+test('receives realtime deletes made by another client', async ({
+ rtUsersLive,
+ other,
+}) => {
+ const bob = rtUsersLive.toArray.find((user) => user.name === 'Bob')
+
+ const { error } = await other.from('users').delete().eq('id', bob?.id)
+ expect(error).toBeNull()
+
+ await vi.waitFor(
+ () =>
+ expect(rtUsersLive.toArray.some((user) => user.id === bob?.id)).toBe(
+ false,
+ ),
+ WAIT,
+ )
+})
diff --git a/packages/supabase-db-collection/e2e/setup.ts b/packages/supabase-db-collection/e2e/setup.ts
new file mode 100644
index 0000000000..26b92a7aad
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/setup.ts
@@ -0,0 +1,27 @@
+import { createClient } from '@supabase/supabase-js'
+import { inject } from 'vitest'
+
+/**
+ * A service_role (admin) client, which bypasses RLS. Only the e2e setup and
+ * seeding code uses it; the collections under test use the anon key.
+ */
+export const makeAdminClient = () =>
+ createClient(inject('supabaseUrl'), inject('supabaseServiceRoleKey'), {
+ auth: { persistSession: false },
+ })
+
+/**
+ * Truncates the adapter's own test tables (restart identity) and re-seeds the
+ * fixture rows in a single transactional round trip, so every test starts from
+ * the same state: Alice (id 1, active) and Bob (id 2, inactive).
+ *
+ * This is opt-in per test file (via the `test` export of ./e2e.utils) rather
+ * than a global `beforeEach`, so it never runs for suites that own different
+ * tables, such as the shared @tanstack/db-collection-e2e suites.
+ */
+export const resetE2e = async (admin: ReturnType) => {
+ const { error } = await admin.rpc('reset_e2e')
+ if (error) {
+ throw new Error(`e2e reset failed: ${error.message}`)
+ }
+}
diff --git a/packages/supabase-db-collection/e2e/supabase.e2e.test.ts b/packages/supabase-db-collection/e2e/supabase.e2e.test.ts
new file mode 100644
index 0000000000..d70d9cdc98
--- /dev/null
+++ b/packages/supabase-db-collection/e2e/supabase.e2e.test.ts
@@ -0,0 +1,366 @@
+/**
+ * Supabase Collection E2E Tests
+ *
+ * End-to-end tests against a local Supabase stack (PostgREST + Realtime), using
+ * the shared test suites from @tanstack/db-collection-e2e. The stack is booted
+ * by ./global-setup.ts and its schema lives in supabase/migrations.
+ */
+
+import { afterEach, beforeEach, describe, expect, inject } from 'vitest'
+import { createClient } from '@supabase/supabase-js'
+import { createCollection } from '@tanstack/db'
+import { QueryClient } from '@tanstack/query-core'
+import { z } from 'zod'
+import {
+ captureSeedData,
+ createCollationTestSuite,
+ createDeduplicationTestSuite,
+ createJoinsTestSuite,
+ createLiveUpdatesTestSuite,
+ createMutationsTestSuite,
+ createPaginationTestSuite,
+ createPredicatesTestSuite,
+ generateSeedData,
+} from '@tanstack/db-collection-e2e'
+import { supabaseCollectionOptions } from '../src/index'
+import { makeAdminClient } from './setup'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { Collection } from '@tanstack/db'
+import type {
+ Comment,
+ E2ETestConfig,
+ Post,
+ SeedDataResult,
+ User,
+} from '@tanstack/db-collection-e2e'
+
+/**
+ * Rows as PostgREST and Realtime deliver them: JSON, so timestamps are strings
+ * and bigints are numbers. The collections convert them into the `User`,
+ * `Post` and `Comment` types with `parse`, and back with `serialize`.
+ */
+const userSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string().nullable(),
+ age: z.number(),
+ isActive: z.boolean(),
+ createdAt: z.date(),
+ metadata: z.record(z.string(), z.unknown()).nullable(),
+ deletedAt: z.date().nullable(),
+})
+
+const postSchema = z.object({
+ id: z.string(),
+ userId: z.string(),
+ title: z.string(),
+ content: z.string().nullable(),
+ viewCount: z.number(),
+ largeViewCount: z.bigint(),
+ publishedAt: z.date().nullable(),
+ deletedAt: z.date().nullable(),
+})
+
+const commentSchema = z.object({
+ id: z.string(),
+ postId: z.string(),
+ userId: z.string(),
+ text: z.string(),
+ createdAt: z.date(),
+ deletedAt: z.date().nullable(),
+})
+
+const parseDate = (value: unknown) => new Date(value as string)
+const dateToJson = (value: Date) => value.toISOString()
+
+const dateColumns = {
+ parse: parseDate,
+ serialize: dateToJson,
+}
+
+const syncModes = [
+ { syncMode: 'eager', suffix: 'eager' },
+ { syncMode: 'on-demand', suffix: 'ondemand' },
+] as const
+
+type SyncMode = (typeof syncModes)[number]['syncMode']
+
+function createCollectionsForSyncMode(
+ supabase: SupabaseClient,
+ syncMode: SyncMode,
+ own: (collection: { cleanup: () => Promise }) => void,
+) {
+ // A QueryClient per collection: the realtime channel registry is keyed by
+ // QueryClient, so sharing one would make the collections share a channel.
+ const users = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'e2e_users',
+ keys: ['id'],
+ schema: userSchema,
+ supabase,
+ queryClient: new QueryClient(),
+ realtime: true,
+ syncMode,
+ parse: {
+ createdAt: dateColumns.parse,
+ deletedAt: dateColumns.parse,
+ },
+ serialize: {
+ createdAt: dateColumns.serialize,
+ deletedAt: dateColumns.serialize,
+ },
+ }),
+ )
+ own(users)
+
+ const posts = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'e2e_posts',
+ keys: ['id'],
+ schema: postSchema,
+ supabase,
+ queryClient: new QueryClient(),
+ realtime: true,
+ syncMode,
+ parse: {
+ // The column is text, so PostgREST sends every digit of the value.
+ largeViewCount: (value) => BigInt(value as string),
+ publishedAt: dateColumns.parse,
+ deletedAt: dateColumns.parse,
+ },
+ serialize: {
+ // The column is text (see the migration), so the exact digits are sent.
+ largeViewCount: (value) => value.toString(),
+ publishedAt: dateColumns.serialize,
+ deletedAt: dateColumns.serialize,
+ },
+ }),
+ )
+ own(posts)
+
+ const comments = createCollection(
+ supabaseCollectionOptions({
+ tableName: 'e2e_comments',
+ keys: ['id'],
+ schema: commentSchema,
+ supabase,
+ queryClient: new QueryClient(),
+ realtime: true,
+ syncMode,
+ parse: {
+ createdAt: dateColumns.parse,
+ deletedAt: dateColumns.parse,
+ },
+ serialize: {
+ createdAt: dateColumns.serialize,
+ deletedAt: dateColumns.serialize,
+ },
+ }),
+ )
+ own(comments)
+
+ return {
+ users: users as unknown as Collection,
+ posts: posts as unknown as Collection,
+ comments: comments as unknown as Collection,
+ }
+}
+
+/** Wire format of the seed rows, as sent to PostgREST. */
+function toRows(seedData: SeedDataResult) {
+ const toIso = (date: Date | null) => (date ? date.toISOString() : null)
+ return {
+ users: seedData.users.map((user) => ({
+ ...user,
+ createdAt: toIso(user.createdAt),
+ deletedAt: toIso(user.deletedAt),
+ })),
+ posts: seedData.posts.map((post) => ({
+ ...post,
+ largeViewCount: post.largeViewCount.toString(),
+ publishedAt: toIso(post.publishedAt),
+ deletedAt: toIso(post.deletedAt),
+ })),
+ comments: seedData.comments.map((comment) => ({
+ ...comment,
+ createdAt: toIso(comment.createdAt),
+ deletedAt: toIso(comment.deletedAt),
+ })),
+ }
+}
+
+async function seedDatabase(admin: SupabaseClient, seedData: SeedDataResult) {
+ // Wipe all pre-existing data, e.g. when reusing a stack from a previous run.
+ const reset = await admin.rpc('reset_e2e_shared')
+ if (reset.error) {
+ throw new Error(`e2e reset failed: ${reset.error.message}`)
+ }
+
+ const rows = toRows(seedData)
+ const results = [
+ await admin.from('e2e_users').insert(rows.users),
+ await admin.from('e2e_posts').insert(rows.posts),
+ await admin.from('e2e_comments').insert(rows.comments),
+ ]
+ const errors = results.flatMap(({ error }) => (error ? [error] : []))
+ if (errors.length > 0) {
+ throw new AggregateError(errors, 'Supabase fixture insertions failed')
+ }
+}
+
+/**
+ * Shared-suite cases that cannot pass against the local Supabase stack, by test
+ * name, with the reason. The suites cannot be edited to accommodate Supabase,
+ * so each case is skipped here and reported as skipped.
+ */
+const unsupportedCases = new Map([
+ [
+ 'should maintain query state during data changes',
+ // Verified: the same steps pass when the live query under test is held by a
+ // subscriber, so the collection itself keeps up with the changes.
+ 'Realtime delivers a change about 500ms after it is committed. This case ' +
+ 'inserts eleven rows one by one, waiting for each to be delivered, which ' +
+ 'takes longer than the five seconds a live query without subscribers ' +
+ 'survives, so the live query under test is garbage collected mid-test.',
+ ],
+])
+
+describe('Supabase Collection E2E Tests', async () => {
+ beforeEach(({ task, skip }) => {
+ const reason = unsupportedCases.get(task.name)
+ if (reason) skip(reason)
+ })
+
+ // The collections under test read and write with the anon key, like a
+ // browser would. Writes that stand in for "another client" use their own
+ // client so the collections only learn about them through Realtime.
+ const makeAnonClient = () =>
+ createClient(inject('supabaseUrl'), inject('supabaseAnonKey'))
+ const other = makeAnonClient()
+
+ const cleanups: Array<() => Promise> = []
+ const own = (collection: { cleanup: () => Promise }) => {
+ cleanups.push(() => collection.cleanup())
+ }
+ afterEach(async () => {
+ const errors: Array = []
+ for (const cleanup of cleanups.splice(0).reverse()) {
+ try {
+ await cleanup()
+ } catch (error) {
+ errors.push(error)
+ }
+ }
+ if (errors.length === 1) throw errors[0]
+ if (errors.length > 1)
+ throw new AggregateError(errors, 'Supabase collection cleanup failed')
+ })
+
+ const seedData = generateSeedData()
+ const fixture = captureSeedData(seedData, {
+ registration: 'packages/supabase-db-collection/e2e/supabase.e2e.test.ts',
+ provider: 'Supabase local stack (PostgREST + Realtime)',
+ })
+ await seedDatabase(makeAdminClient(), seedData)
+
+ async function getConfig(): Promise {
+ const supabase = makeAnonClient()
+ cleanups.push(async () => {
+ await supabase.removeAllChannels()
+ })
+
+ const [eager, onDemand] = syncModes.map(({ syncMode }) =>
+ createCollectionsForSyncMode(supabase, syncMode, own),
+ ) as [
+ ReturnType,
+ ReturnType,
+ ]
+
+ // Hold a subscriber on every collection for the length of the test, like a
+ // mounted component would. Without one, a collection that has lost all its
+ // subscribers is idle: its query observers are removed and the adapter
+ // closes the Realtime channel (there is nothing left to deliver changes to).
+ // Live queries the suites start are garbage collected five seconds after
+ // their last subscriber, which would otherwise silence a long-running test.
+ const subscribable: Array<
+ Pick, `subscribeChanges`>
+ > = [...Object.values(eager), ...Object.values(onDemand)]
+ for (const collection of subscribable) {
+ const subscription = collection.subscribeChanges(() => {})
+ cleanups.push(() => Promise.resolve(subscription.unsubscribe()))
+ }
+
+ // On-demand collections are marked ready immediately.
+ await Promise.all([
+ onDemand.users.preload(),
+ onDemand.posts.preload(),
+ onDemand.comments.preload(),
+ ])
+
+ // Eager collections fetch the whole table before they are ready.
+ await Promise.all([
+ eager.users.preload(),
+ eager.posts.preload(),
+ eager.comments.preload(),
+ ])
+ expect(eager.users.size).toEqual(seedData.users.length)
+ expect(eager.posts.size).toEqual(seedData.posts.length)
+ expect(eager.comments.size).toEqual(seedData.comments.length)
+
+ return {
+ fixture,
+ collections: { eager, onDemand },
+ // Changes made through `mutations` reach the collections via Realtime.
+ hasReplicationLag: true,
+ // `progressive` is Electric-only, so it is not configured.
+ mutations: {
+ insertUser: async (user) => {
+ const { error } = await other
+ .from('e2e_users')
+ .insert(toRows({ ...seedData, users: [user] }).users)
+ if (error) throw error
+ },
+ updateUser: async (id, updates) => {
+ // Only the columns being changed: a partial update must not touch the rest.
+ const changes = Object.fromEntries(
+ Object.entries(updates).map(([column, value]) => [
+ column,
+ value instanceof Date ? value.toISOString() : value,
+ ]),
+ )
+ const { error } = await other
+ .from('e2e_users')
+ .update(changes)
+ .eq('id', id)
+ if (error) throw error
+ },
+ deleteUser: async (id) => {
+ const { error } = await other.from('e2e_users').delete().eq('id', id)
+ if (error) throw error
+ },
+ insertPost: async (post) => {
+ const { error } = await other
+ .from('e2e_posts')
+ .insert(toRows({ ...seedData, posts: [post] }).posts)
+ if (error) throw error
+ },
+ deletePost: async (id) => {
+ const { error } = await other.from('e2e_posts').delete().eq('id', id)
+ if (error) throw error
+ },
+ },
+ setup: async () => {},
+ teardown: async () => {},
+ }
+ }
+
+ // Run all shared test suites. Progressive and Moves are Electric-specific
+ // (they rely on Electric's snapshot/swap transition and move-out semantics).
+ createPredicatesTestSuite(getConfig)
+ createPaginationTestSuite(getConfig)
+ createJoinsTestSuite(getConfig)
+ createDeduplicationTestSuite(getConfig)
+ createCollationTestSuite(getConfig)
+ createMutationsTestSuite(getConfig)
+ createLiveUpdatesTestSuite(getConfig)
+})
diff --git a/packages/supabase-db-collection/package.json b/packages/supabase-db-collection/package.json
new file mode 100644
index 0000000000..598cc40365
--- /dev/null
+++ b/packages/supabase-db-collection/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@tanstack/supabase-db-collection",
+ "version": "1.0.0-rc.0",
+ "description": "Supabase collection for TanStack DB",
+ "author": "Supabase ",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/db.git",
+ "directory": "packages/supabase-db-collection"
+ },
+ "homepage": "https://tanstack.com/db",
+ "keywords": [
+ "supabase",
+ "postgrest",
+ "realtime",
+ "optimistic",
+ "typescript"
+ ],
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite build --watch",
+ "lint": "eslint . --fix",
+ "test": "vitest run",
+ "test:e2e": "vitest run --config vitest.e2e.config.ts"
+ },
+ "type": "module",
+ "main": "dist/cjs/index.cjs",
+ "module": "dist/esm/index.js",
+ "types": "dist/esm/index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/esm/index.d.ts",
+ "default": "./dist/esm/index.js"
+ },
+ "require": {
+ "types": "./dist/cjs/index.d.cts",
+ "default": "./dist/cjs/index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src"
+ ],
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@supabase/postgrest-js": "^2.107.0",
+ "@tanstack/db": "workspace:*",
+ "@tanstack/query-db-collection": "workspace:*"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.74.0",
+ "@tanstack/query-core": "^5.90.15",
+ "typescript": ">=4.7"
+ },
+ "devDependencies": {
+ "@supabase/supabase-js": "^2.107.0",
+ "@tanstack/db-collection-e2e": "workspace:*",
+ "@tanstack/query-core": "^5.90.20",
+ "@vitest/coverage-istanbul": "^5.0.5",
+ "zod": "^4.6.7"
+ }
+}
diff --git a/packages/supabase-db-collection/src/db.ts b/packages/supabase-db-collection/src/db.ts
new file mode 100644
index 0000000000..b1b75f207b
--- /dev/null
+++ b/packages/supabase-db-collection/src/db.ts
@@ -0,0 +1,248 @@
+import { BasicIndex, withCollectionConfigFactory } from '@tanstack/db'
+import { queryCollectionOptions } from '@tanstack/query-db-collection'
+import {
+ subsetOptionsToQueryKey,
+ supabaseOnDelete,
+ supabaseOnInsert,
+ supabaseOnUpdate,
+ supabaseQueryFn,
+} from './functions'
+import { getQueryClient } from './query-client'
+import { syncTableSubscription } from './realtime'
+import type { TableEntry } from './realtime'
+import type { ColumnConversions, RowConversions } from './row-conversions'
+import type { CollectionImpl, StringCollationConfig } from '@tanstack/db'
+import type { QueryClient } from '@tanstack/query-core'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import type { StandardSchemaV1 } from '@standard-schema/spec'
+
+interface SupabaseCollectionOptions<
+ TSchema extends StandardSchemaV1,
+> extends RowConversions> {
+ /**
+ * Controls how string columns are compared when ordering, matching the
+ * database's collation settings. If omitted, TanStack DB's default is used.
+ */
+ defaultStringCollation?: StringCollationConfig
+ /**
+ * The columns that uniquely identify a row. Used to extract the key for
+ * storing the item in the collection and to build the where clause for
+ * update and delete operations.
+ */
+ keys: Array & string>
+ /** The query client */
+ queryClient?: QueryClient
+ /** Whether to receive updates when a record has been inserted, updated, or deleted by another user */
+ realtime?: boolean
+ /**
+ * Whether to push each query's WHERE clause to the Realtime subscription as a
+ * server-side `postgres_changes` filter. Only applies when `realtime` is on.
+ * Defaults to `false`, which subscribes to every change on the table and
+ * filters client-side — simpler, at the cost of more Realtime traffic. Set to
+ * `true` to narrow the subscription server-side.
+ */
+ realtimeUseFilter?: boolean
+ /** The schema of the collection */
+ schema: TSchema
+ /** The supabase browser client */
+ supabase: SupabaseClient
+ /**
+ * When the table's rows are fetched.
+ *
+ * - `'on-demand'` (default): rows are loaded per live query, pushing each
+ * query's where/order/limit to PostgREST.
+ * - `'eager'`: the whole table is fetched (paging past PostgREST's
+ * `db-max-rows`) as soon as the collection starts syncing, regardless of
+ * the queries run against it. Realtime then subscribes to every change on
+ * the table.
+ */
+ syncMode?: 'on-demand' | 'eager'
+ /** The name of the table in the database */
+ tableName: string
+}
+
+// Per-QueryClient registry of table entries, with a single cache subscription
+// per client. Weakly keyed so a discarded QueryClient (and the Supabase clients
+// and collections its entries reference) can be garbage collected.
+const queryClientRegistries = new WeakMap<
+ QueryClient,
+ Map
+>()
+
+const ensureQueryCacheSubscription = (
+ queryClient: QueryClient,
+): Map => {
+ const existing = queryClientRegistries.get(queryClient)
+ if (existing) {
+ return existing
+ }
+
+ const tables = new Map()
+ queryClientRegistries.set(queryClient, tables)
+
+ queryClient.getQueryCache().subscribe((args) => {
+ if (args.type !== 'observerAdded' && args.type !== 'observerRemoved') {
+ return
+ }
+ for (const [tableName, entry] of tables) {
+ syncTableSubscription(queryClient, tableName, entry)
+ }
+ })
+
+ return tables
+}
+
+const registerTable = (
+ queryClient: QueryClient,
+ tableName: string,
+ supabase: SupabaseClient,
+ realtimeUseFilter: boolean,
+): TableEntry => {
+ const tables = ensureQueryCacheSubscription(queryClient)
+
+ let entry = tables.get(tableName)
+ if (!entry) {
+ entry = {
+ supabase,
+ collectionRef: null,
+ conversions: {},
+ realtimeChannel: null,
+ realtimeFiltersKey: null,
+ realtimePendingChannel: null,
+ realtimePendingFiltersKey: null,
+ rejectedFilterKeys: new Set(),
+ realtimeUseFilter,
+ }
+ tables.set(tableName, entry)
+ }
+
+ return entry
+}
+
+/**
+ * Options key marking a collection created by this package, so queryOnce can
+ * tell a collection backed by a Supabase table from any other collection. Its
+ * value is the collection's `parse` conversions.
+ */
+const supabaseCollection = Symbol(`supabaseCollection`)
+
+/**
+ * The columns a collection created by this package reads through `parse`, or
+ * `null` for any other collection, which has no Supabase table behind it.
+ */
+export function parsedColumns(collection: CollectionImpl): Set | null {
+ const parse = (
+ collection.config as {
+ [supabaseCollection]?: ColumnConversions[`parse`]
+ }
+ )[supabaseCollection]
+ return parse ? new Set(Object.keys(parse)) : null
+}
+
+const createSupabaseConfig = (
+ options: SupabaseCollectionOptions,
+) => {
+ const {
+ tableName,
+ keys,
+ schema,
+ supabase,
+ realtime,
+ realtimeUseFilter = false,
+ syncMode = 'on-demand',
+ defaultStringCollation,
+ parse,
+ serialize,
+ } = options
+ // if the query client is not provided, use the global query client
+ const queryClient = options.queryClient ?? getQueryClient()
+
+ type TItem = StandardSchemaV1.InferOutput
+
+ // Derive the collection key from the configured key columns. A single column
+ // keeps its string form (`6` becomes `'6'`). Composite keys encode each
+ // value's string form as a JSON array, so distinct tuples cannot share a key
+ // the way `'-'` joining let `['a-b', 'c']` and `['a', 'b-c']` do.
+ const getKey = (item: TItem): string | number => {
+ const values = keys.map((key) => `${item[key] ?? ``}`)
+ return values.length === 1 ? values[0]! : JSON.stringify(values)
+ }
+
+ const conversions = { parse, serialize } as ColumnConversions
+
+ // Key columns used to match rows on update and delete.
+ const keyColumns = keys as Array
+
+ // A server-side Supabase client has no `channel`, so Realtime is a no-op there
+ // and the table is never registered. Checking here keeps attachSupabaseListeners
+ // able to always return a live subscription.
+ let entry: TableEntry | null = null
+ if (realtime && typeof supabase.channel === 'function') {
+ entry = registerTable(queryClient, tableName, supabase, realtimeUseFilter)
+ }
+ const config = queryCollectionOptions({
+ id: tableName,
+ queryClient,
+ getKey,
+ schema,
+ queryKey: (ctx) => subsetOptionsToQueryKey(tableName, ctx),
+ syncMode,
+ // Known limitation: the initial fetch does not wait for the Realtime channel
+ // to finish subscribing. A row written in the window between this fetch and
+ // the subscription going live can be missed by both — the fetch ran before
+ // the row existed, and the subscription started after the change was
+ // published. Gating the fetch on the subscription closes this gap but couples
+ // every first load to Realtime connect latency, so it is intentionally left
+ // out and tracked separately.
+ queryFn: (ctx) =>
+ supabaseQueryFn(supabase, tableName, ctx, {
+ keys: keyColumns,
+ conversions,
+ }),
+ onInsert: (ctx) => supabaseOnInsert(supabase, tableName, ctx, conversions),
+ onUpdate: (ctx) =>
+ supabaseOnUpdate(supabase, tableName, keyColumns, ctx, conversions),
+ onDelete: (ctx) =>
+ supabaseOnDelete(supabase, tableName, keyColumns, ctx, conversions),
+ autoIndex: 'eager',
+ defaultIndexType: BasicIndex,
+ defaultStringCollation,
+ })
+
+ const originalSync = config.sync.sync
+
+ return {
+ ...config,
+ [supabaseCollection]: conversions.parse ?? {},
+ sync: {
+ // Keeps the hooks query-db-collection attaches to its sync config, such
+ // as the factory that lets a direct write start an idle collection.
+ ...config.sync,
+ sync: (
+ ...args: Parameters
+ ): ReturnType => {
+ if (entry) {
+ entry.collectionRef = args[0].collection
+ entry.conversions = conversions
+ }
+ return originalSync(...args)
+ },
+ },
+ }
+}
+
+export const supabaseCollectionOptions = (
+ options: SupabaseCollectionOptions,
+): ReturnType> =>
+ // Each DbClient materializes its own config, reading through the client's
+ // `queryClient` dependency the way query-db-collection does. The config above
+ // spreads the query collection's options, which drops that collection's own
+ // factory; it would also skip the Realtime wrapper, so this one rebuilds the
+ // whole Supabase config.
+ withCollectionConfigFactory(createSupabaseConfig(options), (client) =>
+ createSupabaseConfig({
+ ...options,
+ queryClient:
+ client.getDependency(`queryClient`) ?? options.queryClient,
+ }),
+ )
diff --git a/packages/supabase-db-collection/src/functions.ts b/packages/supabase-db-collection/src/functions.ts
new file mode 100644
index 0000000000..7dadd9c2c1
--- /dev/null
+++ b/packages/supabase-db-collection/src/functions.ts
@@ -0,0 +1,485 @@
+import { DeleteOperationItemNotFoundError } from '@tanstack/query-db-collection'
+import {
+ cursorCurrentToSearch,
+ cursorWhereFromToSearch,
+ keyColumnsToSearch,
+ loadSubsetOptionsToSearch,
+ splitLoadSubsetOptions,
+ subsetParamsToSearch,
+} from './postgrest-filters'
+import { postgrestRequest } from './postgrest-request'
+import { isSynced } from './realtime'
+import { parseRow, serializeRow } from './row-conversions'
+import type { ColumnConversions } from './row-conversions'
+import type {
+ DeleteMutationFnParams,
+ InsertMutationFnParams,
+ LoadSubsetOptions,
+ UpdateMutationFnParams,
+} from '@tanstack/db'
+import type { QueryClient, QueryMeta } from '@tanstack/query-core'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+export const subsetOptionsToQueryKey = (
+ tableName: string,
+ ctx: LoadSubsetOptions,
+): Array => {
+ // The key shares the request URL's where/order/limit encoding
+ // (`subsetParamsToSearch`) so the two cannot drift; the constant `select` is
+ // excluded because it never distinguishes one subset from another.
+ //
+ // Pagination params (cursor/offset) ARE included: query-db-collection keys row
+ // ownership by this key, so distinct windows of the same subset (same
+ // where/order/limit, different cursor/offset) must not collide — otherwise a
+ // later page's result would take over the earlier page's rows and delete them.
+ // The key still starts with `[tableName]` so `syncTableSubscription` keeps
+ // matching every window of the table by prefix.
+ const search = subsetParamsToSearch(ctx)
+ if (ctx.cursor) {
+ try {
+ for (const [k, v] of cursorWhereFromToSearch(ctx.cursor)) {
+ search.append(k, v)
+ }
+ } catch {
+ // The queryKey function must never throw — query-db-collection calls it
+ // synchronously to key row ownership. If the cursor cannot be rendered
+ // (an un-pushable predicate), fall back to a deterministic discriminator
+ // so distinct windows still get distinct keys and cannot take over and
+ // delete each other's rows.
+ search.append('cursor', JSON.stringify(ctx.cursor.whereFrom))
+ }
+ }
+ if (ctx.offset && !ctx.cursor) {
+ search.append('offset', `${ctx.offset}`)
+ }
+ const key = search.toString()
+ return key ? [tableName, key] : [tableName]
+}
+
+type PageOptions = {
+ /** The caller's row limit; `undefined` means "the full matching set". */
+ limit?: number
+ /** The starting offset (always 0 for cursor reads). */
+ offset: number
+ signal?: AbortSignal
+ /**
+ * Columns that uniquely identify a row. Pages are ordered by them after the
+ * caller's order so that the rows tied on that order keep one position
+ * across the page requests; see {@link withKeyOrder}.
+ */
+ keys: Array
+}
+
+/**
+ * Extends the `order` param with the key columns it does not already sort by.
+ *
+ * Offset paging is only sound over a total order. When the caller's order has
+ * ties (or there is none), Postgres may order the tied rows differently for
+ * each page request, so a row can show up on two pages while another shows up
+ * on none. Ordering by the unique key last makes every page agree.
+ */
+function withKeyOrder(
+ search: URLSearchParams,
+ keys: Array,
+): URLSearchParams {
+ const sorts = search.get('order')?.split(',') ?? []
+ // Each sort is `column.direction[.nulls]`; the column is the first segment.
+ const sortedColumns = new Set(sorts.map((sort) => sort.split('.')[0]))
+ const missing = keys.filter((key) => !sortedColumns.has(key))
+ if (missing.length === 0) {
+ return search
+ }
+ const ordered = new URLSearchParams(search)
+ ordered.set(
+ 'order',
+ [...sorts, ...missing.map((key) => `${key}.asc`)].join(','),
+ )
+ return ordered
+}
+
+/**
+ * Read a full matching set (or the caller's `limit` rows) with offset paging,
+ * looping past PostgREST's `db-max-rows` cap instead of silently truncating at
+ * it. The first request asks for `count=exact` so the loop knows the total up
+ * front and stops without a trailing empty probe; each later page advances the
+ * offset by the rows actually received (self-correcting under concurrent
+ * writes) and is capped by the observed page size and the rows still owed.
+ *
+ * A result that fits in one page is returned as read. When more pages are
+ * needed, the first page is read again under a total order (see
+ * {@link withKeyOrder}) so that all pages are cut from the same sequence.
+ */
+async function fetchAllPages(
+ supabase: SupabaseClient,
+ tableName: string,
+ baseSearch: URLSearchParams,
+ { limit, offset: startOffset, signal, keys }: PageOptions,
+): Promise> {
+ // A zero limit is an empty window: return it without touching the network.
+ if (limit === 0) {
+ return []
+ }
+
+ // The base search already carries the caller's limit/offset (from the search
+ // builders), so the first request is sent verbatim — its URL stays identical
+ // to the single-request behaviour, and the query key that mirrors it.
+ const first = await postgrestRequest>(supabase, tableName, {
+ method: 'GET',
+ search: baseSearch,
+ signal,
+ count: 'exact',
+ })
+ let rows: Array = first.data ? [...first.data] : []
+
+ // The rows the first page returned size the server's cap; a later page that
+ // comes back shorter than this means the server has no more rows.
+ const pageSize = rows.length
+ // `Content-Range` reports the whole matching set, so the rows reachable from
+ // the starting offset are what remains past it. When the count header is
+ // missing we fall back to paging until a short page appears.
+ const total = first.count ?? Number.POSITIVE_INFINITY
+ const remaining = Math.max(0, total - startOffset)
+ const target = limit === undefined ? remaining : Math.min(limit, remaining)
+
+ // The first page was read under the caller's order, whose ties the server may
+ // break differently on the next request. If more pages follow, discard it and
+ // page through a total order instead.
+ let pagedSearch = baseSearch
+ if (pageSize > 0 && rows.length < target) {
+ pagedSearch = withKeyOrder(baseSearch, keys)
+ if (pagedSearch !== baseSearch) {
+ rows = []
+ }
+ }
+
+ let lastPageSize = pageSize
+ while (lastPageSize === pageSize && pageSize > 0 && rows.length < target) {
+ const pageSearch = new URLSearchParams(pagedSearch)
+ pageSearch.set('offset', `${startOffset + rows.length}`)
+ pageSearch.set('limit', `${Math.min(pageSize, target - rows.length)}`)
+ const page = await postgrestRequest>(supabase, tableName, {
+ method: 'GET',
+ search: pageSearch,
+ signal,
+ })
+ const pageRows = page.data ?? []
+ rows.push(...pageRows)
+ lastPageSize = pageRows.length
+ }
+
+ return rows
+}
+
+/**
+ * Read one already-in-budget subset's full window: the keyset tie request (if
+ * any) plus the main paged request. This is what runs once per chunk when
+ * `splitLoadSubsetOptions` had to split an oversized subset into several.
+ */
+async function loadWindow(
+ supabase: SupabaseClient,
+ tableName: string,
+ keys: Array,
+ options: LoadSubsetOptions,
+ signal: AbortSignal,
+): Promise> {
+ const search = loadSubsetOptionsToSearch(options)
+ // Cursor reads pin their own window start, so they never carry an offset (see
+ // loadSubsetOptionsToSearch); only a cursor-less read advances from `offset`.
+ const startOffset = options.offset && !options.cursor ? options.offset : 0
+
+ // A keyset request whose boundary column has ties (e.g. `orderBy(created_at)`
+ // with repeated values) needs a second, unlimited request for the rows equal
+ // to the boundary; the limited `whereFrom` page alone would skip them.
+ //
+ // Do not drop this on the grounds that core's ordered-source loader also
+ // probes the boundary (`requestSnapshot({ where: eq(col, boundary) })`) and
+ // usually pre-loads the same tie class: that probe is a window-path
+ // optimization the adapter cannot assume ran. It is skipped when the boundary
+ // value is unchanged, falls back to a full-source load for non-keyset orders,
+ // and never runs for `loadSubset({ cursor })` calls made outside that path.
+ // Honouring `whereCurrent` here is what makes the cursor correct on its own.
+ const tiesSearch = cursorCurrentToSearch(options)
+ if (!tiesSearch) {
+ return await fetchAllPages(supabase, tableName, search, {
+ limit: options.limit,
+ offset: startOffset,
+ signal,
+ keys,
+ })
+ }
+
+ const [ties, rows] = await Promise.all([
+ // The tie read is deliberately unbounded by the caller's limit — every row
+ // tied at the boundary must come back — but it is still paged so a tie class
+ // larger than the cap is not truncated.
+ fetchAllPages(supabase, tableName, tiesSearch, {
+ offset: 0,
+ signal,
+ keys,
+ }),
+ fetchAllPages(supabase, tableName, search, {
+ limit: options.limit,
+ offset: startOffset,
+ signal,
+ keys,
+ }),
+ ])
+ // `whereCurrent` (== boundary) and `whereFrom` (> / < boundary) are disjoint,
+ // so concatenation never duplicates; the collection re-sorts locally.
+ return [...ties, ...rows]
+}
+
+// PostgREST has no body-carried filters for reads and the Supabase API gateway
+// rejects request lines over about 8 KB with 414, so a big `inArray(...)`
+// predicate (the common shape a lazy join's on-demand collection produces)
+// can make a subset's URL too long to send. This matches postgrest-js's own
+// `urlLengthLimit` default so the two agree on what counts as "too long".
+export const MAX_URL_LENGTH = 8000
+
+// At most this many chunk requests run at once; splitting a large IN list can
+// produce far more chunks than are worth having in flight simultaneously.
+const MAX_CONCURRENT_CHUNKS = 6
+
+// Pages after the first (`fetchAllPages`) set `offset` and `limit` on top of
+// the subset's own search; no real value is wider than this one.
+const WIDEST_PAGE_PARAM = `${Number.MAX_SAFE_INTEGER}`
+
+/**
+ * The length of the longest URL `fetchAllPages` could send for `search`: the
+ * base URL, the `?`, and the query string with the key-column tiebreak order
+ * that multi-page reads add (see {@link withKeyOrder}) and `offset`/`limit` at
+ * their widest, so every page — not only the first — stays within budget.
+ */
+function pagedUrlLength(
+ baseLength: number,
+ search: URLSearchParams,
+ keys: Array,
+): number {
+ const paged = new URLSearchParams(withKeyOrder(search, keys))
+ paged.set('offset', WIDEST_PAGE_PARAM)
+ paged.set('limit', WIDEST_PAGE_PARAM)
+ return baseLength + '?'.length + paged.toString().length
+}
+
+/**
+ * The length of the longest URL a subset would produce: the main request's
+ * pages, and — when the subset has a cursor — the unlimited `whereCurrent` tie
+ * request's pages too, since `loadWindow` sends both for a cursor read.
+ * Measuring rendered URLs (rather than estimating) accounts for quoting and
+ * percent-encoding exactly.
+ */
+function measureSubset(
+ supabase: SupabaseClient,
+ tableName: string,
+ keys: Array,
+ options: LoadSubsetOptions,
+): number {
+ const base = supabase.from(tableName).url.toString().length
+ const mainLength = pagedUrlLength(
+ base,
+ loadSubsetOptionsToSearch(options),
+ keys,
+ )
+ const tiesSearch = cursorCurrentToSearch(options)
+ return tiesSearch
+ ? Math.max(mainLength, pagedUrlLength(base, tiesSearch, keys))
+ : mainLength
+}
+
+/**
+ * Runs one `loadWindow` per chunk with at most `MAX_CONCURRENT_CHUNKS` in
+ * flight at a time, and concatenates their rows. Chunks are disjoint on the
+ * split column (see `splitLoadSubsetOptions`), so concatenation order does not
+ * matter and needs no dedupe.
+ *
+ * On the first failure, every other chunk's request is aborted through a
+ * local `AbortController` that also follows the caller's own signal — this
+ * keeps the same all-or-nothing semantics an unsplit request has ("one failed
+ * page fails the whole load") instead of surfacing a partial result, and the
+ * first error encountered is what `Promise.all` rejects with.
+ */
+async function loadChunks(
+ supabase: SupabaseClient,
+ tableName: string,
+ keys: Array,
+ chunks: Array,
+ signal: AbortSignal,
+): Promise> {
+ // Not `AbortSignal.any`: React Native (Hermes) and Safari before 17.4 lack it.
+ const controller = new AbortController()
+ const abortFromCaller = () => controller.abort(signal.reason)
+ if (signal.aborted) {
+ abortFromCaller()
+ } else {
+ signal.addEventListener('abort', abortFromCaller, { once: true })
+ }
+ const results: Array> = new Array(chunks.length)
+ let nextIndex = 0
+
+ const runWorker = async (): Promise => {
+ while (nextIndex < chunks.length) {
+ const index = nextIndex++
+ try {
+ results[index] = await loadWindow(
+ supabase,
+ tableName,
+ keys,
+ chunks[index]!,
+ controller.signal,
+ )
+ } catch (error) {
+ controller.abort()
+ throw error
+ }
+ }
+ }
+
+ const workerCount = Math.min(MAX_CONCURRENT_CHUNKS, chunks.length)
+ try {
+ await Promise.all(Array.from({ length: workerCount }, runWorker))
+ } finally {
+ signal.removeEventListener('abort', abortFromCaller)
+ }
+ return results.flat()
+}
+
+/**
+ * Reads the rows a query needs, converted to the rows the collection holds
+ * (see {@link RowConversions}).
+ */
+export const supabaseQueryFn = async (
+ supabase: SupabaseClient,
+ tableName: string,
+ ctx: {
+ client: QueryClient
+ queryKey: ReadonlyArray
+ signal: AbortSignal
+ meta: QueryMeta | undefined
+ pageParam?: unknown
+ direction?: unknown
+ },
+ {
+ keys = [],
+ conversions = {},
+ }: { keys?: Array; conversions?: ColumnConversions } = {},
+): Promise> => {
+ const options = ctx.meta?.loadSubsetOptions ?? {}
+ // The query key stays the full, unchunked subset (subsetOptionsToQueryKey
+ // never sees these chunks), so this fan-out is invisible to
+ // query-db-collection: one query still owns every row it returns.
+ const chunks = splitLoadSubsetOptions(options, {
+ maxUrlLength: MAX_URL_LENGTH,
+ measure: (chunkOptions) =>
+ measureSubset(supabase, tableName, keys, chunkOptions),
+ })
+
+ const rows =
+ chunks.length === 1
+ ? await loadWindow