Skip to content

feat(supabase-db-collection): add experimental_queryOnce - #2412

Draft
ivasilov wants to merge 11 commits into
TanStack:mainfrom
ivasilov:ivasilov/supabase-queryonce
Draft

ivasilov wants to merge 11 commits into
TanStack:mainfrom
ivasilov:ivasilov/supabase-queryonce

Conversation

@ivasilov

@ivasilov ivasilov commented Nov 11, 2026 •

Copy link
Copy Markdown

Stacked on #2400 (which is stacked on #2376). This branch is based on ivasilov/supabase-e2e, so the diff also shows those commits until they merge. The new commits are 173400d9b, 052812fbf, 1e0934cdb, and f220600a4.

🎯 Changes

Adds experimental_queryOnce(callback, supabase) to @tanstack/supabase-db-collection (IFCS-154). This is PR 3 of 4.

  1. feat(supabase-db-collection): add Supabase collection #2376: the collection and its unit tests.
  2. test(supabase-db-collection): add e2e tests against a local Supabase stack #2400: e2e tests against a local Supabase stack, plus CI.
  3. This PR (feat(supabase-db-collection): add experimental_queryOnce #2412): experimental_queryOnce.
  4. docs(supabase-db-collection): add docs and publish the package #2413: docs and changeset, and removal of private: true.

What it adds

  • The port from supabase/tanstack-db at 245600c:

    • query-once.ts;
    • serializeQueryIR and the query IR types in serialize.ts;
    • postgrest-filters/query-ir-to-search.ts;
    • their unit tests (query-once.test.ts, serialize.test.ts, and the queryIrToSearch block).

    It is exported only as experimental_queryOnce, because it reads TanStack DB's internal query IR. Changes to fit this repo:

    • descriptor sources;
    • ConditionalSelect in a select;
    • explicit null placement in order;
    • the X-Client-Info name.
  • The queryOnce e2e cases. Reads gets back its one-shot query. Each filters case now reads through three paths: a live query, experimental_queryOnce, and executeQuery. Each path reads through a fresh collection.

  • A server-aggregate oracle against the local stack, plus a migration that enables PostgREST aggregates (pgrst.db_aggregates_enabled) and adds the query_once_rows table.

  • A fix the oracle found. The ported server path returned PostgREST's aggregate row as-is, so a query's answer depended on its route. The table shows each case.

Case PostgREST (ported) TanStack DB queryOnce
Empty match [{ count: 0 }] [] (findOne: undefined)
All-null sum/avg null 0
avg of [1, null] 1 0.5
max(label) over a A b B 'B' (collation) 'b'
Column read with parse raw text ('9' > '10') parsed value
limit/offset without orderBy [] throws
Filter PostgREST cannot apply throws filters on the client

Per the decision on IFCS-154, experimental_queryOnce must match TanStack DB exactly. The server path now takes only count, sum, and avg of plain columns. Every filter must be pushable, and the query can't page, order, or use distinct. It requests a hidden count() and asks for each average as a sum, then applies TanStack DB's rules. Every other query, including min/max, runs through TanStack DB's queryOnce.

Please look at these

  • Virtual fields are not compared. TanStack DB rows carry $key, $origin, $hasPendingWrites, and $collectionId, and server rows do not. The oracle compares only the select's aliases. This is a maintainer decision, recorded in the coverage map.
  • A core defect, recorded and not fixed here. TanStack DB turns a null into 0 before min/max, and 'a' < 0 and 0 < 'a' are both false. So min(label) over 'a', null, null depends on row order. The oracle generates min/max only over columns that hold no text. The defect is in the coverage map's reusable-law backlog.
  • Some TanStack DB rules may be bugs. All-null sum gives 0, and avg includes nulls in its row count. The server path reproduces both. If core changes them, the oracle will flag the server path.
  • The Linear text says groupBy and having run in Postgres. Upstream #28 (in 245600c) already moved them to the client, and this PR keeps them there.

🔬 Oracle Coverage

Owner: the new coverage-map row "Supabase experimental_queryOnce server aggregates" (e2e/query-once-aggregate-oracle.e2e.test.ts).

  • Law: experimental_queryOnce resolves to TanStack DB's queryOnce result over an eager Collection of the same table (IFCS-154). Results are compared at the settled promise. Rows are projected to the select's aliases, and a rejection must match a rejection.

  • Reach, fixed seed 20261022, 40 histories:

    State Histories
    server-route 22
    server-empty-match 12
    server-null-value 8
    server-find-one 10
    server-subquery-source 12
    client-route 33
    converted-column 13
    limit-or-offset 19
    unpushable-filter 6

    Four random campaigns reached every state.

  • RED on the port (173400d9b), with SUPABASE_CLI="pnpm dlx supabase@2.116.0" pnpm exec vitest run --config vitest.e2e.config.ts e2e/query-once-aggregate-oracle.e2e.test.ts. Counterexample: rows [] and sum(id) gave expected [ { sum_id_0: null } ] to strictly equal [].

  • GREEN: pnpm test:e2e passed 161 tests with 1 skipped (the existing skip from test(supabase-db-collection): add e2e tests against a local Supabase stack #2400).

  • Mutants: each of these was rejected by an assertion failure:

    • the SQL row on empty input;
    • a null sum;
    • SQL avg;
    • min/max pushed to the server;
    • no pushability check;
    • sum over a parsed column;
    • paging pushed to the server;
    • subquery filters dropped.
  • Limits:

    • one local stack (CLI 2.116.0) and one table;
    • min/max only over columns that hold no text;
    • small integers and decimals that doubles represent exactly;
    • no grouped, joined, or computed queries, which take TanStack DB's path on both sides.

✅ Checklist

  • I have tested this code locally with pnpm test.

Unit tests: 705 passed and 18 todo, with no type errors. pnpm test:e2e passed 161 tests with 1 skipped. pnpm lint:changed is clean.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release). The package stays private: true until PR 4, which adds the changeset.

Size: the package's minified gzip bundle grows from 5,482 to 8,142 bytes. The port adds 2,325 of those, mostly the faithfully ported serializer and join-embedding code, which experimental_queryOnce cannot reach. The fix adds 335. pnpm size:compare reports no change for published packages.

🤖 Generated with Claude Code

ivasilov and others added 11 commits November 9, 2026 12:01
Port @supabase-labs/tanstack-db (supabase/tanstack-db@245600c) into the
monorepo as @tanstack/supabase-db-collection. queryOnce, e2e tests and
docs follow in separate PRs; the package stays private until the docs PR.

On top of the upstream source:
- syncMode: 'eager' option (default 'on-demand')
- per-column parse/serialize converters
- multi-page reads also order by the key columns, so ties in the
  query's order no longer repeat or skip rows; the oversized-IN URL
  budget accounts for that extra order
- QueryClient registry is a WeakMap
- X-Client-Info version injected from package.json at build time

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Joining composite key values with '-' gave distinct tuples the same
collection key (['', '-'] and ['-', ''] both became '--'), so one row
overwrote the other. Composite keys are now a JSON array of the values'
string forms. Single-column keys are unchanged.

A property test generates pairs of tuples cut from the same text and
checks that keys are equal exactly when the tuples are; it fails on the
'-' join.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The sync wrapper returned a new `sync: { sync }` object, dropping the
factory query-db-collection attaches to its sync config. Without it a
direct write such as `utils.writeUpsert` on an idle collection threw
SyncNotInitializedError instead of starting sync. Spread the original
sync config and replace only `sync`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Spreading the query collection's options dropped its DbClient config
factory, so `collectionOptions(supabaseCollectionOptions(...))` read
through the default QueryClient instead of the client's `queryClient`
dependency, and a second DbClient could not reuse the descriptor.

Register a factory that rebuilds the whole Supabase config, including
the Realtime wrapper, with the client's `queryClient`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l.any

React Native (Hermes) and Safari before 17.4 have no AbortSignal.any,
so every read split for an oversized IN list threw a TypeError there.
The chunk controller now follows the caller's signal through a listener,
keeping the caller's abort reason, and removes the listener when the
load settles, including on failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The order param dropped each sort's `nulls`. TanStack DB puts nulls
first unless told otherwise, while Postgres puts them last for ascending
order, so a limited ascending window over a nullable column loaded the
wrong rows (e.g. [3, 1] instead of the null rows [2, 4]). Every sort the
query asks for now carries `.nullsfirst` or `.nullslast`; the key-column
tiebreak is unchanged.

A new test compares limited windows over a nullable column, for each
direction and null placement, with the same live query over a local-only
collection, served by a mock that orders like Postgres.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…stack

Port the package's own e2e tests from supabase/tanstack-db (reads,
filters, mutations, realtime and realtime filters) and run the shared
@tanstack/db-collection-e2e suites against PostgREST and Realtime.

- The local Supabase project lives in supabase/, on ports 553xx so it
  runs alongside Electric. max_rows = 2 makes reads page.
- The filters cases read through live queries; the queryOnce cases come
  with experimental_queryOnce.
- The shared case "should maintain query state during data changes" is
  skipped: local Realtime adds about 500 ms per change, so the live query
  under test is cleaned up mid-test.
- CI runs the suite in a new `supabase` group of the E2E workflow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Port queryOnce, the query IR serializer, and the IR-to-PostgREST
translation from supabase/tanstack-db at 245600c, with their unit tests.
The function is exported only as experimental_queryOnce because it reads
TanStack DB's internal query representation.

Adapted to this repo's IR: descriptor sources, conditional selects, and
explicit null placement in the order param.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…executeQuery

Restore the upstream queryOnce cases in the reads and filters e2e tests.
Each filter case now checks the live query, experimental_queryOnce, and
executeQuery paths, each through a fresh collection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
experimental_queryOnce must resolve to what TanStack DB's queryOnce
resolves to for the same query over the same table state. The oracle
generates table states and aggregate-only queries, runs both against a
local Supabase stack with PostgREST aggregates enabled, and compares the
selected fields.

RED on the ported implementation:

  SUPABASE_CLI="pnpm dlx supabase@2.116.0" pnpm exec vitest run \
    --config vitest.e2e.config.ts e2e/query-once-aggregate-oracle.e2e.test.ts

  Counterexample: rows [], sum(id)
  expected [ { sum_id_0: null } ] to strictly equal []

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…anStack DB

experimental_queryOnce returned PostgREST's aggregate row as-is, so its
result depended on the route: SQL returns a row for an empty match, a
null sum, and an average over non-null values, while TanStack DB returns
no row, sums a null as 0, and averages over every row. min and max
compared text by collation and ignored parse conversions, and paging or
an unpushable filter changed the outcome.

The server path now takes only count, sum, and avg of plain columns,
with every filter pushable and no paging, ordering, or distinct. It
requests a hidden row count and each average as a sum, and maps the
result to TanStack DB's rules. Every other query runs through TanStack
DB's queryOnce.

- Law: experimental_queryOnce resolves to TanStack DB's queryOnce result
  over the same table state (IFCS-154). Rows are compared on the select's
  aliases (maintainer decision, 2026-11-11).
- Reach (fixed seed 20261022, 40 histories): server-route 22,
  server-empty-match 12, server-null-value 8, server-find-one 10,
  server-subquery-source 12, client-route 33, converted-column 13,
  limit-or-offset 19, unpushable-filter 6. Four random campaigns reached
  every state.
- RED (port, 173400d): rows [], sum(id): expected
  [ { sum_id_0: null } ] to strictly equal [].
- GREEN: SUPABASE_CLI="pnpm dlx supabase@2.116.0" pnpm test:e2e passes
  161 tests (1 skipped).
- Mutants, each rejected by an assertion: SQL row on empty input, null
  sum, SQL avg, min/max pushed, no pushability check, sum over a parsed
  column, paging pushed, subquery filters dropped.
- Limits: min/max are generated only over non-text columns, because
  TanStack DB's own result over text with nulls depends on row order
  (recorded in the reusable-law backlog); one local stack and table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Nov 11, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f220600

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor
Pre-merge checks: Inconclusive 2

Failed Failed checks: 2 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Description check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant