From 7489393719fb84ab2bfa478a8e707054a0b29af0 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Mon, 9 Nov 2026 12:01:22 +0100 Subject: [PATCH 01/10] feat(supabase-db-collection): add Supabase collection 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) --- packages/supabase-db-collection/package.json | 66 ++ packages/supabase-db-collection/src/db.ts | 203 ++++ .../supabase-db-collection/src/functions.ts | 476 ++++++++ packages/supabase-db-collection/src/index.ts | 1 + .../src/postgrest-filters/common.ts | 230 ++++ .../src/postgrest-filters/index.ts | 12 + .../key-columns-to-search.ts | 16 + .../load-subset-options-to-search.ts | 79 ++ .../realtime-filters-to-search.ts | 101 ++ .../split-load-subset-options.ts | 258 ++++ .../subset-params-to-search.ts | 37 + .../src/postgrest-request.ts | 101 ++ .../src/query-client.ts | 30 + .../supabase-db-collection/src/realtime.ts | 449 +++++++ .../src/request-headers.ts | 13 + .../src/row-conversions.ts | 70 ++ .../supabase-db-collection/src/serialize.ts | 8 + .../supabase-db-collection/src/version.ts | 4 + .../tests/index.test.ts | 1034 +++++++++++++++++ .../tests/pagination.test.ts | 490 ++++++++ .../tests/pagination.utils.ts | 123 ++ .../tests/postgrest-filters.test.ts | 513 ++++++++ .../tests/postgrest-request.test.ts | 122 ++ .../tests/realtime.test.ts | 787 +++++++++++++ .../tests/request-headers.test.ts | 139 +++ .../tests/row-conversions.test.ts | 294 +++++ .../tests/split-load-fanout.test.ts | 377 ++++++ .../tests/split-load-subset-options.test.ts | 284 +++++ .../tests/sync-mode.test.ts | 183 +++ .../tests/test.utils.ts | 296 +++++ .../supabase-db-collection/tsconfig.docs.json | 9 + packages/supabase-db-collection/tsconfig.json | 24 + .../supabase-db-collection/vite.config.ts | 33 + pnpm-lock.yaml | 135 ++- 34 files changed, 6980 insertions(+), 17 deletions(-) create mode 100644 packages/supabase-db-collection/package.json create mode 100644 packages/supabase-db-collection/src/db.ts create mode 100644 packages/supabase-db-collection/src/functions.ts create mode 100644 packages/supabase-db-collection/src/index.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/common.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/index.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/key-columns-to-search.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/load-subset-options-to-search.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/realtime-filters-to-search.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/split-load-subset-options.ts create mode 100644 packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts create mode 100644 packages/supabase-db-collection/src/postgrest-request.ts create mode 100644 packages/supabase-db-collection/src/query-client.ts create mode 100644 packages/supabase-db-collection/src/realtime.ts create mode 100644 packages/supabase-db-collection/src/request-headers.ts create mode 100644 packages/supabase-db-collection/src/row-conversions.ts create mode 100644 packages/supabase-db-collection/src/serialize.ts create mode 100644 packages/supabase-db-collection/src/version.ts create mode 100644 packages/supabase-db-collection/tests/index.test.ts create mode 100644 packages/supabase-db-collection/tests/pagination.test.ts create mode 100644 packages/supabase-db-collection/tests/pagination.utils.ts create mode 100644 packages/supabase-db-collection/tests/postgrest-filters.test.ts create mode 100644 packages/supabase-db-collection/tests/postgrest-request.test.ts create mode 100644 packages/supabase-db-collection/tests/realtime.test.ts create mode 100644 packages/supabase-db-collection/tests/request-headers.test.ts create mode 100644 packages/supabase-db-collection/tests/row-conversions.test.ts create mode 100644 packages/supabase-db-collection/tests/split-load-fanout.test.ts create mode 100644 packages/supabase-db-collection/tests/split-load-subset-options.test.ts create mode 100644 packages/supabase-db-collection/tests/sync-mode.test.ts create mode 100644 packages/supabase-db-collection/tests/test.utils.ts create mode 100644 packages/supabase-db-collection/tsconfig.docs.json create mode 100644 packages/supabase-db-collection/tsconfig.json create mode 100644 packages/supabase-db-collection/vite.config.ts diff --git a/packages/supabase-db-collection/package.json b/packages/supabase-db-collection/package.json new file mode 100644 index 0000000000..df3302ed21 --- /dev/null +++ b/packages/supabase-db-collection/package.json @@ -0,0 +1,66 @@ +{ + "name": "@tanstack/supabase-db-collection", + "private": true, + "version": "0.0.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" + }, + "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/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..4537b16abe --- /dev/null +++ b/packages/supabase-db-collection/src/db.ts @@ -0,0 +1,203 @@ +import { BasicIndex } 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 { 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 +} + +export const supabaseCollectionOptions = ({ + tableName, + keys, + schema, + queryClient, + supabase, + realtime, + realtimeUseFilter = false, + syncMode = 'on-demand', + defaultStringCollation, + parse, + serialize, +}: SupabaseCollectionOptions) => { + // if the query client is not provided, use the global query client + queryClient = queryClient ?? getQueryClient() + + type TItem = StandardSchemaV1.InferOutput + + // Derive the collection key from the configured key columns. The values are + // always joined into a string, so a single numeric `id` of 6 becomes `'6'`. + const getKey = (item: TItem): string | number => { + return keys.map((key) => item[key]).join('-') + } + + 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, + sync: { + sync: ( + ...args: Parameters + ): ReturnType => { + if (entry) { + entry.collectionRef = args[0].collection + entry.conversions = conversions + } + return originalSync(...args) + }, + }, + } +} diff --git a/packages/supabase-db-collection/src/functions.ts b/packages/supabase-db-collection/src/functions.ts new file mode 100644 index 0000000000..b3437bc9e7 --- /dev/null +++ b/packages/supabase-db-collection/src/functions.ts @@ -0,0 +1,476 @@ +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` combined with the caller's own signal + * (`AbortSignal.any`) — 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> { + const controller = new AbortController() + const combinedSignal = AbortSignal.any([signal, controller.signal]) + 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]!, + combinedSignal, + ) + } catch (error) { + controller.abort() + throw error + } + } + } + + const workerCount = Math.min(MAX_CONCURRENT_CHUNKS, chunks.length) + await Promise.all(Array.from({ length: workerCount }, runWorker)) + 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( + supabase, + tableName, + keys, + chunks[0]!, + ctx.signal, + ) + : await loadChunks(supabase, tableName, keys, chunks, ctx.signal) + return rows.map((row) => parseRow(row, conversions)) +} + +export const supabaseOnInsert = async ( + supabase: SupabaseClient, + tableName: string, + { transaction, collection }: InsertMutationFnParams, + conversions: ColumnConversions = {}, +) => { + await Promise.all( + transaction.mutations.map(async (mutation) => { + const { data } = await postgrestRequest(supabase, tableName, { + method: 'POST', + search: new URLSearchParams({ select: '*' }), + body: serializeRow(mutation.modified, conversions), + returnRows: true, + single: true, + }) + + // A single-row insert with `Prefer: return=representation` always returns + // the created row. + const inserted = parseRow(data as object, conversions) + mutation.modified = inserted + // The data has been inserted and confirmed by the server, so we can write + // it to the collection. Realtime may already have echoed the insert, in + // which case this is an update of the synced row rather than an insert. + await collection.utils.writeUpsert(inserted) + }), + ) +} + +export const supabaseOnUpdate = async ( + supabase: SupabaseClient, + tableName: string, + keys: Array, + { transaction, collection }: UpdateMutationFnParams, + conversions: ColumnConversions = {}, +) => { + await Promise.all( + transaction.mutations.map(async (mutation) => { + const { original, changes } = mutation + const search = keyColumnsToSearch( + keys, + serializeRow(original, conversions), + ) + search.set('select', '*') + const { data } = await postgrestRequest(supabase, tableName, { + method: 'PATCH', + search, + body: serializeRow({ ...original, ...changes }, conversions), + returnRows: true, + single: true, + }) + + // A single-row update with `Prefer: return=representation` always returns + // the updated row. + const updated = parseRow(data as object, conversions) + mutation.modified = updated + await collection.utils.writeUpdate(updated) + }), + ) +} + +export const supabaseOnDelete = async ( + supabase: SupabaseClient, + tableName: string, + keys: Array, + { transaction, collection }: DeleteMutationFnParams, + conversions: ColumnConversions = {}, +) => { + await Promise.all( + transaction.mutations.map(async (mutation) => { + await postgrestRequest(supabase, tableName, { + method: 'DELETE', + search: keyColumnsToSearch( + keys, + serializeRow(mutation.original, conversions), + ), + }) + + // The data has been deleted and confirmed by the server, so we can write + // it to the collection — unless Realtime already echoed the delete, in + // which case the synced row is gone and writing again would throw. + const key = collection.getKeyFromItem(mutation.original) + if (!isSynced(collection, key)) { + return + } + try { + await collection.utils.writeDelete(key) + } catch (error) { + // Direct writes are applied asynchronously, so a Realtime echo can + // remove the row between the check above and this write being applied. + // The row is gone either way, so the delete is already confirmed. + if (!(error instanceof DeleteOperationItemNotFoundError)) { + throw error + } + } + }), + ) +} diff --git a/packages/supabase-db-collection/src/index.ts b/packages/supabase-db-collection/src/index.ts new file mode 100644 index 0000000000..58a93e76de --- /dev/null +++ b/packages/supabase-db-collection/src/index.ts @@ -0,0 +1 @@ +export { supabaseCollectionOptions } from './db' diff --git a/packages/supabase-db-collection/src/postgrest-filters/common.ts b/packages/supabase-db-collection/src/postgrest-filters/common.ts new file mode 100644 index 0000000000..78f5aa8182 --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/common.ts @@ -0,0 +1,230 @@ +import type { SerializedExpression as Expression } from '../serialize' + +type Comparison = { column: string; operator: string; value: string } +export type PostgrestParam = + ({ kind: 'column' } & Comparison) | { kind: 'group'; filter: string } + +interface FilterOptions { + mergeIn?: boolean + /** + * Quote top-level scalar values (the Realtime path). PostgREST sends each + * comparison as its own `column=op.value` query param, so top-level scalars + * must stay raw; Realtime comma-joins conditions into a single `filter`, so + * top-level scalars must be quoted to keep reserved characters (commas, + * whitespace) from splitting a condition. + */ + quoteScalars?: boolean + strict?: boolean + stripAlias?: boolean +} + +/** Set the comma-joined `order` param (independent of limit/offset). */ +export function appendOrder( + search: URLSearchParams, + sorts: Array<{ column: string; ascending: boolean }>, +): void { + if (sorts.length === 0) return + search.set( + 'order', + sorts + .map((sort) => `${sort.column}.${sort.ascending ? 'asc' : 'desc'}`) + .join(','), + ) +} + +export function appendLimit(search: URLSearchParams, limit: number): void { + search.set('limit', `${limit}`) +} + +export function appendOffset(search: URLSearchParams, offset: number): void { + search.set('offset', `${offset}`) +} + +/** Render params as the `URLSearchParams` sent to (and keyed by) PostgREST. */ +export function paramsToSearch(params: Array): URLSearchParams { + const search = new URLSearchParams() + for (const param of params) { + if (param.kind === 'column') + search.append(param.column, `${param.operator}.${param.value}`) + else search.append('or', `(${param.filter})`) + } + return search +} + +export function flattenAnd(expr: Expression): Array { + return expr.type === 'func' && expr.name === 'and' + ? expr.args.flatMap(flattenAnd) + : [expr] +} + +// PostgREST interpolates filter values into the URL as-is, and +// `Date.prototype.toString()` produces something Postgres cannot cast to a +// timestamp. Rendering it as ISO 8601 keeps the server query in agreement with +// the Realtime subscription, which serializes Dates the same way. +export const toScalarString = (value: unknown): string => + value instanceof Date ? value.toISOString() : `${value}` + +// Only lists and logical groups parse quoted values. Top-level scalar values +// must remain raw: col=eq."x" would match the quotes themselves. +const NEEDS_QUOTES = /^$|^\s|\s$|[,()"\\]/ +export const quoteValue = (value: unknown): string => { + const raw = toScalarString(value) + return NEEDS_QUOTES.test(raw) + ? `"${raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + : raw +} + +const SCALAR_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike'] + +function renderComparison( + expr: Expression, + quoteScalars: boolean, + options: FilterOptions, +): Comparison | null { + if (expr.type !== 'func') return null + const [left, right] = expr.args + if (expr.name === 'not' && left) { + if (left.type === 'func' && left.name === 'not' && left.args[0]) { + return renderComparison(left.args[0], quoteScalars, options) + } + const inner = renderComparison(left, quoteScalars, options) + if (!inner) return null + // TanStack's NOT IN [] excludes nulls; PostgREST's NOT IN () includes them. + if (inner.operator === 'in' && inner.value === '()') { + return { ...inner, operator: 'not.is', value: 'null' } + } + return { + ...inner, + operator: `not.${inner.operator}`, + } + } + if (left?.type !== 'ref' || left.path[0] === '$selected') return null + const column = (options.stripAlias ? left.path.slice(1) : left.path).join('.') + if (!column) return null + if (expr.name === 'isNull') return { column, operator: 'is', value: 'null' } + if (right?.type !== 'val') return null + + if (expr.name === 'in') { + if (!Array.isArray(right.value)) return null + // TanStack's membership test ignores null list members for non-null rows. + const values = Array.from( + new Set(right.value.filter((value) => value != null)), + ) + // PostgREST treats IN ("") as an empty list, even when quoted. + if (values.length === 1 && values[0] === '') { + return { column, operator: 'eq', value: quoteScalars ? '""' : '' } + } + return { + column, + operator: 'in', + value: `(${values.map(quoteValue).join(',')})`, + } + } + if (!SCALAR_OPERATORS.includes(expr.name)) return null + let value = right.value + if (expr.name === 'like' || expr.name === 'ilike') { + // TanStack uses SQL %/_ wildcards, but treats * and backslashes literally. + // PostgREST always rewrites * to %, so such patterns cannot be pushed. + if (typeof value !== 'string' || value.includes('*')) return null + value = value.replace(/\\/g, '\\\\') + } + return { + column, + operator: expr.name, + value: quoteScalars ? quoteValue(value) : toScalarString(value), + } +} + +// Embedded expressions are all-or-nothing. Dropping a child of OR or NOT +// could narrow the response and permanently lose matching rows. +function toFilterString( + expr: Expression, + options: FilterOptions, + negated = false, +): string | null { + if (expr.type !== 'func') return null + if (expr.name === 'not') { + return expr.args[0] ? toFilterString(expr.args[0], options, !negated) : null + } + if (expr.name === 'and' || expr.name === 'or') { + if (expr.args.length === 0) return null + // Move NOT down to comparisons with De Morgan's laws. In particular, an + // empty IN list needs its null guard even under a negated logical group. + const operator = negated ? (expr.name === 'and' ? 'or' : 'and') : expr.name + const parts = expr.args.map((arg) => toFilterString(arg, options, negated)) + return parts.includes(null) ? null : `${operator}(${parts.join(',')})` + } + const comparison = renderComparison( + negated ? { type: 'func', name: 'not', args: [expr] } : expr, + true, + options, + ) + return comparison + ? `${comparison.column}.${comparison.operator}.${comparison.value}` + : null +} + +// Preserve the live adapter's union of duplicate IN demands. This deliberately +// fetches a superset, which TanStack re-filters. Never do this inside OR/NOT or +// for server aggregates, where client-side filtering cannot repair the result. +export function mergeInFilters(filters: Array): Array { + const merged: Array = [] + const valuesByField = new Map>() + for (const filter of filters) { + if (filter.type !== 'func' || filter.name !== 'in') { + merged.push(filter) + continue + } + const [left, right] = filter.args + if ( + left?.type !== 'ref' || + right?.type !== 'val' || + !Array.isArray(right.value) + ) { + merged.push(filter) + continue + } + const field = JSON.stringify(left.path) + const existing = valuesByField.get(field) + if (existing) { + existing.push(...right.value) + } else { + const values = [...right.value] + valuesByField.set(field, values) + merged.push({ ...filter, args: [left, { type: 'val', value: values }] }) + } + } + return merged +} + +export function toPostgrestParams( + expr: Expression | undefined | null, + options: FilterOptions = {}, +): Array { + if (!expr) return [] + const conjuncts = flattenAnd(expr) + const filters = options.mergeIn ? mergeInFilters(conjuncts) : conjuncts + return filters.flatMap((filter): Array => { + const comparison = renderComparison( + filter, + options.quoteScalars ?? false, + options, + ) + if (comparison) return [{ kind: 'column', ...comparison }] + const embedded = toFilterString(filter, options) + if (embedded !== null) { + return [ + { + kind: 'group', + filter: embedded.startsWith('or(') ? embedded.slice(3, -1) : embedded, + }, + ] + } + if (options.strict) { + throw new Error( + 'Cannot push WHERE expression to PostgREST; server aggregates require fully pushable filters', + ) + } + return [] + }) +} diff --git a/packages/supabase-db-collection/src/postgrest-filters/index.ts b/packages/supabase-db-collection/src/postgrest-filters/index.ts new file mode 100644 index 0000000000..ebe7243a7e --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/index.ts @@ -0,0 +1,12 @@ +export { keyColumnsToSearch } from './key-columns-to-search' +export { + cursorCurrentToSearch, + cursorWhereFromToSearch, + loadSubsetOptionsToSearch, +} from './load-subset-options-to-search' +export { realtimeFiltersToSearch } from './realtime-filters-to-search' +export { + type SplitLoadSubsetOptionsConfig, + splitLoadSubsetOptions, +} from './split-load-subset-options' +export { subsetParamsToSearch } from './subset-params-to-search' diff --git a/packages/supabase-db-collection/src/postgrest-filters/key-columns-to-search.ts b/packages/supabase-db-collection/src/postgrest-filters/key-columns-to-search.ts new file mode 100644 index 0000000000..b2790a3f5b --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/key-columns-to-search.ts @@ -0,0 +1,16 @@ +import { paramsToSearch, toScalarString } from './common' + +/** Build the `key.eq.value` search that matches one row by its key columns. */ +export function keyColumnsToSearch( + keys: Array, + item: object, +): URLSearchParams { + return paramsToSearch( + keys.map((key) => ({ + kind: 'column', + column: key, + operator: 'eq', + value: toScalarString(Reflect.get(item, key)), + })), + ) +} diff --git a/packages/supabase-db-collection/src/postgrest-filters/load-subset-options-to-search.ts b/packages/supabase-db-collection/src/postgrest-filters/load-subset-options-to-search.ts new file mode 100644 index 0000000000..986f1cb512 --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/load-subset-options-to-search.ts @@ -0,0 +1,79 @@ +import { appendOffset, paramsToSearch, toPostgrestParams } from './common' +import { subsetParamsToSearch } from './subset-params-to-search' +import type { LoadSubsetOptions } from '@tanstack/db' + +/** + * Render the cursor's `whereFrom` (keyset "rows after boundary") filters. + * + * Uses the adapter's own {@link toPostgrestParams} renderer so it handles the + * composite cursors core emits for multi-column `orderBy` + * (`or(gt(c1,v1), and(eq(c1,v1), gt(c2,v2)))`) and serialises Date boundaries + * as ISO 8601 — both of which the pre-flattening `extractSimpleComparisons` + * path could not. `strict` surfaces any un-pushable cursor rather than silently + * dropping a predicate and widening the window. + */ +export function cursorWhereFromToSearch( + cursor: LoadSubsetOptions['cursor'], +): URLSearchParams { + if (!cursor) return new URLSearchParams() + return paramsToSearch(toPostgrestParams(cursor.whereFrom, { strict: true })) +} + +/** + * Build the full read query string for a TanStack DB subset load: the shared + * subset params ({@link subsetParamsToSearch}) plus `select=*` and the cursor's + * keyset (`whereFrom`) filters, or `offset` for cursor-less requests. + */ +export function loadSubsetOptionsToSearch( + options: LoadSubsetOptions, +): URLSearchParams { + const search = new URLSearchParams() + search.set('select', '*') + for (const [key, value] of subsetParamsToSearch(options)) { + search.append(key, value) + } + + for (const [key, value] of cursorWhereFromToSearch(options.cursor)) { + search.append(key, value) + } + + // Cursor and offset are mutually exclusive: core may send both, but the + // cursor already pins the window start, so an offset on top of it re-skips + // rows the cursor has already moved past. Honour offset only without a cursor. + if (options.offset && !options.cursor) { + appendOffset(search, options.offset) + } + return search +} + +/** + * Build the read query string for the boundary-tie request that accompanies a + * cursor load: the same `where`/`order` as the main request plus the cursor's + * `whereCurrent` (rows equal to the boundary value), and deliberately **no + * limit** — every tied row must come back or a row sharing the boundary value + * with `orderBy` would be skipped. Returns `null` when there is no cursor or no + * tie predicate, so the caller can fall back to the single main request. + */ +export function cursorCurrentToSearch( + options: LoadSubsetOptions, +): URLSearchParams | null { + if (!options.cursor) return null + const currentParams = toPostgrestParams(options.cursor.whereCurrent, { + strict: true, + }) + if (currentParams.length === 0) return null + + const search = new URLSearchParams() + search.set('select', '*') + // where + order only — no limit, no offset, no `whereFrom`. + for (const [key, value] of subsetParamsToSearch({ + where: options.where, + orderBy: options.orderBy, + })) { + search.append(key, value) + } + for (const [key, value] of paramsToSearch(currentParams)) { + search.append(key, value) + } + return search +} diff --git a/packages/supabase-db-collection/src/postgrest-filters/realtime-filters-to-search.ts b/packages/supabase-db-collection/src/postgrest-filters/realtime-filters-to-search.ts new file mode 100644 index 0000000000..4eb8aa69ea --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/realtime-filters-to-search.ts @@ -0,0 +1,101 @@ +import { toPostgrestParams } from './common' +import type { PostgrestParam } from './common' +import type { LoadSubsetOptions } from '@tanstack/db' + +type WhereExpression = LoadSubsetOptions['where'] +type ColumnParam = Extract + +/** + * Realtime `postgres_changes` accepts only a top-level column compared with one + * of these operators (optionally negated with `not.`). Anything else — an + * embedded column, an OR group, a function — cannot be pushed and forces the + * catch-all. + */ +const REALTIME_OPERATOR = /^(?:not\.)?(?:eq|gt|gte|lt|lte|in|is)$/ +/** Realtime rejects `in` filters with more than this many values. */ +const MAX_IN_VALUES = 100 + +/** + * Whether a rendered PostgREST param is expressible as a single Realtime + * condition. Realtime shares PostgREST's WHERE grammar, so the shared renderer + * already produced the exact `operator.value` wire form; this only rejects the + * shapes Realtime cannot evaluate. + */ +const isRealtimeParam = (param: PostgrestParam): param is ColumnParam => { + // OR groups and other embedded filters have no single-column Realtime form. + if (param.kind !== 'column') { + return false + } + if (!REALTIME_OPERATOR.test(param.operator)) { + return false + } + // Realtime can only evaluate a top-level table column, never an embedded one. + if (param.column.includes('.')) { + return false + } + if (param.operator.endsWith('in')) { + // An empty IN list matches nothing; the catch-all keeps the subscription + // usable and lets the live query re-filter client-side. + if (param.value === '()') { + return false + } + // Splitting on commas overcounts values that quote a comma, which only ever + // trips the limit early and falls back to the (correct) catch-all. + const members = param.value.slice(1, -1).split(',') + if (members.length > MAX_IN_VALUES) { + return false + } + } + return true +} + +/** + * Build the Realtime `postgres_changes` filters for a table's active queries. + * + * The result is a single `URLSearchParams` with one repeated `filter` entry per + * active query, each value being that query's AND-ed conditions comma-joined + * into Realtime's wire form (e.g. `active=eq.true,id=gt.5`). Conditions and + * queries are sorted and deduplicated so an unchanged filter set produces a + * stable `toString()` key. An empty `URLSearchParams` is the catch-all: it is + * returned whenever any query is unfiltered or uses an expression Realtime + * cannot evaluate, since a narrower subscription would drop rows that query + * needs. + */ +export function realtimeFiltersToSearch( + whereExpressions: Array, +): URLSearchParams { + const groups = new Set() + + for (const where of whereExpressions) { + let params: Array + try { + // `strict` throws on anything unpushable (server aggregates, functions); + // `quoteScalars` matches the request path's quoting so reserved characters + // survive the comma-joined condition list. + params = toPostgrestParams(where, { quoteScalars: true, strict: true }) + } catch { + return new URLSearchParams() + } + + // A query with no pushable conditions matches every row, so the whole table + // must fall back to the catch-all. + if (params.length === 0) { + return new URLSearchParams() + } + + const conditions = new Set() + for (const param of params) { + if (!isRealtimeParam(param)) { + return new URLSearchParams() + } + conditions.add(`${param.column}=${param.operator}.${param.value}`) + } + groups.add(Array.from(conditions).sort().join(',')) + } + + const search = new URLSearchParams() + for (const filter of Array.from(groups).sort()) { + search.append('filter', filter) + } + return search +} diff --git a/packages/supabase-db-collection/src/postgrest-filters/split-load-subset-options.ts b/packages/supabase-db-collection/src/postgrest-filters/split-load-subset-options.ts new file mode 100644 index 0000000000..bd099beb1e --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/split-load-subset-options.ts @@ -0,0 +1,258 @@ +import { IR } from '@tanstack/db' +import { flattenAnd, mergeInFilters } from './common' +import type { LoadSubsetOptions } from '@tanstack/db' +import type { SerializedExpression as Expression } from '../serialize' + +export interface SplitLoadSubsetOptionsConfig { + /** The longest URL (in characters) a single chunk's request may produce. */ + maxUrlLength: number + /** + * Renders the longest URL a given subset would produce (main request, and + * the `whereCurrent` tie request when the subset has a cursor). Injected so + * the splitter stays a pure function of its inputs — the caller supplies the + * real `supabase.from(table).url` + search rendering, tests supply a fake. + */ + measure: (options: LoadSubsetOptions) => number +} + +type SplittableConjunct = { + index: number + path: Array + values: Array +} + +// Mirrors `renderComparison`'s IN handling (common.ts): TanStack's membership +// test ignores null members, and duplicates contribute nothing extra to the +// rendered list, so both are stripped before we decide how big a conjunct is +// and before we hand out chunk values. +function dedupedValues(values: Array): Array { + return Array.from(new Set(values.filter((value) => value != null))) +} + +// Only a positive, top-level `in(ref, [...])` conjunct is safe to split: the +// union of its chunks is the same set of matching rows. `not(in(...))` and any +// IN nested in OR/NOT are left untouched by the caller (they never reach here +// as a candidate) because a chunked union is not equivalent to their meaning. +function splittableConjuncts( + conjuncts: Array, +): Array { + const candidates: Array = [] + conjuncts.forEach((conjunct, index) => { + if (conjunct.type !== 'func' || conjunct.name !== 'in') { + return + } + const [left, right] = conjunct.args + if ( + left?.type !== 'ref' || + right?.type !== 'val' || + !Array.isArray(right.value) + ) { + return + } + const values = dedupedValues(right.value) + if (values.length === 0) { + // Nothing to chunk (every member was null, or the list was empty); leave + // the conjunct exactly as it was rendered before. + return + } + candidates.push({ index, path: left.path, values }) + }) + // Largest first: the biggest IN list is the one worth splitting, and the + // fallback in `splitConjuncts` halves the next-largest one. + return candidates.sort((a, b) => b.values.length - a.values.length) +} + +function replaceConjunct( + conjuncts: Array, + candidate: SplittableConjunct, + values: Array, +): Array { + const copy = conjuncts.slice() + copy[candidate.index] = new IR.Func('in', [ + new IR.PropRef(candidate.path), + new IR.Value(values), + ]) + return copy +} + +function rebuildWhere( + conjuncts: Array, +): LoadSubsetOptions['where'] { + if (conjuncts.length === 0) { + return + } + const where = + conjuncts.length === 1 + ? conjuncts[0] + : new IR.Func('and', conjuncts as unknown as Array) + // `conjuncts` entries are either untouched pieces of the caller's own + // `where` (already real IR instances) or `IR.Func`/`IR.Value`/`IR.PropRef` + // instances built above, so this is a widen-then-narrow of the same runtime + // shape `toPostgrestParams` already accepts elsewhere in this package. + return where as unknown as LoadSubsetOptions['where'] +} + +// Applies the caller's `options` (limit/offset/cursor, orderBy, etc.) to one +// chunk's conjunct list, rewriting limit/offset per the plan: a cursor read +// keeps its limit unchanged (the cursor already pins the window start via a +// filter); a cursor-less read with an offset must ask each chunk for enough +// rows to cover the *whole* global window (`limit + offset`) starting at 0, +// because every row in that window ranks at most `offset + limit` inside its +// own chunk — the union of per-chunk windows then still contains the global +// window, with no cross-chunk merge needed since chunks are disjoint on the +// split column. +function buildChunkOptions( + options: LoadSubsetOptions, + conjuncts: Array, +): LoadSubsetOptions { + const chunk: LoadSubsetOptions = { + ...options, + where: rebuildWhere(conjuncts), + } + if (!options.cursor && options.offset) { + if (options.limit !== undefined) { + chunk.limit = options.limit + options.offset + } + chunk.offset = undefined + } + return chunk +} + +// Binary-search the longest prefix of `values` whose chunk still fits the +// budget; `fits` is monotonically decreasing in prefix length, so this is +// O(log n) renders instead of O(n). +function longestFittingPrefix( + fits: (length: number) => boolean, + count: number, +): number { + if (count === 0 || !fits(1)) { + return 0 + } + if (fits(count)) { + return count + } + let low = 1 + let high = count + while (low < high) { + const mid = low + Math.ceil((high - low) / 2) + if (fits(mid)) { + low = mid + } else { + high = mid - 1 + } + } + return low +} + +/** + * Split one oversized subset's conjunct list into several whose rendered URL + * fits `maxUrlLength`. The largest splittable IN is packed greedily; when not + * even one of its values fits because another IN is also huge, that other IN + * is halved and each half is split again from scratch. Halving (rather than + * packing the other IN as tightly as possible) leaves room for more than one + * value of the largest IN per chunk, so the chunk count stays close to the + * minimum. The resulting conjunct lists are a cartesian product of the split + * candidates and remain pairwise disjoint on the split column(s), so the union + * of their result sets is exactly the original subset's — no dedupe needed. + * + * Returns `undefined` when no split fits: something other than the split + * candidates (e.g. a huge `not(in(...))`) keeps even a single value over + * budget, so fanning out would only send more oversized requests. + */ +function splitConjuncts( + options: LoadSubsetOptions, + conjuncts: Array, + config: SplitLoadSubsetOptionsConfig, +): Array> | undefined { + const [current, ...rest] = splittableConjuncts(conjuncts) + if (!current) { + return [conjuncts] + } + + const chunks: Array> = [] + let remaining = current.values + while (remaining.length > 0) { + const fits = (length: number) => + config.measure( + buildChunkOptions( + options, + replaceConjunct(conjuncts, current, remaining.slice(0, length)), + ), + ) <= config.maxUrlLength + const fitLength = longestFittingPrefix(fits, remaining.length) + + if (fitLength > 0) { + chunks.push( + replaceConjunct(conjuncts, current, remaining.slice(0, fitLength)), + ) + remaining = remaining.slice(fitLength) + continue + } + + const other = rest.find((candidate) => candidate.values.length > 1) + if (!other) { + // Nothing left to shrink and even a single value does not fit. + return + } + + // Another IN is too big to leave room for even one of `current`'s values: + // halve it, and split each half (with `current`'s remaining values) again. + const withRemaining = replaceConjunct(conjuncts, current, remaining) + const middle = Math.ceil(other.values.length / 2) + for (const half of [ + other.values.slice(0, middle), + other.values.slice(middle), + ]) { + const halfChunks = splitConjuncts( + options, + replaceConjunct(withRemaining, other, half), + config, + ) + if (!halfChunks) { + return + } + chunks.push(...halfChunks) + } + remaining = [] + } + return chunks +} + +/** + * Split an oversized `loadSubset` request into several whose rendered URL each + * fit `maxUrlLength`, so a caller can run them as separate requests and + * concatenate the results. Returns `[options]` unchanged — the same object, + * not a copy — whenever it already fits, so the common path sends a + * byte-identical URL and keeps today's behaviour. + * + * Only a positive, top-level `AND`ed `in(ref, [...])` conjunct is ever split: + * splitting inside `OR`/`NOT`, or a top-level `not(in(...))`, would change + * which rows the union of chunks matches. When no such conjunct exists, + * `options.where` is absent, or no split brings every chunk within budget, + * this returns `[options]` and the caller sends the request as-is, letting the server's own error (if any) surface exactly + * as it does today. + */ +export function splitLoadSubsetOptions( + options: LoadSubsetOptions, + config: SplitLoadSubsetOptionsConfig, +): Array { + if (config.measure(options) <= config.maxUrlLength) { + return [options] + } + if (!options.where) { + return [options] + } + + const conjuncts = mergeInFilters(flattenAnd(options.where)) + if (splittableConjuncts(conjuncts).length === 0) { + return [options] + } + + const chunks = splitConjuncts(options, conjuncts, config) + if (!chunks) { + return [options] + } + return chunks.map((chunkConjuncts) => + buildChunkOptions(options, chunkConjuncts), + ) +} diff --git a/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts b/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts new file mode 100644 index 0000000000..d6a87f83ad --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts @@ -0,0 +1,37 @@ +import { parseOrderByExpression } from '@tanstack/db' +import { + appendLimit, + appendOrder, + paramsToSearch, + toPostgrestParams, +} from './common' +import type { LoadSubsetOptions } from '@tanstack/db' + +/** + * Encode the params that identify a cached subset — where filters, order, and + * limit. Deliberately excludes `select`, `cursor`, and `offset`: those are the + * per-page request details, not part of what distinguishes one subset from + * another. This is the shared core of the request URL and the query key, so the + * two cannot drift. + */ +export function subsetParamsToSearch( + options: LoadSubsetOptions, +): URLSearchParams { + const { where, orderBy, limit } = options + const search = paramsToSearch(toPostgrestParams(where, { mergeIn: true })) + appendOrder( + search, + parseOrderByExpression(orderBy).map((sort) => ({ + column: sort.field.join('.'), + ascending: sort.direction === 'asc', + })), + ) + // `limit: 0` is a real, distinct subset (an empty window). Guard on + // `undefined`, not falsiness, so it does not collide with the unlimited query + // on one cache key — otherwise the empty limit-0 result would take over and + // delete every row the unlimited query owns. + if (limit !== undefined) { + appendLimit(search, limit) + } + return search +} diff --git a/packages/supabase-db-collection/src/postgrest-request.ts b/packages/supabase-db-collection/src/postgrest-request.ts new file mode 100644 index 0000000000..358e8af3aa --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-request.ts @@ -0,0 +1,101 @@ +import { PostgrestError, PostgrestFilterBuilder } from '@supabase/postgrest-js' +import { CLIENT_INFO, CLIENT_INFO_HEADER } from './request-headers' +import type { SupabaseClient } from '@supabase/supabase-js' + +const SINGLE_ROW_ACCEPT = 'application/vnd.pgrst.object+json' + +interface PostgrestRequestOptions { + /** JSON body for insert/update. */ + body?: unknown + /** + * Ask PostgREST for the total matching-row count (`Prefer: count=…`). The + * count is read back off the `Content-Range` header; the paging loop uses it + * to fetch a set larger than the server's `db-max-rows` cap without probing. + */ + count?: 'exact' | 'planned' | 'estimated' + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' + /** Ask PostgREST to return the affected rows (`Prefer: return=representation`). */ + returnRows?: boolean + /** The full query string, built by the params helpers. */ + search: URLSearchParams + /** Abort signal, threaded from the query's `ctx.signal`. */ + signal?: AbortSignal + /** Expect exactly one row (`Accept: application/vnd.pgrst.object+json`). */ + single?: boolean +} + +/** A PostgREST read result: the rows plus the total count when one was asked for. */ +export interface PostgrestResult { + /** Total matching rows when `count` was requested, else `null`. */ + count: number | null + /** The response body: an array of rows, a single row, or `null` when empty. */ + data: TData | null +} + +/** + * Issue a PostgREST request with a hand-built query string while leaving + * supabase-js in charge of auth, schema, tracing, timeout, and transport. + * + * `supabase.from(table)` yields a `PostgrestQueryBuilder` whose `url`, + * `headers`, `schema`, and `fetch` are the auth-injecting, timeout-wrapped + * request state (the `fetch` wrapper derives `apikey`/`Authorization`, the + * headers already carry the user's `global.headers`). We clone that state, + * swap in our own search string, and hand it to `PostgrestFilterBuilder` — a + * documented standalone entry point — so we reuse its `Accept-Profile`/ + * `Content-Profile`, response parsing, and error handling without re-deriving + * auth ourselves. + */ +export async function postgrestRequest( + supabase: SupabaseClient, + table: string, + { + method, + search, + body, + returnRows, + single, + count, + signal, + }: PostgrestRequestOptions, +): Promise> { + const queryBuilder = supabase.from(table) + + const url = new URL(queryBuilder.url.toString()) + url.search = search.toString() + + const headers = new Headers(queryBuilder.headers) + headers.set(CLIENT_INFO_HEADER, CLIENT_INFO) + if (returnRows) { + headers.append('Prefer', 'return=representation') + } + if (count) { + headers.append('Prefer', `count=${count}`) + } + if (single) { + headers.set('Accept', SINGLE_ROW_ACCEPT) + } + + // PostgrestFilterBuilder's generics describe database schema types that are + // unavailable without codegen and irrelevant here, since only `data`, `error` + // and `count` are read off the awaited response. Pinning them to `any` also + // keeps this call valid across postgrest-js versions that differ in the + // number of generic parameters. + const builder = new PostgrestFilterBuilder({ + method, + url, + headers, + schema: queryBuilder.schema, + body, + fetch: queryBuilder.fetch, + // The builder threads its own `signal` straight into the underlying fetch, + // so aborting the query cancels the in-flight request. + signal, + }) + + const { data, error, count: total } = await builder + if (error) { + throw error instanceof Error ? error : new PostgrestError(error) + } + // The caller states the response shape it expects for this request. + return { data: data as TData | null, count: total ?? null } +} diff --git a/packages/supabase-db-collection/src/query-client.ts b/packages/supabase-db-collection/src/query-client.ts new file mode 100644 index 0000000000..ac44b7b794 --- /dev/null +++ b/packages/supabase-db-collection/src/query-client.ts @@ -0,0 +1,30 @@ +import { QueryClient, isServer } from '@tanstack/query-core' + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 60 * 1000, + }, + }, + }) +} + +let browserQueryClient: QueryClient | undefined + +export function getQueryClient() { + if (isServer) { + // Server: always make a new query client + return makeQueryClient() + } + // Browser: make a new query client if we don't already have one + // This is very important, so we don't re-make a new client if React + // suspends during the initial render. This may not be needed if we + // have a suspense boundary BELOW the creation of the query client + if (!browserQueryClient) { + browserQueryClient = makeQueryClient() + } + return browserQueryClient +} diff --git a/packages/supabase-db-collection/src/realtime.ts b/packages/supabase-db-collection/src/realtime.ts new file mode 100644 index 0000000000..9b7d47c076 --- /dev/null +++ b/packages/supabase-db-collection/src/realtime.ts @@ -0,0 +1,449 @@ +import { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js' +import { + DeleteOperationItemNotFoundError, + UpdateOperationItemNotFoundError, +} from '@tanstack/query-db-collection' +import { realtimeFiltersToSearch } from './postgrest-filters' +import { parseRow } from './row-conversions' +import type { ColumnConversions } from './row-conversions' +import type { + RealtimePostgresChangesFilter, + RealtimePostgresDeletePayload, + RealtimePostgresUpdatePayload, + SupabaseClient, +} from '@supabase/supabase-js' +import type { Collection } from '@tanstack/db' +import type { QueryClient } from '@tanstack/query-core' + +type ChangeEvent = 'INSERT' | 'UPDATE' | 'DELETE' + +export type RealtimeSubscription = { + channel: ReturnType + /** + * Resolves with the terminal subscribe status — `SUBSCRIBED`, + * `CHANNEL_ERROR`, or `CLOSED`. It is not time-bounded: while realtime-js is + * still retrying the join (e.g. `TIMED_OUT`) it stays pending. Callers + * distinguish a filter rejection (`CHANNEL_ERROR` on a filtered subscription) + * from a transient connection error, so a dropped connection is not mistaken + * for a server rejection. + */ + outcome: Promise +} + +/** + * Whether a row is in the collection's synced store. + * + * `collection.has()` reflects the optimistic view, which includes local + * mutations that are still in flight. The manual sync writes here (and the + * delete guard in {@link ./functions}) validate against the synced store only, + * so that is the store to check: a Realtime echo of a pending local insert must + * not be treated as an update, and a row with a pending local delete is still + * there to be updated or deleted. + */ +export const isSynced = ( + collection: Collection, + key: TKey, +): boolean => collection._state.syncedData.has(key) + +/** + * Direct writes are applied asynchronously, so the row can be removed — e.g. by + * the local delete handler confirming the same delete — between the + * {@link isSynced} check and the write landing. The row is gone either way. + */ +const ignoreRemovedRow = (error: unknown) => { + if ( + error instanceof UpdateOperationItemNotFoundError || + error instanceof DeleteOperationItemNotFoundError + ) { + return + } + throw error +} + +/** + * Subscribes to Supabase Realtime changes for a table and writes inserts, + * updates, and deletes into the collection. + * + * Filters are applied to INSERT and UPDATE listeners only: a filtered event is + * Realtime telling us the row matches an active query, which is what makes it + * belong in the collection — including a row that an UPDATE moved into that + * query's window. Two extra unfiltered listeners cover what a filtered + * subscription cannot see: + * + * - UPDATE, so a row already in the collection still receives its changes after + * it stops matching the filter instead of going stale. + * - DELETE, because Realtime only delivers filtered delete events for tables + * with `replica identity full`, and delete payloads are just the key anyway. + * + * `filters` is the {@link ./postgrest-filters!realtimeFiltersToSearch} result: + * one `filter` entry per active query, each a comma-joined Realtime condition + * list. No entries means a catch-all subscription to every change on the table. + */ +export const attachSupabaseListeners = < + T extends Record, + TKey extends string | number, +>( + supabase: SupabaseClient, + topic: string, + tableName: string, + collection: Collection, + filters: URLSearchParams = new URLSearchParams(), + conversions: ColumnConversions = {}, +): RealtimeSubscription => { + const channel = supabase.channel(topic) + + const changesFilter = ( + event: TEvent, + filter?: string, + ): RealtimePostgresChangesFilter => { + const config: RealtimePostgresChangesFilter = { + event, + schema: 'public', + table: tableName, + } + if (filter) { + config.filter = filter + } + return config + } + + // The row matches an active query's filter, so it belongs in the collection + // whether or not we have seen it before. Upsert decides insert-vs-update on + // the synced store, which also makes replayed or racing events harmless. + const handleUpsert = (payload: { new: T }) => { + collection.utils.writeUpsert(parseRow(payload.new, conversions)) + } + + // Unfiltered updates arrive for every row in the table, so only rows the + // collection already holds are written — otherwise it would mirror the whole + // table locally. + const handleKnownUpdate = (payload: RealtimePostgresUpdatePayload) => { + const row = parseRow(payload.new, conversions) + if (isSynced(collection, collection.getKeyFromItem(row))) { + collection.utils.writeUpdate(row).catch(ignoreRemovedRow) + } + } + + const handleDelete = (payload: RealtimePostgresDeletePayload) => { + const id = collection.getKeyFromItem(parseRow(payload.old, conversions)) + if (isSynced(collection, id)) { + collection.utils.writeDelete(id).catch(ignoreRemovedRow) + } + } + + const conditions = filters.getAll('filter') + // A catch-all subscription registers one unfiltered INSERT/UPDATE pair. + for (const filter of conditions.length > 0 ? conditions : [undefined]) { + channel.on( + 'postgres_changes', + changesFilter('INSERT', filter), + handleUpsert, + ) + channel.on( + 'postgres_changes', + changesFilter('UPDATE', filter), + handleUpsert, + ) + } + + // The extra unfiltered UPDATE listener only earns its keep when the INSERT/ + // UPDATE listeners above are filtered; a catch-all already sees every update. + if (conditions.length > 0) { + channel.on( + 'postgres_changes', + changesFilter('UPDATE'), + handleKnownUpdate, + ) + } + + channel.on('postgres_changes', changesFilter('DELETE'), handleDelete) + + let resolveOutcome: (status: REALTIME_SUBSCRIBE_STATES) => void = () => + undefined + const outcome = new Promise((resolve) => { + resolveOutcome = resolve + }) + + channel.subscribe((status) => { + // SUBSCRIBED means the join succeeded. CHANNEL_ERROR is how the server + // rejects a subscription — typically a filter it cannot evaluate — but it is + // also emitted for a transient transport failure, so the caller tells the + // two apart. CLOSED is a terminal close before joining. TIMED_OUT is + // deliberately not terminal: realtime-js keeps retrying the join and reports + // the outcome through this same callback. + if ( + status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED || + status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || + status === REALTIME_SUBSCRIBE_STATES.CLOSED + ) { + resolveOutcome(status) + } + }) + + return { channel, outcome } +} + +/** + * A table's Realtime channel lifecycle state, held by the registry in + * {@link ./db}. The joined channel and any in-flight swap are tracked + * separately so a replacement can be superseded or torn down without losing the + * channel that is still delivering changes. + */ +export interface TableEntry { + /** + * The collection receiving realtime writes. Its row type comes from the + * caller's schema, so the registry (keyed only by table name) cannot name it. + */ + collectionRef: Collection | null + /** Converts Realtime payload rows to the rows the collection holds. */ + conversions: ColumnConversions + /** The joined channel currently delivering changes (null when none). */ + realtimeChannel: ReturnType | null + /** Serialized set of Realtime filters the current channel was subscribed with */ + realtimeFiltersKey: string | null + /** + * A replacement channel that is still subscribing and has not joined yet. It + * is tracked separately from {@link realtimeChannel} so an in-flight swap can + * be superseded or torn down without losing the channel that is still + * delivering changes. + */ + realtimePendingChannel: ReturnType | null + /** Serialized set of Realtime filters {@link realtimePendingChannel} is subscribing with */ + realtimePendingFiltersKey: string | null + /** + * Whether WHERE clauses are pushed to Realtime as server-side filters. When + * `false`, the table always subscribes with a catch-all and filters + * client-side. + */ + realtimeUseFilter: boolean + /** + * Filter sets the server has rejected. They are replaced by a catch-all + * subscription instead of being retried on every observer change. + */ + rejectedFilterKeys: Set + supabase: SupabaseClient +} + +/** A subscription that receives every change for the table. */ +const CATCH_ALL_FILTERS = new URLSearchParams() +/** The registry key of the catch-all: an empty filter set serializes to "". */ +const CATCH_ALL_KEY = '' + +/** + * Channel topics are namespaced and numbered because `supabase.channel()` + * returns the *existing* channel for a topic that is already registered, and + * subscribing to an already-joined channel throws. Reusing the table name would + * hand back the channel currently being torn down, and could collide with a + * channel the application opened itself or with another QueryClient sharing the + * same Supabase client — so the counter is module-level, not per table. + */ +let channelCount = 0 + +/** + * Every channel this adapter opens for `tableName` has a topic starting with + * this prefix (supabase-js exposes it under its own `realtime:` prefix). + */ +export const realtimeChannelTopicPrefix = (tableName: string) => + `supabase-tanstack-db:${tableName}:` + +const nextChannelTopic = (tableName: string) => { + channelCount += 1 + return `${realtimeChannelTopicPrefix(tableName)}${channelCount}` +} + +/** + * Opens a channel for `filters` as the table's *pending* channel. The joined + * channel keeps listening until the replacement actually joins, so no change + * slips through while the swap is in flight; only then is the predecessor + * removed. A swap already in flight is superseded (and torn down) first. + * + * On a terminal failure the pending channel is disposed. A filter the server + * rejects is remembered and retried as a catch-all, while the previous channel + * keeps working. A `CHANNEL_ERROR` on the catch-all itself is treated as a + * transient connection error, not a rejection: if a previous channel is still + * working it is kept, otherwise the errored channel is retained so realtime-js + * reconnects it instead of leaving the table with no subscription at all. + */ +function subscribeToChanges( + queryClient: QueryClient, + entry: TableEntry, + tableName: string, + collection: Collection, + filters: URLSearchParams, + filtersKey: string, +) { + const previousChannel = entry.realtimeChannel + const previousFiltersKey = entry.realtimeFiltersKey + + // A swap already in flight is now stale: tear it down before opening the next + // so a superseded replacement never leaks. + if (entry.realtimePendingChannel) { + entry.supabase.removeChannel(entry.realtimePendingChannel) + entry.realtimePendingChannel = null + entry.realtimePendingFiltersKey = null + } + + const subscription = attachSupabaseListeners( + entry.supabase, + nextChannelTopic(tableName), + tableName, + collection, + filters, + entry.conversions, + ) + + entry.realtimePendingChannel = subscription.channel + entry.realtimePendingFiltersKey = filtersKey + + // True once a newer swap (or a teardown) has taken over this pending channel. + const superseded = () => entry.realtimePendingChannel !== subscription.channel + + const onJoined = () => { + if (superseded()) { + return + } + if (previousChannel) { + entry.supabase.removeChannel(previousChannel) + } + entry.realtimeChannel = subscription.channel + entry.realtimeFiltersKey = filtersKey + entry.realtimePendingChannel = null + entry.realtimePendingFiltersKey = null + } + + const onError = () => { + if (superseded()) { + entry.supabase.removeChannel(subscription.channel) + return + } + entry.realtimePendingChannel = null + entry.realtimePendingFiltersKey = null + + if (filtersKey !== CATCH_ALL_KEY) { + // A filtered subscription the server refused: drop it, remember it, and + // let syncTableSubscription pick the catch-all it now maps to — the same + // decision the observer makes — while the previous channel keeps working. + entry.supabase.removeChannel(subscription.channel) + entry.rejectedFilterKeys.add(filtersKey) + syncTableSubscription(queryClient, tableName, entry) + return + } + + // A catch-all has nothing to reject, so CHANNEL_ERROR here is a transient + // connection error rather than a filter rejection. + if (previousChannel) { + // A previous channel is still delivering changes: drop the failed + // catch-all and hand the swap back to the predecessor. + entry.supabase.removeChannel(subscription.channel) + entry.realtimeChannel = previousChannel + entry.realtimeFiltersKey = previousFiltersKey + return + } + // Nothing else is subscribed: keep the errored channel so realtime-js + // reconnects it (its listeners stay attached) instead of leaving the table + // with no subscription until an observer changes. + entry.realtimeChannel = subscription.channel + entry.realtimeFiltersKey = filtersKey + } + + const onClosed = () => { + // A terminal close before joining will not reconnect: drop it and hand the + // swap back to the still-open previous channel. + if (superseded()) { + entry.supabase.removeChannel(subscription.channel) + return + } + entry.supabase.removeChannel(subscription.channel) + entry.realtimeChannel = previousChannel + entry.realtimeFiltersKey = previousFiltersKey + entry.realtimePendingChannel = null + entry.realtimePendingFiltersKey = null + } + + subscription.outcome.then((status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + onJoined() + } else if (status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR) { + onError() + } else { + onClosed() + } + }) +} + +/** + * Reconcile one table's Realtime subscription with its active queries: tear the + * channel down when there are none, otherwise derive the filters and (re)open a + * channel unless the current (or in-flight) one already matches. This is the + * single place that maps active queries to filters, so both the cache observer + * and the rejection fallback route through it. + */ +export function syncTableSubscription( + queryClient: QueryClient, + tableName: string, + entry: TableEntry, +) { + const queries = queryClient.getQueryCache().findAll({ + queryKey: [tableName], + type: 'active', + }) + + // No active queries: tear down any existing subscription, including a swap + // still in flight, so neither the joined nor the pending channel leaks. + if (queries.length === 0) { + if (entry.realtimeChannel) { + entry.supabase.removeChannel(entry.realtimeChannel) + } + if (entry.realtimePendingChannel) { + entry.supabase.removeChannel(entry.realtimePendingChannel) + } + entry.realtimeChannel = null + entry.realtimeFiltersKey = null + entry.realtimePendingChannel = null + entry.realtimePendingFiltersKey = null + return + } + + if (!entry.collectionRef) { + return + } + + // Derive the Realtime filters from the WHERE clause of every active query so + // the subscription only receives changes that those queries care about. When + // server-side filtering is disabled, skip that and subscribe to every change. + let filters = CATCH_ALL_FILTERS + let filtersKey = CATCH_ALL_KEY + if (entry.realtimeUseFilter) { + const whereExpressions = queries.map( + (query) => query.meta?.loadSubsetOptions?.where, + ) + filters = realtimeFiltersToSearch(whereExpressions) + filtersKey = filters.toString() + if (entry.rejectedFilterKeys.has(filtersKey)) { + filters = CATCH_ALL_FILTERS + filtersKey = CATCH_ALL_KEY + } + } + + // Reuse the existing subscription when the set of filters hasn't changed. + // While a swap is in flight the pending channel already targets the new + // filters, so compare against it to avoid firing the same swap twice. + const currentFiltersKey = entry.realtimePendingChannel + ? entry.realtimePendingFiltersKey + : entry.realtimeFiltersKey + if ( + (entry.realtimeChannel || entry.realtimePendingChannel) && + currentFiltersKey === filtersKey + ) { + return + } + + subscribeToChanges( + queryClient, + entry, + tableName, + entry.collectionRef, + filters, + filtersKey, + ) +} diff --git a/packages/supabase-db-collection/src/request-headers.ts b/packages/supabase-db-collection/src/request-headers.ts new file mode 100644 index 0000000000..12ad8261fa --- /dev/null +++ b/packages/supabase-db-collection/src/request-headers.ts @@ -0,0 +1,13 @@ +import { VERSION } from './version' + +/** + * Header name and value used to identify requests made by this library. + * + * Chain `.setHeader(CLIENT_INFO_HEADER, CLIENT_INFO)` onto a + * `PostgrestQueryBuilder`/`PostgrestFilterBuilder` call to stamp the + * request. This intentionally REPLACES supabase-js's own default + * `X-Client-Info` header (matching the `@supabase/ssr` convention) rather + * than appending to or preserving it. + */ +export const CLIENT_INFO_HEADER = 'X-Client-Info' +export const CLIENT_INFO = `@tanstack/supabase-db-collection/${VERSION}` diff --git a/packages/supabase-db-collection/src/row-conversions.ts b/packages/supabase-db-collection/src/row-conversions.ts new file mode 100644 index 0000000000..1ff1c9c338 --- /dev/null +++ b/packages/supabase-db-collection/src/row-conversions.ts @@ -0,0 +1,70 @@ +/** + * Per-column conversions from a row as it travels over the wire to the row the + * collection holds (`parse`), and back (`serialize`). + * + * PostgREST and Realtime speak JSON, so a `timestamptz` arrives as a string and + * a `bigint` as a number (and `JSON.stringify` throws on a `bigint` request + * body). A collection whose schema declares `Date` or `bigint` columns needs + * these conversions to read and write them. + * + * A conversion is only called for a column that is present in the row and not + * `null` / `undefined`; those pass through unchanged, so nullable columns need + * no special handling. + */ +export interface RowConversions { + /** + * Converts a column value received from PostgREST or Realtime into the value + * the collection holds. Applies to rows read, rows returned by inserts and + * updates, and Realtime payloads. + */ + parse?: { + [K in keyof TItem]?: (value: unknown) => NonNullable + } + /** + * Converts a column value held by the collection into the JSON-serializable + * value sent to PostgREST in insert and update bodies, and used to match rows + * by key on update and delete. + */ + serialize?: { + [K in keyof TItem]?: (value: NonNullable) => unknown + } +} + +/** + * The conversions with their per-column types erased. The caller's options are + * type-checked against {@link RowConversions}; the plumbing that applies them + * only needs to look a column up by name. + */ +export interface ColumnConversions { + parse?: Record unknown> + serialize?: Record unknown> +} + +const convertRow = ( + row: object, + conversions: Record unknown> | undefined, +): Record => { + if (!conversions) { + return { ...row } + } + const converted: Record = { ...row } + for (const [column, convert] of Object.entries(conversions)) { + const value = converted[column] + if (value != null) { + converted[column] = convert(value as never) + } + } + return converted +} + +/** Applies `parse` to a row received from the server. */ +export const parseRow = ( + row: object, + { parse }: ColumnConversions, +): TItem => convertRow(row, parse) as TItem + +/** Applies `serialize` to a row about to be sent to the server. */ +export const serializeRow = ( + row: object, + { serialize }: ColumnConversions, +): Record => convertRow(row, serialize) diff --git a/packages/supabase-db-collection/src/serialize.ts b/packages/supabase-db-collection/src/serialize.ts new file mode 100644 index 0000000000..da076724b8 --- /dev/null +++ b/packages/supabase-db-collection/src/serialize.ts @@ -0,0 +1,8 @@ +// The expression shape the PostgREST filter renderer understands. TanStack DB's +// `IR.BasicExpression` is structurally assignable to it, so `loadSubset` where +// clauses can be rendered without converting them first. +export type SerializedExpression = + | { type: `ref`; path: Array } + | { type: `val`; value: unknown } + | { type: `func`; name: string; args: Array } + | { type: `agg`; name: string; args: Array } diff --git a/packages/supabase-db-collection/src/version.ts b/packages/supabase-db-collection/src/version.ts new file mode 100644 index 0000000000..f26fbe5d0c --- /dev/null +++ b/packages/supabase-db-collection/src/version.ts @@ -0,0 +1,4 @@ +// Injected at build time by the `define` in vite.config.ts from package.json. +declare const __SUPABASE_DB_COLLECTION_VERSION__: string + +export const VERSION: string = __SUPABASE_DB_COLLECTION_VERSION__ diff --git a/packages/supabase-db-collection/tests/index.test.ts b/packages/supabase-db-collection/tests/index.test.ts new file mode 100644 index 0000000000..94ecb0a107 --- /dev/null +++ b/packages/supabase-db-collection/tests/index.test.ts @@ -0,0 +1,1034 @@ +import { + add, + and, + avg, + coalesce, + concat, + count, + eq, + groupKeys, + gt, + gte, + ilike, + inArray, + include, + isNull, + isUndefined, + length, + like, + lower, + lt, + lte, + max, + min, + not, + or, + sum, + upper, +} from '@tanstack/db' +import { afterEach, beforeEach, describe, test } from 'vitest' +import { + createMockFetch, + createMockedTodosCollection, + createMockedUsersCollection, + createMockedUsersTodosCollection, + expectFetchUrls, + queryResult, +} from './test.utils' + +describe('PostgREST query generation', () => { + let mockFetch: ReturnType + let usersCollection: ReturnType + let utCollection: ReturnType + let todosCollection: ReturnType + + beforeEach(() => { + mockFetch = createMockFetch() + usersCollection = createMockedUsersCollection(mockFetch) + utCollection = createMockedUsersTodosCollection(mockFetch) + todosCollection = createMockedTodosCollection(mockFetch) + }) + + afterEach(() => { + usersCollection.cleanup() + utCollection.cleanup() + todosCollection.cleanup() + }) + + describe('FROM', () => { + test('SELECT * FROM users', async () => { + await queryResult((q) => q.from({ user: usersCollection })) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('WHERE', () => { + test('WHERE active = true', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&active=eq.true']) + }) + + test('WHERE id = 1', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => eq(user.id, 1)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=eq.1']) + }) + + test("WHERE name = 'John'", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.name, 'John')), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&name=eq.John']) + }) + + test('WHERE id > 5 (gt)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => gt(user.id, 5)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=gt.5']) + }) + + test('WHERE id >= 5 (gte)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => gte(user.id, 5)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=gte.5']) + }) + + test('WHERE id < 10 (lt)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => lt(user.id, 10)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=lt.10']) + }) + + test('WHERE id <= 10 (lte)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => lte(user.id, 10)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=lte.10']) + }) + + test('WHERE id IN (1, 2, 3)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => inArray(user.id, [1, 2, 3])), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=in.(1,2,3)']) + }) + + test('merges IN filters for the same column', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and(inArray(user.id, [1, 2]), inArray(user.id, [2, 3])), + ), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=in.(1,2,3)']) + }) + + test('keeps IN filters for different columns separate', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and(inArray(user.id, [1, 2]), inArray(user.name, ['Alice', 'Bob'])), + ), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&id=in.(1,2)&name=in.(Alice,Bob)', + ]) + }) + + test('WHERE NOT(active = false)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(eq(user.active, false))), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=not.eq.false', + ]) + }) + + test('WHERE NOT(id > 5)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(gt(user.id, 5))), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=not.gt.5']) + }) + + test('WHERE NOT(id IN (1, 2))', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(inArray(user.id, [1, 2]))), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=not.in.(1,2)']) + }) + + test('a Date value is sent as ISO 8601', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + gt(user.name, new Date('2026-01-02T03:04:05.000Z') as never), + ), + ) + // `Date.toString()` would produce something Postgres cannot cast. + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=gt.2026-01-02T03:04:05.000Z', + ]) + }) + + test('Date values inside an IN filter are sent as ISO 8601', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + inArray(user.name, [ + new Date('2026-01-02T03:04:05.000Z'), + new Date('2026-02-03T04:05:06.000Z'), + ] as never), + ), + ) + // Each list member goes through quoteValue, which must also render Dates + // as ISO 8601 rather than the locale string from `Date.toString()`. + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=in.(2026-01-02T03:04:05.000Z,2026-02-03T04:05:06.000Z)', + ]) + }) + + test('WHERE name IS NULL', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => isNull(user.name)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&name=is.null']) + }) + + test('WHERE NOT(name IS NULL)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(isNull(user.name))), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&name=not.is.null']) + }) + + test('a negated condition does not reuse the cache entry of the plain one', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).where(({ user }) => gt(user.id, 5)), + ) + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => not(gt(user.id, 5))), + ) + // Two fetches, not one: a shared query key would have served the second + // query the first query's rows. + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&id=gt.5', + '/rest/v1/users?select=*&id=not.gt.5', + ]) + }) + + test('AND: active = true AND id > 5', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => and(eq(user.active, true), gt(user.id, 5))), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&id=gt.5', + ]) + }) + + test('chained .where', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .where(({ user }) => gt(user.id, 5)), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&id=gt.5', + ]) + }) + + test("WHERE name LIKE '%Ali%'", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => like(user.name, '%Ali%')), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=like.%25Ali%25', + ]) + }) + + test("WHERE name ILIKE '%ali%'", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => ilike(user.name, '%ali%')), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=ilike.%25ali%25', + ]) + }) + + test('OR: active = true OR id = 1', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => or(eq(user.active, true), eq(user.id, 1))), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&or=(active.eq.true,id.eq.1)', + ]) + }) + + test('OR of ILIKEs (search across columns)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, '%ali%'), ilike(user.email, '%ali%')), + ), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&or=(name.ilike.%25ali%25,email.ilike.%25ali%25)', + ]) + }) + + test("nested AND/OR: active = true AND (id > 5 OR name = 'admin')", async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and( + eq(user.active, true), + or(gt(user.id, 5), eq(user.name, 'admin')), + ), + ), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&or=(id.gt.5,name.eq.admin)', + ]) + }) + + test('nested AND/OR of ILIKEs', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and( + eq(user.active, true), + or(ilike(user.name, '%ali%'), ilike(user.email, '%ali%')), + ), + ), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&or=(name.ilike.%25ali%25,email.ilike.%25ali%25)', + ]) + }) + + test('value containing a comma is quoted', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(ilike(user.name, '%a,b%'), eq(user.email, 'x,y')), + ), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&or=(name.ilike."%25a,b%25",email.eq."x,y")', + ]) + }) + + test('IN list quotes members containing reserved characters', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => inArray(user.name, ['plain', 'a,b', 'c(d)'])), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&name=in.(plain,"a,b","c(d)")', + ]) + }) + + test('AND drops only the unpushable conjunct', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + and(eq(user.active, true), eq(upper(user.name), 'ALICE')), + ), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&active=eq.true']) + }) + + test('OR containing an unpushable branch is dropped entirely', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => + or(eq(user.active, true), eq(upper(user.name), 'ALICE')), + ), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('SELECT columns (client-side)', () => { + test('subset of fields', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ id: user.id, name: user.name })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('field renaming', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ userId: user.id, fullName: user.name })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('computed boolean with eq', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + isActive: eq(user.active, true), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('upper(name)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + upperName: upper(user.name), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('lower(email)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + lowerEmail: lower(user.email), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test("concat(name, ' ', email)", async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + nameAndEmail: concat(user.name, ' ', user.email), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('length(name)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + nameLength: length(user.name), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('add(id, 1)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + idPlusOne: add(user.id, 1), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test("coalesce(name, 'Unknown')", async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + displayName: coalesce(user.name, 'Unknown'), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('spread + computed field', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + ...user, + highId: gt(user.id, 5), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('Aggregate functions (client-side)', () => { + test('count(id)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ totalUsers: count(user.id) })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('sum(id)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ sumId: sum(user.id) })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('avg(id)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ avgId: avg(user.id) })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('min(id)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ minId: min(user.id) })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('max(id)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ maxId: max(user.id) })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('multiple aggregates', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + totalUsers: count(user.id), + maxId: max(user.id), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('order by $selected/computed field', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ + id: user.id, + name: user.name, + nameLength: length(user.name), + })) + .orderBy(({ $selected }) => $selected.nameLength, 'desc'), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('GROUP BY (client-side)', () => { + test('single column groupBy', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .groupBy(groupKeys(({ user }) => user.active)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('single column GROUP BY with WHERE', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .groupBy(groupKeys(({ user }) => user.active)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?active=eq.true&select=*']) + }) + + test('multiple columns GROUP BY', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .groupBy(groupKeys(({ user }) => [user.active, user.name])), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('GROUP BY + aggregates', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .groupBy(({ user }) => user.active) + .select(({ user }) => ({ + active: user.active, + userCount: count(user.id), + })), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?active=eq.true&select=*']) + }) + }) + + describe('HAVING (client-side)', () => { + test('HAVING with direct aggregate', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .groupBy(({ user }) => user.active) + .select(({ user }) => ({ + active: user.active, + totalId: sum(user.id), + })) + .having(({ user }) => gt(sum(user.id), 10)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('HAVING with $selected fields', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .groupBy(({ user }) => user.active) + .select(({ user }) => ({ + active: user.active, + totalCount: count(user.id), + })) + .having(({ $selected }) => gt($selected.totalCount, 5)), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('ORDER BY', () => { + test('default (asc)', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).orderBy(({ user }) => user.name), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.asc']) + }) + + test('explicit asc', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, 'asc'), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.asc']) + }) + + test('descending', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, 'desc'), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.desc']) + }) + + test('multi-column orderBy', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, 'asc') + .orderBy(({ user }) => user.id, 'desc'), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=name.asc,id.desc', + ]) + }) + }) + + describe('LIMIT', () => { + test.todo('basic limit', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.id) + .limit(10), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=id.asc&limit=10', + ]) + }) + }) + + describe('LIMIT + OFFSET', () => { + test.todo('pagination (page 2)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.id) + .limit(20) + .offset(20), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=id.asc&limit=20&offset=20', + ]) + }) + }) + + describe('Subquery in FROM', () => { + test('filtered query as source pushes filter down', async () => { + await queryResult((q) => { + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + return q.from({ activeUser: activeUsers }) + }) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&active=eq.true']) + }) + }) + + describe('findOne', () => { + test.todo('findOne with where pushes filter and limit', async () => { + // findOne doesn't use limit=1 in Tanstack DB + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.id, 1)) + .findOne(), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&id=eq.1&limit=1']) + }) + }) + + describe('DISTINCT (client-side)', () => { + test('single column distinct', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ name: user.name })) + .distinct(), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('multi column distinct', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .select(({ user }) => ({ name: user.name, active: user.active })) + .distinct(), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + + test('distinct with where clause', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .select(({ user }) => ({ name: user.name })) + .distinct(), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?active=eq.true&select=*']) + }) + }) + + describe('Combined queries', () => { + test.todo('WHERE + ORDER BY + LIMIT', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.name, 'asc') + .limit(10), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&order=name.asc&limit=10', + ]) + }) + + test('WHERE + ORDER BY', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.active, true)) + .orderBy(({ user }) => user.name, 'desc'), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&active=eq.true&order=name.desc', + ]) + }) + + test('WHERE + ORDER BY + LIMIT', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .where(({ user }) => gt(user.id, 3)) + .orderBy(({ user }) => user.id) + .limit(5), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&id=gt.3&order=id.asc&limit=5', + ]) + }) + + test('GROUP BY + HAVING + ORDER BY($selected) + LIMIT (client-side)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .groupBy(({ user }) => user.active) + .select(({ user }) => ({ + active: user.active, + userCount: count(user.id), + maxId: max(user.id), + })) + .having(({ $selected }) => gt($selected.userCount, 0)) + .orderBy(({ $selected }) => $selected.userCount, 'desc') + .limit(10), + ) + expectFetchUrls(mockFetch, ['/rest/v1/users?select=*']) + }) + }) + + describe('JOIN', () => { + test('basic inner join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .innerJoin({ ut: utCollection }, ({ user, ut }) => + eq(user.id, ut.user_id), + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users_todos?select=*', + '/rest/v1/users?id=in.(user_1)&select=*', + ]) + }) + + test('(left) join with select', async () => { + await queryResult((q) => + q.from({ user: usersCollection }).select(({ user }) => ({ + id: user.id, + todos: include( + q + .from({ ut: utCollection }) + .where(({ ut }) => eq(ut.user_id, user.id)), + ), + })), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + ]) + }) + + test('explicit inner join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .join( + { ut: utCollection }, + ({ user, ut }) => eq(user.id, ut.user_id), + 'inner', + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users_todos?select=*', + '/rest/v1/users?id=in.(user_1)&select=*', + ]) + }) + + test('left join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .leftJoin({ ut: utCollection }, ({ user, ut }) => + eq(user.id, ut.user_id), + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + ]) + }) + + test('right join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .rightJoin({ ut: utCollection }, ({ user, ut }) => + eq(user.id, ut.user_id), + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users_todos?select=*', + '/rest/v1/users?id=in.(user_1)&select=*', + ]) + }) + + test('full join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .fullJoin({ ut: utCollection }, ({ user, ut }) => + eq(user.id, ut.user_id), + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*', + ]) + }) + + test('two chained joins (users -> users_todos -> todos)', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .join({ ut: utCollection }, ({ user, ut }) => eq(user.id, ut.user_id)) + .join({ todo: todosCollection }, ({ ut, todo }) => + eq(ut.todo_id, todo.id), + ), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + '/rest/v1/todos?id=in.(todo_1)&select=*', + ]) + }) + + test('JOIN + WHERE + SELECT', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .join({ ut: utCollection }, ({ user, ut }) => eq(user.id, ut.user_id)) + .join({ todo: todosCollection }, ({ ut, todo }) => + eq(ut.todo_id, todo.id), + ) + .where(({ todo }) => eq(todo.completed, false)) + .select(({ user, todo }) => ({ + userName: user.name, + todoTitle: todo.title, + })), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + '/rest/v1/todos?id=in.(todo_1)&select=*', + ]) + }) + + test('filtered subquery as join target', async () => { + await queryResult((q) => { + const activeUserTodos = q + .from({ ut: utCollection }) + .where(({ ut }) => gt(ut.todo_id, 0)) + return q + .from({ user: usersCollection }) + .join({ ut: activeUserTodos }, ({ user, ut }) => + eq(user.id, ut.user_id), + ) + }) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&todo_id=gt.0&user_id=in.(user_1)', + ]) + }) + + test('deduplicated subquery used in multiple joins', async () => { + await queryResult((q) => { + const userTodos = q.from({ ut: utCollection }) + return q + .from({ user: usersCollection }) + .join({ ut1: userTodos }, ({ user, ut1 }) => eq(user.id, ut1.user_id)) + .join({ ut2: userTodos }, ({ user, ut2 }) => eq(user.id, ut2.user_id)) + }) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + ]) + }) + + test('subquery with groupBy + aggregates joined to another query', async () => { + await queryResult((q) => { + const todoCountsByUser = q + .from({ ut: utCollection }) + .groupBy(({ ut }) => ut.user_id) + .select(({ ut }) => ({ + user_id: ut.user_id, + todoCount: count(ut.todo_id), + })) + return q + .from({ user: usersCollection }) + .join({ stats: todoCountsByUser }, ({ user, stats }) => + eq(user.id, stats.user_id), + ) + .select(({ user, stats }) => ({ + userName: user.name, + todoCount: stats.todoCount, + })) + }) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + ]) + }) + + test('find unmatched rows in left join', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .leftJoin({ ut: utCollection }, ({ user, ut }) => + eq(user.id, ut.user_id), + ) + .where(({ ut }) => isUndefined(ut)), + ) + + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*', + '/rest/v1/users_todos?select=*&user_id=in.(user_1)', + ]) + }) + }) +}) diff --git a/packages/supabase-db-collection/tests/pagination.test.ts b/packages/supabase-db-collection/tests/pagination.test.ts new file mode 100644 index 0000000000..dc4ffe5421 --- /dev/null +++ b/packages/supabase-db-collection/tests/pagination.test.ts @@ -0,0 +1,490 @@ +import { createClient } from '@supabase/supabase-js' +import { + IR, + createCollection, + createLiveQueryCollection, + createLiveQueryWindowController, +} from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' +import { supabaseQueryFn } from '../src/functions' +import { supabaseCollectionOptions } from '../src/index' +import { getSearches, makePaginatingFetch } from './pagination.utils' +import { SUPABASE_KEY, SUPABASE_URL } from './test.utils' + +// A live infinite query (`useLiveInfiniteQuery`) is, without React, a +// `createLiveQueryWindowController` over an ordered+limited live query. Growing +// the window makes core hand the adapter keyset `LoadSubsetOptions.cursor`s, +// which is the path these tests exercise end to end against a mocked PostgREST. +// +// NOTE — two kinds of `=eq.` reads appear below: +// 1. Boundary probe: core's `OrderedSourceLoader.loadBoundary()` issues +// `requestSnapshot({ where: eq(col, boundary) })` — where only, no order, +// no limit — to settle the peek row that decides `hasNextPage`. The adapter +// renders it `select=*&=eq.`. +// 2. `whereCurrent` tie read: the adapter's `supabaseQueryFn` emits it when +// `LoadSubsetOptions.cursor` is set, paired with the `gt`/`lt` keyset read. +// It is ordered and unlimited (`select=*&order=...&=eq.`) so every +// row tied at the boundary loads even when the tie class exceeds the page. +// In the window path these overlap: the boundary probe already pulls the full +// tie class before the `whereCurrent` read runs, so the tie class is fetched +// twice. The adapter must still emit `whereCurrent` — it honors the cursor +// contract and cannot assume the boundary probe ran (`loadBoundary` skips when +// the boundary value is unchanged, falls back to a full-source load for +// non-keyset orders, and never runs for `loadSubset({ cursor })` calls outside +// the window path). Collapsing the duplication is core's call, not the adapter's. + +const itemsSchema = z.object({ id: z.number(), rank: z.number() }) + +// rank has a tie class (20) spanning ids 2–4 that straddles the first page. +const FIXTURE = [ + { id: 1, rank: 10 }, + { id: 2, rank: 20 }, + { id: 3, rank: 20 }, + { id: 4, rank: 20 }, + { id: 5, rank: 30 }, + { id: 6, rank: 40 }, +] + +/** A Supabase-backed source collection over the paginating mock. */ +function makeCollection(mockFetch: ReturnType) { + return createCollection( + supabaseCollectionOptions({ + tableName: 'items', + keys: ['id'], + schema: itemsSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + }), + ) +} + +/** Wrap a live query in a window controller with an active subscriber. */ +function windowController( + live: ReturnType, + cleanups: Array<() => void>, + pageSize: number, +) { + const controller = createLiveQueryWindowController(live, { pageSize }) + // A subscriber activates the window lease so pages actually load. + const unsub = controller.subscribe(() => {}) + cleanups.push(() => { + unsub() + controller.dispose() + live.cleanup() + }) + return controller +} + +const ids = (snapshot: { data: ReadonlyArray }) => + snapshot.data.map((row) => (row as { id: number }).id) + +describe('live infinite query (windowed) pagination', () => { + let collection: ReturnType + let mockFetch: ReturnType + const cleanups: Array<() => void> = [] + + beforeEach(() => { + mockFetch = makePaginatingFetch(FIXTURE) + collection = makeCollection(mockFetch) + cleanups.push(() => { + collection.cleanup() + }) + }) + + afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) { + cleanup() + } + }) + + test('fetches a single page without a keyset cursor', async () => { + const live = createLiveQueryCollection((q) => + q + .from({ item: collection }) + .orderBy(({ item }) => item.id) + .limit(2), + ) + const controller = windowController(live, cleanups, 2) + + await controller.preload() + const snapshot = controller.getSnapshot() + // One committed page of `pageSize`, with more rows available. + expect(snapshot.pages.map((page) => page.map((row) => row.id))).toEqual([ + [1, 2], + ]) + expect(ids(snapshot)).toEqual([1, 2]) + expect(snapshot.hasNextPage).toBe(true) + + // The first page is a plain ordered window (one peek row past the page) + // plus core's boundary probe — no keyset cursor (`gt`) and no offset yet. + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc&limit=3', // window + 1 peek row + 'select=*&id=eq.3', // boundary probe (loadBoundary) settles the peek + ]) + }) + + test('grows the window with a keyset cursor and never an offset', async () => { + const live = createLiveQueryCollection((q) => + q + .from({ item: collection }) + .orderBy(({ item }) => item.id) + .limit(2), + ) + const controller = windowController(live, cleanups, 2) + + await controller.preload() + expect(ids(controller.getSnapshot())).toEqual([1, 2]) + + await controller.fetchNextPage() + // Page 1 rows are preserved and the result is a contiguous ordered prefix. + expect(ids(controller.getSnapshot())).toEqual([1, 2, 3, 4]) + + // The exact PostgREST reads the flow emits. The next page is a keyset + // request from the boundary (id 3): ordered, limited, and — crucially — + // with no offset re-skipping rows. No request carries an offset. + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc&limit=3', // page 1: window + 1 peek row + 'select=*&id=eq.3', // boundary probe (loadBoundary) settles the peek + 'select=*&order=id.asc&id=eq.3', // page 2: whereCurrent tie read + 'select=*&order=id.asc&limit=2&id=gt.3', // page 2: whereFrom keyset read + 'select=*&id=eq.5', // boundary probe for the next peek + ]) + }) + + test('fetches the full boundary tie class so no tied row is skipped', async () => { + const live = createLiveQueryCollection((q) => + q + .from({ item: collection }) + .orderBy(({ item }) => item.rank) + .limit(2), + ) + const controller = windowController(live, cleanups, 2) + + await controller.preload() + expect(ids(controller.getSnapshot())).toEqual([1, 2]) + + await controller.fetchNextPage() + const result = ids(controller.getSnapshot()) + // The whole rank-20 tie class (ids 2–4) is loaded even though id 4 sits + // beyond page 1's peek, so the window stays a contiguous ordered prefix + // with no gaps and no duplicates. + expect(result).toContain(4) + expect(result).toEqual([1, 2, 3, 4]) + expect(new Set(result).size).toBe(result.length) + + // Same boundary values (rank 20) exercise `whereCurrent`. This test locks + // the shape the adapter must emit: the tie read (3rd) keeps the order, + // filters to the boundary with `eq`, and has no limit; the keyset read (4th) + // advances strictly past the boundary with `gt`; neither carries an offset. + // (Here id 4 is in fact already loaded by the boundary probe — 2nd read, + // `rank=eq.20`, no order — which makes the `whereCurrent` read redundant in + // the window path; see the module note. The adapter emits it regardless + // because it honors the cursor contract.) + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=rank.asc&limit=3', // page 1: window + 1 peek row + 'select=*&rank=eq.20', // boundary probe (loadBoundary): full tie class + 'select=*&order=rank.asc&rank=eq.20', // page 2: whereCurrent tie read + 'select=*&order=rank.asc&limit=1&rank=gt.20', // page 2: whereFrom keyset read + 'select=*&rank=eq.30', // boundary probe for the next peek + ]) + }) +}) + +// A collection load with no windowing (a plain subscribe) +// issues a single `supabaseQueryFn` call that must return the FULL matching set, +// looping past PostgREST's `db-max-rows` cap instead of truncating at it. +describe('db-max-rows paging loop (supabaseQueryFn)', () => { + // Ordered fixture the capped mock pages through. + const ROWS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }] + + const ORDER_BY = [ + { + expression: new IR.PropRef(['id']), + compareOptions: { direction: 'asc' as const, nulls: 'last' as const }, + }, + ] + + const prefer = (call: Array): string => + new Headers((call[1] as RequestInit | undefined)?.headers).get('prefer') ?? + '' + + const run = ( + mockFetch: ReturnType, + loadSubsetOptions: Record, + signal: AbortSignal = new AbortController().signal, + ) => { + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + return supabaseQueryFn(supabase, 'items', { + client: new QueryClient(), + queryKey: ['items'], + signal, + meta: { loadSubsetOptions } as never, + }) + } + + test('pages through the whole set when it exceeds the server cap', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY }) + + // Every row returned exactly once, in order. + expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([ + 1, 2, 3, 4, 5, + ]) + // The first request has no artificial page-size limit — the cap sizes it — + // and each later page advances the offset by the rows received, capped by + // the rows still owed (the last page asks for just 1). + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc', + 'select=*&order=id.asc&offset=2&limit=2', + 'select=*&order=id.asc&offset=4&limit=1', + ]) + }) + + test('requests count=exact on the first page only', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + await run(mockFetch, { orderBy: ORDER_BY }) + + const prefers = mockFetch.mock.calls.map(prefer) + expect(prefers[0]).toContain('count=exact') + for (const p of prefers.slice(1)) { + expect(p).not.toContain('count=') + } + }) + + test('an exact multiple of the cap needs no trailing empty request', async () => { + const mockFetch = makePaginatingFetch(ROWS.slice(0, 4), { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY }) + + expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([ + 1, 2, 3, 4, + ]) + // Two requests, not three: the count tells the loop it is done at offset 4. + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc', + 'select=*&order=id.asc&offset=2&limit=2', + ]) + }) + + test('a caller offset counts against the total: no trailing empty request', async () => { + // Six rows, offset 2, cap 2: rows 3-4 then 5-6 — the count (6) minus the + // offset (2) tells the loop it is done after two requests, not three. + const SIX = [...ROWS, { id: 6 }] + const mockFetch = makePaginatingFetch(SIX, { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY, offset: 2 }) + + expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([ + 3, 4, 5, 6, + ]) + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc&offset=2', + 'select=*&order=id.asc&offset=4&limit=2', + ]) + }) + + test('a set within the cap is a single request', async () => { + const mockFetch = makePaginatingFetch(ROWS.slice(0, 2), { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY }) + + expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([1, 2]) + expect(getSearches(mockFetch)).toEqual(['select=*&order=id.asc']) + }) + + test('the loop owns limit: a caller limit above the cap still pages', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY, limit: 3 }) + + // Exactly the caller's 3 rows, fetched across cap-sized pages. + expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([1, 2, 3]) + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=id.asc&limit=3', + 'select=*&order=id.asc&limit=1&offset=2', + ]) + }) + + describe('rows tied on the requested order', () => { + type TiedRow = { id: number; rank: number } + + // Every request orders the tied rows differently, like Postgres may when a + // top-N sort runs with a different limit and offset. Only an order that + // also sorts by the unique `id` pins one position per row. + const makeTieShufflingFetch = (fixture: Array, cap: number) => { + let requestCount = 0 + return vi.fn().mockImplementation((input) => { + requestCount += 1 + const params = new URL(String(input)).searchParams + const columns = (params.get('order') ?? '') + .split(',') + .filter(Boolean) + .map((sort) => sort.split('.')[0] as keyof TiedRow) + const arrival = + requestCount % 2 === 0 ? [...fixture].reverse() : [...fixture] + const rows = arrival.sort((a, b) => { + for (const column of columns) { + if (a[column] !== b[column]) return a[column] - b[column] + } + return 0 + }) + const offset = Number(params.get('offset') ?? 0) + const page = rows.slice(offset, offset + cap) + return Promise.resolve( + new Response(JSON.stringify(page), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-range': `${offset}-${offset + page.length - 1}/${rows.length}`, + }, + }), + ) + }) + } + + const TIED: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 1 }, + { id: 3, rank: 1 }, + { id: 4, rank: 1 }, + { id: 5, rank: 2 }, + { id: 6, rank: 2 }, + ] + const RANK_ORDER = [ + { + expression: new IR.PropRef(['rank']), + compareOptions: { direction: 'asc' as const, nulls: 'last' as const }, + }, + ] + + const readTied = ( + mockFetch: ReturnType, + keys: Array, + loadSubsetOptions: Record = { orderBy: RANK_ORDER }, + ) => + supabaseQueryFn( + createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + 'items', + { + client: new QueryClient(), + queryKey: ['items'], + signal: new AbortController().signal, + meta: { loadSubsetOptions } as never, + }, + { keys }, + ) + + test('without key order the shuffled pages repeat and skip rows', async () => { + // Control for the model below: with no key to order by, the reads this + // mock serves are exactly the unstable ones the key order exists to fix. + const rows = await readTied(makeTieShufflingFetch(TIED, 2), []) + + expect(new Set(rows.map((row) => row.id)).size).toBeLessThan(TIED.length) + }) + + test('pages ordered by the key return each tied row exactly once', async () => { + const rows = await readTied(makeTieShufflingFetch(TIED, 2), ['id']) + + expect(rows.map((row) => row.id).sort()).toEqual([1, 2, 3, 4, 5, 6]) + }) + + test('the first page is read again under the key order when more pages follow', async () => { + const mockFetch = makeTieShufflingFetch(TIED, 2) + await readTied(mockFetch, ['id']) + + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=rank.asc', + 'select=*&order=rank.asc,id.asc&offset=0&limit=2', + 'select=*&order=rank.asc,id.asc&offset=2&limit=2', + 'select=*&order=rank.asc,id.asc&offset=4&limit=2', + ]) + }) + + test('an unordered read is paged in key order', async () => { + const mockFetch = makeTieShufflingFetch(TIED, 2) + const rows = await readTied(mockFetch, ['id'], {}) + + expect(rows.map((row) => row.id).sort()).toEqual([1, 2, 3, 4, 5, 6]) + expect(getSearches(mockFetch).slice(1)).toEqual([ + 'select=*&order=id.asc&offset=0&limit=2', + 'select=*&order=id.asc&offset=2&limit=2', + 'select=*&order=id.asc&offset=4&limit=2', + ]) + }) + + test('every key column missing from the order is appended', async () => { + const mockFetch = makeTieShufflingFetch(TIED, 2) + await readTied(mockFetch, ['rank', 'id']) + + // `rank` is already sorted by, so only `id` is added. + expect(getSearches(mockFetch)[1]).toBe( + 'select=*&order=rank.asc,id.asc&offset=0&limit=2', + ) + }) + + test('a set within one page is read once, as asked', async () => { + const mockFetch = makeTieShufflingFetch(TIED.slice(0, 2), 2) + await readTied(mockFetch, ['id']) + + expect(getSearches(mockFetch)).toEqual(['select=*&order=rank.asc']) + }) + }) + + test('limit: 0 returns an empty set with zero requests', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const rows = await run(mockFetch, { orderBy: ORDER_BY, limit: 0 }) + + expect(rows).toEqual([]) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('threads the abort signal to every request', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const signal = new AbortController().signal + await run(mockFetch, { orderBy: ORDER_BY }, signal) + + expect(mockFetch.mock.calls.length).toBeGreaterThan(1) + for (const call of mockFetch.mock.calls) { + expect((call[1] as RequestInit).signal).toBe(signal) + } + }) + + test('one failed page fails the whole load (no partial data)', async () => { + let calls = 0 + const failingFetch = vi.fn().mockImplementation(() => { + calls += 1 + if (calls === 1) { + return Promise.resolve( + new Response(JSON.stringify([{ id: 1 }, { id: 2 }]), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-range': '0-1/5', + }, + }), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ message: 'boom' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ) + }) + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: failingFetch }, + }) + + await expect( + supabaseQueryFn(supabase, 'items', { + client: new QueryClient(), + queryKey: ['items'], + signal: new AbortController().signal, + meta: { loadSubsetOptions: { orderBy: ORDER_BY } } as never, + }), + ).rejects.toBeDefined() + }) +}) diff --git a/packages/supabase-db-collection/tests/pagination.utils.ts b/packages/supabase-db-collection/tests/pagination.utils.ts new file mode 100644 index 0000000000..7626e540dd --- /dev/null +++ b/packages/supabase-db-collection/tests/pagination.utils.ts @@ -0,0 +1,123 @@ +import { vi } from 'vitest' + +type Row = Record + +const COMPARATORS: Record boolean> = { + gt: (a, b) => a > b, + gte: (a, b) => a >= b, + lt: (a, b) => a < b, + lte: (a, b) => a <= b, + eq: (a, b) => a === b, +} + +// Parses `in.(1,2,3)`'s value half (`(1,2,3)`) into its member list. Numeric +// ids are all the split-chunk tests need, so members are compared as numbers. +function parseInList(value: string): Array { + const inner = value.replace(/^\(/, '').replace(/\)$/, '') + return inner === '' ? [] : inner.split(',').map(Number) +} + +/** + * A mock `fetch` that serves a fixture like PostgREST would for the read paths + * the pagination adapter emits: single-column `order`, `limit`, `offset`, and + * `col=op.value` scalar filters (`gt`/`gte`/`lt`/`lte`/`eq`/`in`). Non-GET + * requests echo their body back so inserts/updates parse. + * + * `cap` simulates PostgREST's `db-max-rows`: no response ever returns more than + * `cap` rows, even when a larger `limit` is requested, while the Content-Range + * count still reports the true total — exactly the condition the paging loop + * exists to handle. + */ +export function makePaginatingFetch( + fixture: Array, + { cap }: { cap?: number } = {}, +) { + const columns = Object.keys(fixture[0] ?? {}) + return vi.fn().mockImplementation((input, init) => { + const method = init?.method ?? 'GET' + if (method !== 'GET') { + const body = init?.body ? JSON.parse(init.body as string) : {} + return Promise.resolve(json(body)) + } + + const params = new URL(String(input)).searchParams + let rows = [...fixture] + + for (const column of columns) { + for (const raw of params.getAll(column)) { + const [op = '', value = ''] = raw.split(/\.(.*)/s) + if (op === 'in') { + const members = new Set(parseInList(value)) + rows = rows.filter((r) => members.has(Number(r[column]))) + continue + } + const compare = COMPARATORS[op] + if (compare) { + const target = Number(value) + rows = rows.filter((r) => compare(Number(r[column]), target)) + } + } + } + + const order = params.get('order') + if (order) { + const [column = '', direction] = (order.split(',')[0] ?? '').split('.') + const sign = direction === 'desc' ? -1 : 1 + rows.sort((a, b) => (Number(a[column]) - Number(b[column])) * sign) + } + + // The total matching set before paging — what PostgREST reports in the + // Content-Range header when `count=exact` is requested. + const total = rows.length + const offset = params.get('offset') ? Number(params.get('offset')) : 0 + const limit = params.get('limit') + const requested = limit === null ? undefined : offset + Number(limit) + const upperBound = + cap === undefined + ? requested + : Math.min(requested ?? offset + cap, offset + cap) + rows = rows.slice(offset, upperBound) + const end = offset + rows.length - 1 + const contentRange = rows.length + ? `${offset}-${end}/${total}` + : `*/${total}` + return Promise.resolve(json(rows, contentRange)) + }) +} + +function json(body: unknown, contentRange?: string): Response { + const headers: Record = { + 'content-type': 'application/json', + } + if (contentRange !== undefined) { + headers['content-range'] = contentRange + } + return new Response(JSON.stringify(body), { status: 200, headers }) +} + +/** + * The decoded query strings of every GET the mock captured, in order, with the + * leading `?` stripped (e.g. `select=*&order=id.asc&limit=2&id=gt.3`). + */ +export function getSearches( + mockFetch: ReturnType, +): Array { + return mockFetch.mock.calls + .filter((call) => (call[1]?.method ?? 'GET') === 'GET') + .map((call) => + decodeURIComponent(new URL(String(call[0])).search).replace(/^\?/, ''), + ) +} + +/** + * The rendered (percent-encoded) URL of every GET the mock captured, in + * order — what actually crosses the wire, and so what a request-line budget + * is measured against. + */ +export function getRawUrls( + mockFetch: ReturnType, +): Array { + return mockFetch.mock.calls + .filter((call) => (call[1]?.method ?? 'GET') === 'GET') + .map((call) => String(call[0])) +} diff --git a/packages/supabase-db-collection/tests/postgrest-filters.test.ts b/packages/supabase-db-collection/tests/postgrest-filters.test.ts new file mode 100644 index 0000000000..ffe80b0491 --- /dev/null +++ b/packages/supabase-db-collection/tests/postgrest-filters.test.ts @@ -0,0 +1,513 @@ +import { + IR, + and, + eq, + gt, + gte, + ilike, + inArray, + isNull, + like, + lt, + lte, + not, + or, + upper, +} from '@tanstack/db' +import { describe, expect, test } from 'vitest' +import { subsetOptionsToQueryKey } from '../src/functions' +import { + cursorCurrentToSearch, + keyColumnsToSearch, + loadSubsetOptionsToSearch, + realtimeFiltersToSearch, + subsetParamsToSearch, +} from '../src/postgrest-filters' + +// A bare (unaliased) column ref, as the live subset loader receives them. +const col = (name: string) => new IR.PropRef([name]) + +const name = col('name') +const id = col('id') +const active = col('active') + +// A single orderBy clause, as LoadSubsetOptions carries them. +const sort = (ref: IR.PropRef, direction: 'asc' | 'desc') => ({ + expression: ref, + compareOptions: { direction, nulls: 'last' as const }, +}) + +// Each active query becomes one `filter` entry: its AND-ed conditions, +// comma-joined into Realtime's wire form. An empty result is the catch-all. +const realtimeEntries = (...where: Array>) => + realtimeFiltersToSearch(where).getAll('filter') + +describe('realtimeFiltersToSearch', () => { + test('builds decoded filter strings for supported comparisons', () => { + expect(realtimeEntries(eq(id, 1))).toEqual(['id=eq.1']) + // Shares the PostgREST renderer, so negated equality is `not.eq`, not `neq`. + expect(realtimeEntries(not(eq(active, false)))).toEqual([ + 'active=not.eq.false', + ]) + expect(realtimeEntries(isNull(name))).toEqual(['name=is.null']) + expect(realtimeEntries(inArray(id, [1, 2, 3]))).toEqual(['id=in.(1,2,3)']) + }) + + test('sorts and deduplicates AND conditions and active-query filters', () => { + const filter = and(gt(id, 5), eq(active, true), eq(active, true)) + const equivalent = and(eq(active, true), gt(id, 5)) + expect(realtimeEntries(filter, equivalent)).toEqual([ + 'active=eq.true,id=gt.5', + ]) + }) + + test('quotes reserved characters without URL-encoding the decoded value', () => { + expect(realtimeEntries(eq(name, 'Doe, Jane'))).toEqual([ + 'name=eq."Doe, Jane"', + ]) + }) + + // Shares PostgREST's quoteValue, so quoting matches the request path: reserved + // characters, surrounding whitespace, and the empty string are all quoted. + test.each([ + ['', '""'], + [' a ', '" a "'], + ['a"b\\c', '"a\\"b\\\\c"'], + ])('quotes value %j like the PostgREST request path', (value, quoted) => { + expect(realtimeEntries(eq(name, value))).toEqual([`name=eq.${quoted}`]) + // A single-member IN renders as an equality via the shared renderer, so the + // empty string collapses to `eq.""` rather than `in.("")`. + const inExpected = + value === '' ? `name=eq.${quoted}` : `name=in.(${quoted})` + expect(realtimeEntries(inArray(name, [value]))).toEqual([inExpected]) + }) + + test('serializes bigints and Dates', () => { + const when = new Date('2020-01-02T03:04:05.000Z') + expect(realtimeEntries(eq(id, 9_007_199_254_740_993n))).toEqual([ + 'id=eq.9007199254740993', + ]) + expect(realtimeEntries(eq(name, when))).toEqual([ + 'name=eq.2020-01-02T03:04:05.000Z', + ]) + // A non-finite number renders as `eq.Infinity`: the shared renderer no longer + // rejects it locally, so the server does (and the swap falls back to + // catch-all). Correctness is unaffected; only the fallback path differs. + expect(realtimeEntries(eq(id, Number.POSITIVE_INFINITY))).toEqual([ + 'id=eq.Infinity', + ]) + }) + + test('uses empty parameters as the catch-all for unsupported filters', () => { + expect(realtimeEntries()).toEqual([]) + expect(realtimeEntries(inArray(id, []))).toEqual([]) + expect(realtimeEntries(or(eq(id, 1), eq(id, 2)))).toEqual([]) + }) +}) + +// ── subsetParamsToSearch ──────────────────────────────────────────── +// Most tests here exercise the WHERE quoting/negation/IN/null/wildcard logic, +// which lives in a single internal translator shared by every builder; +// subsetParamsToSearch is its thinnest public wrapper. +describe('subsetParamsToSearch', () => { + const filters = (where: IR.BasicExpression) => + subsetParamsToSearch({ where }) + + test.each([ + [eq(id, 1), 'eq.1'], + [gt(id, 1), 'gt.1'], + [gte(id, 1), 'gte.1'], + [lt(id, 1), 'lt.1'], + [lte(id, 1), 'lte.1'], + [isNull(id), 'is.null'], + [inArray(id, [1, 2, 3]), 'in.(1,2,3)'], + ])('renders comparison operators: %s', (where, expected) => { + expect(filters(where).get('id')).toBe(expected) + }) + + test('leaves top-level scalar values raw (unquoted)', () => { + expect(filters(eq(name, 'a,b(c)')).get('name')).toBe('eq.a,b(c)') + }) + + test('preserves AND branches inside OR', () => { + expect( + filters(or(and(eq(active, true), gt(id, 1)), eq(name, 'Bob'))).get('or'), + ).toBe('(and(active.eq.true,id.gt.1),name.eq.Bob)') + }) + + test.each([and, or])('negates logical groups', (logical) => { + expect( + filters(not(logical(eq(active, true), eq(name, 'Bob')))).get('or'), + ).toBe( + logical === and + ? '(active.not.eq.true,name.not.eq.Bob)' + : '(and(active.not.eq.true,name.not.eq.Bob))', + ) + }) + + test('supports double negation of comparisons and logical groups', () => { + expect(filters(not(not(eq(name, 'Alice')))).get('name')).toBe('eq.Alice') + expect(filters(not(not(or(eq(name, 'Alice'), eq(id, 2))))).get('or')).toBe( + '(name.eq.Alice,id.eq.2)', + ) + }) + + test('preserves multiple OR parameters under AND', () => { + expect( + filters( + and(or(eq(id, 1), eq(id, 2)), or(eq(name, 'Alice'), eq(name, 'Bob'))), + ).getAll('or'), + ).toEqual(['(id.eq.1,id.eq.2)', '(name.eq.Alice,name.eq.Bob)']) + }) + + test.each([ + ['', '""'], + [' a ', '" a "'], + ['a,b(c)', '"a,b(c)"'], + ['a"b\\c', '"a\\"b\\\\c"'], + ['a.b:c&d=1+%', 'a.b:c&d=1+%'], + ])('quotes logical values and IN members: %j', (value, quoted) => { + expect(filters(or(eq(name, value), eq(id, 2))).get('or')).toBe( + `(name.eq.${quoted},id.eq.2)`, + ) + expect(filters(inArray(name, [value, value])).get('name')).toBe( + value === '' ? 'eq.' : `in.(${quoted})`, + ) + expect(filters(not(inArray(name, [value]))).get('name')).toBe( + value === '' ? 'not.eq.' : `not.in.(${quoted})`, + ) + }) + + test('renders negated comparisons inside OR', () => { + expect( + filters(or(not(isNull(name)), not(inArray(name, ['a,b', 'c'])))).get( + 'or', + ), + ).toBe('(name.not.is.null,name.not.in.("a,b",c))') + }) + + test('does not union IN constraints inside logical groups', () => { + expect( + filters( + or(and(inArray(id, [1, 2]), inArray(id, [2, 3])), eq(active, true)), + ).get('or'), + ).toBe('(and(id.in.(1,2),id.in.(2,3)),active.eq.true)') + }) + + test('renders empty IN lists with SQL null semantics', () => { + expect(filters(inArray(id, [])).get('id')).toBe('in.()') + expect(filters(not(inArray(id, []))).get('id')).toBe('not.is.null') + expect(filters(not(not(inArray(id, [])))).get('id')).toBe('in.()') + }) + + test('preserves null guards for empty IN inside a negated logical group', () => { + expect( + filters(not(and(inArray(name, []), eq(active, true)))).get('or'), + ).toBe('(name.not.is.null,active.not.eq.true)') + }) + + test('ignores null list members instead of matching literal null strings', () => { + expect(filters(inArray(name, ['Alice', null])).get('name')).toBe( + 'in.(Alice)', + ) + expect(filters(not(inArray(name, ['Alice', null]))).get('name')).toBe( + 'not.in.(Alice)', + ) + expect(filters(not(inArray(name, [null]))).get('name')).toBe('not.is.null') + }) + + test.each([like, ilike])( + 'preserves SQL wildcards and literal backslashes', + (match) => { + const op = match === like ? 'like' : 'ilike' + expect(filters(match(name, 'A_%')).get('name')).toBe(`${op}.A_%`) + expect(filters(not(match(name, 'a\\%'))).get('name')).toBe( + `not.${op}.a\\\\%`, + ) + }, + ) + + test.each([ + ['computed OR', or(eq(active, true), eq(upper(name), 'ALICE'))], + ['computed NOT AND', not(and(eq(active, true), eq(upper(name), 'ALICE')))], + ['literal asterisk', like(name, '*Ali*')], + ['negated literal asterisk', not(ilike(name, '*Ali*'))], + ])('drops unpushable %s, keeping the rest', (_, where) => { + expect([...filters(and(gt(id, 0), where))]).toEqual([['id', 'gt.0']]) + }) + + test('appends a single order direction', () => { + expect( + subsetParamsToSearch({ orderBy: [sort(id, 'asc')] }).get('order'), + ).toBe('id.asc') + expect( + subsetParamsToSearch({ orderBy: [sort(id, 'desc')] }).get('order'), + ).toBe('id.desc') + }) + + test('merges multiple order columns into one comma-joined value', () => { + const search = subsetParamsToSearch({ + orderBy: [sort(id, 'asc'), sort(name, 'desc')], + }) + expect(search.get('order')).toBe('id.asc,name.desc') + expect(search.getAll('order')).toHaveLength(1) + }) + + test('appends limit', () => { + expect(subsetParamsToSearch({ limit: 10 }).get('limit')).toBe('10') + }) + + test('merges duplicate top-level IN demands without mutating the input', () => { + const where = and(inArray(id, [1, 2]), inArray(id, [2, 3])) + const before = JSON.stringify(where) + expect(subsetParamsToSearch({ where }).getAll('id')).toEqual(['in.(1,2,3)']) + expect(JSON.stringify(where)).toBe(before) + }) + + test('excludes select, offset, and cursor (pagination/request details)', () => { + const search = subsetParamsToSearch({ + where: eq(id, 1), + limit: 5, + offset: 99, + cursor: { whereFrom: gt(id, 1), whereCurrent: eq(id, 1) }, + }) + expect(search.get('id')).toBe('eq.1') + expect(search.get('limit')).toBe('5') + expect(search.has('select')).toBe(false) + expect(search.has('offset')).toBe(false) + // The cursor's `gt(id, 1)` must not leak in as an id filter. + expect(search.getAll('id')).toEqual(['eq.1']) + }) +}) + +// ── loadSubsetOptionsToSearch ─────────────────────────────────────── +describe('loadSubsetOptionsToSearch', () => { + test('always requests all columns', () => { + expect(loadSubsetOptionsToSearch({}).get('select')).toBe('*') + }) + + test('empty options produce only select=*', () => { + expect([...loadSubsetOptionsToSearch({})]).toEqual([['select', '*']]) + }) + + test('includes where, order and limit from the subset params', () => { + const search = loadSubsetOptionsToSearch({ + where: eq(id, 1), + orderBy: [sort(id, 'asc')], + limit: 5, + }) + expect(search.get('id')).toBe('eq.1') + expect(search.get('order')).toBe('id.asc') + expect(search.get('limit')).toBe('5') + }) + + test('appends offset independently of limit', () => { + const search = loadSubsetOptionsToSearch({ offset: 20 }) + expect(search.get('offset')).toBe('20') + expect(search.has('limit')).toBe(false) + }) + + test('appends cursor filters: quotes IN members, leaves scalars raw', () => { + const search = loadSubsetOptionsToSearch({ + cursor: { + whereFrom: and(inArray(name, ['a,b', 'c']), gt(id, 5)), + whereCurrent: eq(id, 5), + }, + }) + expect(search.get('name')).toBe('in.("a,b",c)') + expect(search.get('id')).toBe('gt.5') + }) + + test('honours the cursor and skips offset when both are present', () => { + const search = loadSubsetOptionsToSearch({ + orderBy: [sort(id, 'asc')], + limit: 20, + offset: 20, + cursor: { whereFrom: gt(id, 40), whereCurrent: eq(id, 40) }, + }) + expect(search.get('id')).toBe('gt.40') + expect(search.get('limit')).toBe('20') + expect(search.has('offset')).toBe(false) + }) + + test('renders a composite multi-column cursor without throwing', () => { + const a = col('a') + const b = col('b') + const build = () => + loadSubsetOptionsToSearch({ + orderBy: [sort(a, 'asc'), sort(b, 'asc')], + limit: 20, + cursor: { + whereFrom: or(gt(a, 1), and(eq(a, 1), gt(b, 2))), + whereCurrent: eq(a, 1), + }, + }) + expect(build).not.toThrow() + expect(build().get('or')).toBe('(a.gt.1,and(a.eq.1,b.gt.2))') + }) + + test('serialises a Date cursor boundary as ISO 8601', () => { + const createdAt = col('created_at') + const boundary = new Date('2024-01-01T00:00:00.000Z') + const search = loadSubsetOptionsToSearch({ + orderBy: [sort(createdAt, 'asc')], + limit: 20, + cursor: { + whereFrom: gt(createdAt, boundary), + whereCurrent: eq(createdAt, boundary), + }, + }) + expect(search.get('created_at')).toBe('gt.2024-01-01T00:00:00.000Z') + }) +}) + +// ── cursorCurrentToSearch ─────────────────────────────────────────── +describe('cursorCurrentToSearch', () => { + test('returns null without a cursor', () => { + expect(cursorCurrentToSearch({ where: eq(id, 1) })).toBeNull() + }) + + test('renders where + order + whereCurrent, never limit', () => { + const search = cursorCurrentToSearch({ + where: eq(active, true), + orderBy: [sort(id, 'asc')], + limit: 20, + cursor: { whereFrom: gt(id, 40), whereCurrent: eq(id, 40) }, + }) + expect(search?.get('active')).toBe('eq.true') + expect(search?.get('order')).toBe('id.asc') + expect(search?.get('id')).toBe('eq.40') + expect(search?.has('limit')).toBe(false) + }) +}) + +// ── keyColumnsToSearch ────────────────────────────────────────────── +describe('keyColumnsToSearch', () => { + test('serializes Date keys as ISO timestamps', () => { + const timestamp = new Date('2024-01-01T12:30:00.123Z') + const search = keyColumnsToSearch(['recorded_at'], { + recorded_at: timestamp, + }) + + expect(search.get('recorded_at')).toBe('eq.2024-01-01T12:30:00.123Z') + }) + + test('serializes Date values in composite keys without changing other keys', () => { + const search = keyColumnsToSearch(['sensor_id', 'recorded_at'], { + sensor_id: 'east,1', + recorded_at: new Date('2024-01-01T14:30:00.123+02:00'), + }) + + expect([...search]).toEqual([ + ['sensor_id', 'eq.east,1'], + ['recorded_at', 'eq.2024-01-01T12:30:00.123Z'], + ]) + }) + + test('builds an eq filter for a single key column', () => { + expect(keyColumnsToSearch(['id'], { id: 1, name: 'x' }).get('id')).toBe( + 'eq.1', + ) + }) + + test('builds one eq filter per composite key, preserving order', () => { + const search = keyColumnsToSearch(['user_id', 'todo_id'], { + user_id: 1, + todo_id: 2, + title: 'ignored', + }) + expect(search.get('user_id')).toBe('eq.1') + expect(search.get('todo_id')).toBe('eq.2') + expect([...search.keys()]).toEqual(['user_id', 'todo_id']) + }) +}) + +// ── subsetOptionsToQueryKey ───────────────────────────────────────── +describe('subsetOptionsToQueryKey', () => { + const key = (where: IR.BasicExpression) => + subsetOptionsToQueryKey('users', { where }) + + test('collapses to [tableName] when nothing is pushable', () => { + expect(key(or(eq(name, 'Alice'), eq(upper(name), 'BOB')))).toEqual([ + 'users', + ]) + }) + + test('literal query delimiters cannot collide with extra filters', () => { + expect(key(eq(name, 'Alice&active=eq.true'))).not.toEqual( + key(and(eq(name, 'Alice'), eq(active, true))), + ) + }) + + test('distinguishes NOT, OR branches, and IN list boundaries', () => { + expect(key(eq(name, 'Alice'))).not.toEqual(key(not(eq(name, 'Alice')))) + expect(key(or(eq(name, 'Alice'), eq(active, true)))).not.toEqual( + key(or(eq(name, 'Bob'), eq(active, true))), + ) + expect(key(inArray(name, ['a,b', 'c']))).not.toEqual( + key(inArray(name, ['a', 'b,c'])), + ) + }) + + test('uses the merged IN request', () => { + expect( + key(and(inArray(name, ['a', 'b']), inArray(name, ['b', 'c']))), + ).toEqual(key(inArray(name, ['a', 'b', 'c']))) + }) + + test('gives distinct cursor windows of one subset distinct keys', () => { + const base = { orderBy: [sort(id, 'asc')], limit: 20 } + const page2 = subsetOptionsToQueryKey('users', { + ...base, + cursor: { whereFrom: gt(id, 20), whereCurrent: eq(id, 20) }, + }) + const page3 = subsetOptionsToQueryKey('users', { + ...base, + cursor: { whereFrom: gt(id, 40), whereCurrent: eq(id, 40) }, + }) + expect(page2).not.toEqual(page3) + // The prefix stays `[tableName]` so realtime keeps matching every window. + expect(page2[0]).toBe('users') + expect(page3[0]).toBe('users') + }) + + test('gives distinct offset windows of one subset distinct keys', () => { + const base = { orderBy: [sort(id, 'asc')], limit: 20 } + expect( + subsetOptionsToQueryKey('users', { ...base, offset: 20 }), + ).not.toEqual(subsetOptionsToQueryKey('users', { ...base, offset: 40 })) + }) + + test('gives distinct multi-column cursor windows distinct keys', () => { + const a = col('a') + const b = col('b') + const base = { orderBy: [sort(a, 'asc'), sort(b, 'asc')], limit: 20 } + const page2 = subsetOptionsToQueryKey('users', { + ...base, + cursor: { + whereFrom: or(gt(a, 1), and(eq(a, 1), gt(b, 2))), + whereCurrent: eq(a, 1), + }, + }) + const page3 = subsetOptionsToQueryKey('users', { + ...base, + cursor: { + whereFrom: or(gt(a, 3), and(eq(a, 3), gt(b, 4))), + whereCurrent: eq(a, 3), + }, + }) + expect(page2).not.toEqual(page3) + expect(page2[0]).toBe('users') + expect(page3[0]).toBe('users') + }) + + test('keeps limit: 0 distinct from an unlimited subset', () => { + const zero = subsetOptionsToQueryKey('users', { + where: eq(id, 1), + limit: 0, + }) + const unlimited = subsetOptionsToQueryKey('users', { where: eq(id, 1) }) + expect(zero).not.toEqual(unlimited) + expect(new URLSearchParams(zero[1]).get('limit')).toBe('0') + }) +}) diff --git a/packages/supabase-db-collection/tests/postgrest-request.test.ts b/packages/supabase-db-collection/tests/postgrest-request.test.ts new file mode 100644 index 0000000000..6a538b5038 --- /dev/null +++ b/packages/supabase-db-collection/tests/postgrest-request.test.ts @@ -0,0 +1,122 @@ +import { PostgrestError } from '@supabase/postgrest-js' +import { createClient } from '@supabase/supabase-js' +import { IR, and, eq, gt } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { describe, expect, test, vi } from 'vitest' +import { subsetOptionsToQueryKey, supabaseQueryFn } from '../src/functions' +import { postgrestRequest } from '../src/postgrest-request' +import { SUPABASE_KEY, SUPABASE_URL, createMockFetch } from './test.utils' + +describe('request-state escape hatch', () => { + // supabase-js changing these fields would silently drop auth; fail loudly. + test('supabase.from(table) exposes url, headers, and fetch', () => { + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY) + const qb = supabase.from('users') + expect(qb.url).toBeInstanceOf(URL) + expect(qb.url.toString()).toBe(`${SUPABASE_URL}/rest/v1/users`) + expect(qb.headers).toBeInstanceOf(Headers) + expect(typeof qb.fetch).toBe('function') + }) +}) + +describe('query key matches request URL', () => { + test('the cache key encodes the same filters as the sent request', async () => { + const name = new IR.PropRef(['name']) + const id = new IR.PropRef(['id']) + const where = and(eq(name, 'Alice&x=1'), gt(id, 3)) + + const [, keyString] = subsetOptionsToQueryKey('users', { where }) + + const mockFetch = createMockFetch() + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + await supabaseQueryFn(supabase, 'users', { + client: new QueryClient(), + queryKey: ['users'], + signal: new AbortController().signal, + meta: { loadSubsetOptions: { where } }, + }) + const sent = new URL(String(mockFetch.mock.calls[0]?.[0])).searchParams + sent.delete('select') + // Every filter param in the key is present verbatim in the request. + for (const [key, value] of new URLSearchParams(keyString)) { + expect(sent.getAll(key)).toContain(value) + } + }) +}) + +describe('cursor pagination fetches boundary ties', () => { + test('a cursor load issues an unlimited tie request and a limited keyset request', async () => { + const id = new IR.PropRef(['id']) + const mockFetch = createMockFetch() + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + + await supabaseQueryFn(supabase, 'users', { + client: new QueryClient(), + queryKey: ['users'], + signal: new AbortController().signal, + meta: { + loadSubsetOptions: { + orderBy: [ + { + expression: id, + compareOptions: { direction: 'asc', nulls: 'last' }, + }, + ], + limit: 20, + cursor: { whereFrom: gt(id, 40), whereCurrent: eq(id, 40) }, + }, + }, + }) + + expect(mockFetch).toHaveBeenCalledTimes(2) + const urls = mockFetch.mock.calls.map( + (call: Array) => new URL(String(call[0])).searchParams, + ) + const ties = urls.find((s) => s.get('id') === 'eq.40') + const keyset = urls.find((s) => s.get('id') === 'gt.40') + + expect(ties?.has('limit')).toBe(false) + expect(keyset?.get('limit')).toBe('20') + // Cursor pins the window; offset must never ride alongside it. + expect(keyset?.has('offset')).toBe(false) + }) +}) + +describe('postgrest errors are thrown as Error instances', () => { + test('a failed request throws a PostgrestError with the message preserved', async () => { + const errorBody = { + message: 'permission denied for table users', + details: 'insufficient privilege', + hint: null, + code: '42501', + } + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(errorBody), { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ) + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + + const promise = postgrestRequest(supabase, 'users', { + method: 'GET', + search: new URLSearchParams(), + }) + + await expect(promise).rejects.toBeInstanceOf(PostgrestError) + await expect(promise).rejects.toMatchObject({ + message: 'permission denied for table users', + code: '42501', + }) + // The original bug: throwing a plain object stringified to "[object Object]". + await expect(promise).rejects.toSatisfy((e: unknown) => + String(e).includes('permission denied'), + ) + }) +}) diff --git a/packages/supabase-db-collection/tests/realtime.test.ts b/packages/supabase-db-collection/tests/realtime.test.ts new file mode 100644 index 0000000000..2ad6361798 --- /dev/null +++ b/packages/supabase-db-collection/tests/realtime.test.ts @@ -0,0 +1,787 @@ +import { + and, + createCollection, + eq, + gt, + gte, + inArray, + isNull, + liveQueryCollectionOptions, + lt, + lte, + not, +} from '@tanstack/db' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { realtimeFiltersToSearch } from '../src/postgrest-filters' +import { + createMockChannel, + createMockFetch, + createRealtimeUsersCollection, + emit, + filtersFor, + listenersFor, + nth, +} from './test.utils' +import type { Collection } from '@tanstack/db' +import type { MockChannel } from './test.utils' + +// Collections created during a test, torn down afterwards so their query +// observers are removed (and the realtime channel detached). +let createdCollections: Array> = [] + +function track>(collection: T): T { + createdCollections.push(collection) + return collection +} + +beforeEach(() => { + createdCollections = [] +}) + +afterEach(() => { + // Tear down in reverse creation order so dependent live queries are cleaned + // up before the source collections they depend on. + for (const collection of [...createdCollections].reverse()) { + collection.cleanup() + } +}) + +type QueryFn = Parameters[0]['query'] +type RealtimeCollection = ReturnType< + typeof createRealtimeUsersCollection +>['collection'] + +function startLiveQuery( + collection: RealtimeCollection, + buildQuery: (collection: RealtimeCollection) => QueryFn, +) { + const opts = liveQueryCollectionOptions({ query: buildQuery(collection) }) + return track( + createCollection(opts as Extract), + ) +} + +// Runs a live query against a fresh realtime users collection and waits for the +// realtime channel to be attached, then returns the recording mock channel. +async function captureChannel( + buildQuery: (collection: RealtimeCollection) => QueryFn, + // These tests exercise server-side filtering, which is opt-in on the + // collection (realtimeUseFilter defaults to false), so enable it by default + // here and let individual tests override it. + { realtimeUseFilter = true }: { realtimeUseFilter?: boolean } = {}, +) { + const mockFetch = createMockFetch() + const mockChannel = createMockChannel() + const { collection } = createRealtimeUsersCollection(mockFetch, mockChannel, { + realtimeUseFilter, + }) + track(collection) + + const live = startLiveQuery(collection, buildQuery) + + await live.preload() + await live.toArrayWhenReady() + await vi.waitFor(() => expect(mockChannel.on).toHaveBeenCalled()) + + return { mockChannel, collection } +} + +// The filters applied to INSERT listeners — the ones that decide which rows +// belong in the collection. +function insertFilters(mockChannel: MockChannel): Array { + return filtersFor(mockChannel, 'INSERT') +} + +describe('realtime filter propagation', () => { + describe('supported single-comparison filters', () => { + test('eq on a number column', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => eq(user.id, 1)), + ) + expect(insertFilters(mockChannel)).toEqual(['id=eq.1']) + }) + + test('eq on a boolean column', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => eq(user.active, true)), + ) + expect(insertFilters(mockChannel)).toEqual(['active=eq.true']) + }) + + test('gt', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => gt(user.id, 5)), + ) + expect(insertFilters(mockChannel)).toEqual(['id=gt.5']) + }) + + test('gte', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => gte(user.id, 5)), + ) + expect(insertFilters(mockChannel)).toEqual(['id=gte.5']) + }) + + test('lt', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => lt(user.id, 10)), + ) + expect(insertFilters(mockChannel)).toEqual(['id=lt.10']) + }) + + test('lte', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => lte(user.id, 10)), + ) + expect(insertFilters(mockChannel)).toEqual(['id=lte.10']) + }) + + test('inArray maps to in.(...)', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => inArray(user.id, [1, 2, 3])), + ) + expect(insertFilters(mockChannel)).toEqual(['id=in.(1,2,3)']) + }) + + test('not(eq) maps to not.eq', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => not(eq(user.active, false))), + ) + expect(insertFilters(mockChannel)).toEqual(['active=not.eq.false']) + }) + + test('isNull maps to is.null', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => isNull(user.name)), + ) + expect(insertFilters(mockChannel)).toEqual(['name=is.null']) + }) + + test('not(isNull) maps to not.is.null', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => not(isNull(user.name))), + ) + expect(insertFilters(mockChannel)).toEqual(['name=not.is.null']) + }) + + test('not(inArray) maps to not.in', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => not(inArray(user.id, [1, 2]))), + ) + expect(insertFilters(mockChannel)).toEqual(['id=not.in.(1,2)']) + }) + + test('not(gt) maps to not.gt', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => not(gt(user.id, 5))), + ) + expect(insertFilters(mockChannel)).toEqual(['id=not.gt.5']) + }) + + test('composite AND becomes a comma-separated filter', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => and(eq(user.active, true), gt(user.id, 5))), + ) + expect(insertFilters(mockChannel)).toEqual(['active=eq.true,id=gt.5']) + }) + }) + + describe('value serialization', () => { + test('a string containing a comma is PostgREST-quoted', async () => { + // Unquoted, the comma would read as a second ANDed condition. + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => eq(user.name, 'Doe, Jane')), + ) + expect(insertFilters(mockChannel)).toEqual(['name=eq."Doe, Jane"']) + }) + + test('a quote inside a value is escaped', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => eq(user.name, 'He said "hi", ok')), + ) + expect(insertFilters(mockChannel)).toEqual([ + 'name=eq."He said \\"hi\\", ok"', + ]) + }) + + test('a plain string is left unquoted', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => eq(user.name, 'Zed')), + ) + expect(insertFilters(mockChannel)).toEqual(['name=eq.Zed']) + }) + + test('an in list of exactly 100 values is still filtered', async () => { + const values = Array.from({ length: 100 }, (_, index) => index) + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => inArray(user.id, values)), + ) + expect(insertFilters(mockChannel)).toEqual([ + `id=in.(${values.join(',')})`, + ]) + }) + + test('an in list longer than 100 values falls back to a catch-all', async () => { + const values = Array.from({ length: 101 }, (_, index) => index) + const { mockChannel } = await captureChannel( + (collection) => (q) => + q + .from({ user: collection }) + .where(({ user }) => inArray(user.id, values)), + ) + expect(insertFilters(mockChannel)).toEqual([null]) + }) + }) + + describe('catch-all subscriptions (no filter)', () => { + test('no WHERE clause', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => q.from({ user: collection }), + ) + expect(insertFilters(mockChannel)).toEqual([null]) + }) + + test('no redundant unfiltered UPDATE listener is added', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => q.from({ user: collection }), + ) + expect(filtersFor(mockChannel, 'UPDATE')).toEqual([null]) + }) + + test('realtimeUseFilter: false subscribes catch-all despite a filterable WHERE', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => eq(user.id, 1)), + { realtimeUseFilter: false }, + ) + // The WHERE is representable as a Realtime filter, but filtering is off, + // so every listener subscribes to all changes and no redundant unfiltered + // UPDATE listener is added. + expect(insertFilters(mockChannel)).toEqual([null]) + expect(filtersFor(mockChannel, 'UPDATE')).toEqual([null]) + expect(filtersFor(mockChannel, 'DELETE')).toEqual([null]) + }) + + test('defaults to catch-all when realtimeUseFilter is omitted', async () => { + const mockFetch = createMockFetch() + const mockChannel = createMockChannel() + // No realtimeUseFilter option → the collection default (false) applies. + const { collection } = createRealtimeUsersCollection( + mockFetch, + mockChannel, + ) + track(collection) + + const live = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + await live.preload() + await live.toArrayWhenReady() + await vi.waitFor(() => expect(mockChannel.on).toHaveBeenCalled()) + + // A filterable WHERE is not pushed; every listener subscribes to all rows. + expect(insertFilters(mockChannel)).toEqual([null]) + expect(filtersFor(mockChannel, 'UPDATE')).toEqual([null]) + }) + }) + + describe('listener layout', () => { + test('filters apply to INSERT and UPDATE, never to DELETE', async () => { + const { mockChannel } = await captureChannel( + (collection) => (q) => + q.from({ user: collection }).where(({ user }) => eq(user.id, 1)), + ) + + expect(filtersFor(mockChannel, 'INSERT')).toEqual(['id=eq.1']) + // The filtered listener catches rows entering the window; the unfiltered + // one keeps rows already held up to date after they leave it. + expect(filtersFor(mockChannel, 'UPDATE')).toEqual(['id=eq.1', null]) + // Realtime only delivers filtered deletes with `replica identity full`. + expect(filtersFor(mockChannel, 'DELETE')).toEqual([null]) + }) + }) + + describe('payload routing into the collection', () => { + const row = { + id: 99, + name: 'Zed', + email: 'zed@test.com', + active: true, + } + + test('INSERT writes the new row', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }) + + expect(collection.has(collection.getKeyFromItem(row))).toBe(true) + }) + + test('INSERT for a row already held overwrites instead of throwing', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }) + expect(() => + emit(mockChannel, { + eventType: 'INSERT', + new: { ...row, name: 'Twice' }, + old: {}, + }), + ).not.toThrow() + + expect(collection.get(collection.getKeyFromItem(row))?.name).toBe('Twice') + }) + + test('UPDATE writes the updated row', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }) + emit(mockChannel, { + eventType: 'UPDATE', + new: { ...row, name: 'Updated' }, + old: {}, + }) + + expect(collection.get(collection.getKeyFromItem(row))?.name).toBe( + 'Updated', + ) + }) + + test('a matching UPDATE for an unseen row inserts it', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 99)), + ) + const filtered = nth(listenersFor(mockChannel, 'UPDATE'), 0) + + // Realtime only delivers this to the filtered listener when the row + // matches, which means the row has moved into the query's window. + expect(() => + filtered.handler({ eventType: 'UPDATE', new: row, old: {} }), + ).not.toThrow() + + expect(collection.has(collection.getKeyFromItem(row))).toBe(true) + }) + + test('an unfiltered UPDATE for an unseen row is ignored', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + const catchAll = listenersFor(mockChannel, 'UPDATE').find( + (call) => !call.config.filter, + ) + + // Would otherwise mirror every row in the table into the collection. + expect(() => + catchAll?.handler({ eventType: 'UPDATE', new: row, old: {} }), + ).not.toThrow() + + expect(collection.has(collection.getKeyFromItem(row))).toBe(false) + }) + + test('INSERT for an already-present key with changed data does not throw', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + const { handler } = nth(mockChannel.onCalls, 0) + + // Realtime replayed/duplicated the same INSERT with changed data: this + // is the exact shape of an unhandled write-conflict error. + handler({ eventType: 'INSERT', new: row, old: {} }) + expect(() => + handler({ + eventType: 'INSERT', + new: { ...row, name: 'Zed Prime' }, + old: {}, + }), + ).not.toThrow() + + const key = collection.getKeyFromItem(row) + expect(collection.get(key)?.name).toBe('Zed Prime') + }) + + test('UPDATE for a key not present in the collection does not throw', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + const { handler } = nth(mockChannel.onCalls, 0) + + expect(() => + handler({ eventType: 'UPDATE', new: row, old: {} }), + ).not.toThrow() + + const key = collection.getKeyFromItem(row) + expect(collection.get(key)?.name).toBe('Zed') + }) + + test('DELETE removes the row, and is a no-op for an unknown key', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }) + const key = collection.getKeyFromItem(row) + expect(collection.has(key)).toBe(true) + + emit(mockChannel, { eventType: 'DELETE', new: {}, old: row }) + expect(collection.has(key)).toBe(false) + + // A delete for a key the collection never had must not throw. + expect(() => + emit(mockChannel, { + eventType: 'DELETE', + new: {}, + old: { id: 12_345, name: '', email: '', active: false }, + }), + ).not.toThrow() + }) + }) + + describe('fetching without waiting for the subscription', () => { + // Known limitation (see queryFn in src/db.ts): the initial fetch is not + // gated on the channel subscribing, so a row written in the window between + // the fetch and the subscription going live can be missed by both. + test('the first fetch does not wait for the channel to subscribe', async () => { + const mockFetch = createMockFetch() + const mockChannel = createMockChannel({ autoSubscribe: false }) + const { collection } = createRealtimeUsersCollection( + mockFetch, + mockChannel, + ) + track(collection) + + const live = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + await live.preload() + await live.toArrayWhenReady() + + // The fetch proceeds even though the channel never reported SUBSCRIBED. + expect(mockFetch).toHaveBeenCalled() + }) + }) + + describe('resubscribing when the filter set changes', () => { + test('uses a fresh channel topic and drops the previous channel', async () => { + const mockFetch = createMockFetch() + const channels: Array = [] + const { collection, supabase } = createRealtimeUsersCollection( + mockFetch, + () => { + const channel = createMockChannel() + channels.push(channel) + return channel + }, + { realtimeUseFilter: true }, + ) + track(collection) + + const first = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + await first.preload() + await first.toArrayWhenReady() + await vi.waitFor(() => expect(channels.length).toBe(1)) + + const second = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => gt(user.id, 5)), + ) + await second.preload() + await second.toArrayWhenReady() + await vi.waitFor(() => expect(channels.length).toBe(2)) + + const topics = ( + supabase.channel as unknown as ReturnType + ).mock.calls.map(([topic]) => topic) + // A repeated topic would hand back the channel being torn down. + expect(new Set(topics).size).toBe(topics.length) + + // The union of both queries' filters is now subscribed. + expect(insertFilters(nth(channels, 1))).toEqual(['id=eq.1', 'id=gt.5']) + + // The old channel is only dropped once the replacement is subscribed. + await vi.waitFor(() => + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 0)), + ) + }) + }) + + describe('racing local mutations', () => { + const row = { id: 77, name: 'Race', email: 'race@test.com', active: true } + + test('a Realtime echo of a pending local insert does not throw', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + const key = collection.getKeyFromItem(row) + + const tx = collection.insert(row) + // The optimistic row is visible right away, but the synced store only + // learns about it once PostgREST answers — Realtime is usually faster. + expect(collection.has(key)).toBe(true) + expect(() => + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }), + ).not.toThrow() + + await expect(tx.when('settled')).resolves.toBeDefined() + expect(collection.get(key)?.name).toBe('Race') + }) + + test('a Realtime echo of a pending local delete does not throw', async () => { + const { mockChannel, collection } = await captureChannel( + (c) => (q) => q.from({ user: c }), + ) + emit(mockChannel, { eventType: 'INSERT', new: row, old: {} }) + const key = collection.getKeyFromItem(row) + + const tx = collection.delete(key) + expect(collection.has(key)).toBe(false) + expect(() => + emit(mockChannel, { eventType: 'DELETE', new: {}, old: row }), + ).not.toThrow() + + await expect(tx.when('settled')).resolves.toBeDefined() + expect(collection.has(key)).toBe(false) + }) + }) + + describe('swapping channels safely', () => { + // The first channel joins immediately; every replacement waits for the test + // to report its subscription status. + async function startSwap() { + const mockFetch = createMockFetch() + const channels: Array = [] + const { collection, supabase } = createRealtimeUsersCollection( + mockFetch, + () => { + const channel = createMockChannel({ + autoSubscribe: channels.length === 0, + }) + channels.push(channel) + return channel + }, + { realtimeUseFilter: true }, + ) + track(collection) + + const first = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + await first.preload() + await first.toArrayWhenReady() + await vi.waitFor(() => expect(channels.length).toBe(1)) + + const second = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => gt(user.id, 5)), + ) + const secondPreloaded = second.preload() + await vi.waitFor(() => expect(channels.length).toBe(2)) + await vi.waitFor(() => + expect(nth(channels, 1).subscribe).toHaveBeenCalled(), + ) + + return { channels, collection, supabase, secondPreloaded } + } + + test('keeps the previous channel until the replacement reports SUBSCRIBED', async () => { + const { channels, supabase, secondPreloaded } = await startSwap() + + // Dropping it now would leave a window with no subscription at all. + expect(supabase.removeChannel).not.toHaveBeenCalled() + + nth(channels, 1).confirmSubscribed() + await secondPreloaded + await vi.waitFor(() => + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 0)), + ) + }) + + test('falls back to a catch-all when the server rejects the filtered subscription', async () => { + const { channels, collection, supabase, secondPreloaded } = + await startSwap() + + nth(channels, 1).confirmSubscribed('CHANNEL_ERROR') + + await vi.waitFor(() => expect(channels.length).toBe(3)) + expect(insertFilters(nth(channels, 2))).toEqual([null]) + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 1)) + // The working channel is only replaced once the fallback has joined. + expect(supabase.removeChannel).not.toHaveBeenCalledWith(nth(channels, 0)) + + nth(channels, 2).confirmSubscribed() + await vi.waitFor(() => + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 0)), + ) + await secondPreloaded + + // The rejected filter set is remembered: a query that would produce it + // again reuses the catch-all instead of failing the same way twice. + const third = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => gt(user.id, 5)), + ) + await third.preload() + await third.toArrayWhenReady() + expect(channels.length).toBe(3) + }) + + test('keeps the previous channel when even the catch-all is rejected', async () => { + const { channels, supabase, secondPreloaded } = await startSwap() + + nth(channels, 1).confirmSubscribed('CHANNEL_ERROR') + await vi.waitFor(() => expect(channels.length).toBe(3)) + nth(channels, 2).confirmSubscribed('CHANNEL_ERROR') + + await vi.waitFor(() => + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 2)), + ) + expect(supabase.removeChannel).toHaveBeenCalledWith(nth(channels, 1)) + expect(supabase.removeChannel).not.toHaveBeenCalledWith(nth(channels, 0)) + expect(channels.length).toBe(3) + + // Data loading is never held hostage by a failed subscription. + await secondPreloaded + }) + + test('a transient error on the sole catch-all keeps the channel so it can reconnect', async () => { + const mockFetch = createMockFetch() + const channels: Array = [] + const { collection, supabase } = createRealtimeUsersCollection( + mockFetch, + () => { + const channel = createMockChannel({ autoSubscribe: false }) + channels.push(channel) + return channel + }, + // realtimeUseFilter defaults to false → a single catch-all subscription. + ) + track(collection) + + const live = startLiveQuery(collection, (c) => (q) => q.from({ user: c })) + const preloaded = live.preload() + await vi.waitFor(() => expect(channels.length).toBe(1)) + await vi.waitFor(() => + expect(nth(channels, 0).subscribe).toHaveBeenCalled(), + ) + + // The only channel reports a transient connection error before joining. A + // catch-all has nothing to reject, so this is not a filter rejection. + nth(channels, 0).confirmSubscribed('CHANNEL_ERROR') + await preloaded + + // It is NOT removed — realtime-js keeps retrying the join and its listeners + // stay attached — and no replacement is opened, so the table is never left + // without a channel until an observer changes. + expect(supabase.removeChannel).not.toHaveBeenCalled() + expect(channels.length).toBe(1) + }) + + test('tearing down all queries removes the joined and the in-flight channel', async () => { + const mockFetch = createMockFetch() + const channels: Array = [] + const { collection, supabase } = createRealtimeUsersCollection( + mockFetch, + () => { + const channel = createMockChannel({ + autoSubscribe: channels.length === 0, + }) + channels.push(channel) + return channel + }, + { realtimeUseFilter: true }, + ) + track(collection) + + const first = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => eq(user.id, 1)), + ) + await first.preload() + await first.toArrayWhenReady() + await vi.waitFor(() => expect(channels.length).toBe(1)) + + // A second query opens a replacement channel that never joins, so it stays + // pending alongside the first, still-joined channel. + const second = startLiveQuery( + collection, + (c) => (q) => q.from({ user: c }).where(({ user }) => gt(user.id, 5)), + ) + const secondPreloaded = second.preload() + await vi.waitFor(() => expect(channels.length).toBeGreaterThan(1)) + await secondPreloaded + + // Tearing down every active query must dispose every channel this table + // opened — a joined channel and any swap still in flight — with none left + // behind. + await first.cleanup() + await second.cleanup() + + await vi.waitFor(() => { + for (const channel of channels) { + expect(supabase.removeChannel).toHaveBeenCalledWith(channel) + } + }) + }) + }) + + // `or(...)` (and other unsupported expressions) cannot be driven through the + // live-query path because the query's own supabaseQueryFn calls the same + // strict toPostgrestParams, which throws on an unpushable expression. Cover the + // defensive fallback directly. + describe('realtimeFiltersToSearch falls back instead of throwing', () => { + test('an unsupported expression yields a catch-all', () => { + const orExpression = { type: 'func', name: 'or', args: [] } as any + expect(realtimeFiltersToSearch([orExpression])).toEqual( + new URLSearchParams(), + ) + }) + }) +}) diff --git a/packages/supabase-db-collection/tests/request-headers.test.ts b/packages/supabase-db-collection/tests/request-headers.test.ts new file mode 100644 index 0000000000..d28538c6dc --- /dev/null +++ b/packages/supabase-db-collection/tests/request-headers.test.ts @@ -0,0 +1,139 @@ +import { createClient } from '@supabase/supabase-js' +import { createCollection } from '@tanstack/db' +import { afterEach, describe, expect, test } from 'vitest' +import pkg from '../package.json' +import { supabaseCollectionOptions } from '../src/index' +import { VERSION } from '../src/version' +import { + SUPABASE_KEY, + SUPABASE_URL, + createMockFetch, + createMockedUsersCollection, + getRequestHeaders, + queryResult, + usersSchema, +} from './test.utils' + +const EXPECTED_CLIENT_INFO = `@tanstack/supabase-db-collection/${VERSION}` + +test('VERSION stays in sync with package.json', () => { + expect(VERSION).toBe(pkg.version) +}) + +describe('automatic X-Client-Info header', () => { + let collection: ReturnType + + afterEach(() => { + collection.cleanup() + }) + + test('is present on select requests', async () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + + await queryResult((q) => q.from({ user: collection })) + + expect(getRequestHeaders(mockFetch, 0).get('x-client-info')).toBe( + EXPECTED_CLIENT_INFO, + ) + }) + + test('is present on insert requests', async () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + await queryResult((q) => q.from({ user: collection })) + + const tx = collection.insert({ + id: 2, + name: 'Bob', + email: 'bob@test.com', + active: true, + }) + await tx.when('settled') + + const lastCall = mockFetch.mock.calls.length - 1 + expect(getRequestHeaders(mockFetch, lastCall).get('x-client-info')).toBe( + EXPECTED_CLIENT_INFO, + ) + }) + + test('is present on update requests', async () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + await queryResult((q) => q.from({ user: collection })) + + const insertTx = collection.insert({ + id: 2, + name: 'Bob', + email: 'bob@test.com', + active: true, + }) + await insertTx.when('settled') + + const updateTx = collection.update('2', (draft) => { + draft.name = 'Bobby' + }) + await updateTx.when('settled') + + const lastCall = mockFetch.mock.calls.length - 1 + expect(getRequestHeaders(mockFetch, lastCall).get('x-client-info')).toBe( + EXPECTED_CLIENT_INFO, + ) + }) + + test('is present on delete requests', async () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + await queryResult((q) => q.from({ user: collection })) + + const insertTx = collection.insert({ + id: 2, + name: 'Bob', + email: 'bob@test.com', + active: true, + }) + await insertTx.when('settled') + + const deleteTx = collection.delete('2') + await deleteTx.when('settled') + + const lastCall = mockFetch.mock.calls.length - 1 + expect(getRequestHeaders(mockFetch, lastCall).get('x-client-info')).toBe( + EXPECTED_CLIENT_INFO, + ) + }) + + test("replaces supabase-js's own X-Client-Info rather than appending", async () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + + await queryResult((q) => q.from({ user: collection })) + + const value = getRequestHeaders(mockFetch, 0).get('x-client-info') + expect(value).toBe(EXPECTED_CLIENT_INFO) + expect(value).not.toContain('supabase-js') + }) + + test('preserves user-supplied global headers', async () => { + const mockFetch = createMockFetch() + collection = createCollection( + supabaseCollectionOptions({ + tableName: 'users', + keys: ['id'], + schema: usersSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { + fetch: mockFetch, + headers: { 'X-Custom-Header': 'custom-value' }, + }, + }), + }), + ) + + await queryResult((q) => q.from({ user: collection })) + + const headers = getRequestHeaders(mockFetch, 0) + expect(headers.get('x-custom-header')).toBe('custom-value') + expect(headers.get('x-client-info')).toBe(EXPECTED_CLIENT_INFO) + }) +}) diff --git a/packages/supabase-db-collection/tests/row-conversions.test.ts b/packages/supabase-db-collection/tests/row-conversions.test.ts new file mode 100644 index 0000000000..809aeda6f8 --- /dev/null +++ b/packages/supabase-db-collection/tests/row-conversions.test.ts @@ -0,0 +1,294 @@ +import { createClient } from '@supabase/supabase-js' +import { createCollection } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' +import { supabaseCollectionOptions } from '../src/index' +import { + SUPABASE_KEY, + SUPABASE_URL, + createMockChannel, + emit, +} from './test.utils' +import type { MockChannel } from './test.utils' + +const eventsSchema = z.object({ + id: z.bigint(), + title: z.string(), + startsAt: z.date(), + endsAt: z.date().nullable(), + tags: z.set(z.string()), +}) + +// What PostgREST and Realtime send for the schema above: a bigint is a JSON +// number, a timestamptz an ISO string and a text[] a JSON array. +type EventRecord = { + id: number + title: string + startsAt: string + endsAt: string | null + tags: Array +} + +const START = '2024-05-06T07:08:09.123+00:00' +const END = '2024-05-07T07:08:09.123+00:00' + +const cleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup() + } +}) + +const json = (body: unknown, contentRange?: string) => + new Response(JSON.stringify(body), { + status: 200, + headers: { + 'content-type': 'application/json', + ...(contentRange ? { 'content-range': contentRange } : {}), + }, + }) + +/** A PostgREST stand-in holding `rows`, recording the write requests it gets. */ +function makeServer(rows: Array) { + const writes: Array<{ method: string; search: string; body: unknown }> = [] + const fetchMock = vi.fn().mockImplementation((input, init) => { + const method = init?.method ?? 'GET' + if (method === 'GET') { + return Promise.resolve(json(rows, `0-${rows.length - 1}/${rows.length}`)) + } + const body = JSON.parse(String(init?.body ?? '{}')) + writes.push({ method, search: new URL(String(input)).search, body }) + // PATCH and POST return the stored row, as `Prefer: return=representation` + // asks: bigints as JSON numbers, with a column the server filled in. + return Promise.resolve( + json({ ...body, id: Number(body.id ?? 1), endsAt: body.endsAt ?? null }), + ) + }) + return { fetchMock, writes } +} + +function makeEvents( + rows: Array, + { realtime = false }: { realtime?: boolean } = {}, +) { + const server = makeServer(rows) + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: server.fetchMock }, + }) + const channel: MockChannel = createMockChannel() + if (realtime) { + supabase.channel = vi.fn( + () => channel, + ) as unknown as typeof supabase.channel + supabase.removeChannel = vi.fn() as unknown as typeof supabase.removeChannel + } + const collection = createCollection( + supabaseCollectionOptions({ + tableName: 'events', + keys: ['id'], + schema: eventsSchema, + supabase, + queryClient: new QueryClient(), + syncMode: 'eager', + realtime, + parse: { + id: (value) => BigInt(value as number), + startsAt: (value) => new Date(value as string), + endsAt: (value) => new Date(value as string), + tags: (value) => new Set(value as Array), + }, + serialize: { + id: (value) => value.toString(), + startsAt: (value) => value.toISOString(), + endsAt: (value) => value.toISOString(), + // A Set would serialize to `{}`. + tags: (value) => [...value], + }, + }), + ) + cleanups.push(() => collection.cleanup()) + return { collection, channel, ...server } +} + +describe('parse and serialize', () => { + test('rows read from PostgREST are parsed into the schema types', async () => { + const { collection } = makeEvents([ + { + id: 7, + title: 'Launch', + startsAt: START, + endsAt: END, + tags: ['a', 'b'], + }, + ]) + await collection.preload() + + expect(collection.get('7')).toMatchObject({ + id: 7n, + title: 'Launch', + startsAt: new Date(START), + endsAt: new Date(END), + tags: new Set(['a', 'b']), + }) + }) + + test('null columns are not passed to parse', async () => { + const { collection } = makeEvents([ + { id: 7, title: 'Open ended', startsAt: START, endsAt: null, tags: [] }, + ]) + await collection.preload() + + expect(collection.get('7')?.endsAt).toBeNull() + }) + + test('columns without a conversion are left as received', async () => { + const { collection } = makeEvents([ + { id: 7, title: 'Launch', startsAt: START, endsAt: null, tags: [] }, + ]) + await collection.preload() + + expect(collection.get('7')?.title).toBe('Launch') + }) + + test('an insert sends serialized columns and stores the parsed server row', async () => { + const { collection, writes } = makeEvents([]) + await collection.preload() + + const tx = collection.insert({ + id: 42n, + title: 'Insert', + startsAt: new Date(START), + endsAt: null, + tags: new Set(['x', 'y']), + }) + await tx.when('settled') + + expect(writes).toEqual([ + { + method: 'POST', + search: '?select=*', + body: { + id: '42', + title: 'Insert', + startsAt: '2024-05-06T07:08:09.123Z', + endsAt: null, + tags: ['x', 'y'], + }, + }, + ]) + expect(collection.get('42')).toMatchObject({ + title: 'Insert', + startsAt: new Date(START), + tags: new Set(['x', 'y']), + }) + }) + + test('an update matches the row by its serialized key and sends serialized columns', async () => { + const { collection, writes } = makeEvents([ + { + id: 7, + title: 'Launch', + startsAt: START, + endsAt: END, + tags: ['a', 'b'], + }, + ]) + await collection.preload() + + const tx = collection.update('7', (draft) => { + draft.title = 'Relaunch' + }) + await tx.when('settled') + + expect(writes).toEqual([ + { + method: 'PATCH', + search: '?id=eq.7&select=*', + body: { + id: '7', + title: 'Relaunch', + startsAt: '2024-05-06T07:08:09.123Z', + endsAt: '2024-05-07T07:08:09.123Z', + tags: ['a', 'b'], + }, + }, + ]) + expect(collection.get('7')?.title).toBe('Relaunch') + }) + + test('a delete matches the row by its serialized key', async () => { + const { collection, writes } = makeEvents([ + { id: 7, title: 'Launch', startsAt: START, endsAt: null, tags: [] }, + ]) + await collection.preload() + + const tx = collection.delete('7') + await tx.when('settled') + + expect(writes).toMatchObject([{ method: 'DELETE', search: '?id=eq.7' }]) + expect(collection.has('7')).toBe(false) + }) + + describe('realtime', () => { + const attached = async (rows: Array = []) => { + const events = makeEvents(rows, { realtime: true }) + await events.collection.preload() + await vi.waitFor(() => expect(events.channel.on).toHaveBeenCalled()) + return events + } + + test('inserted rows are parsed', async () => { + const { collection, channel } = await attached() + + emit(channel, { + eventType: 'INSERT', + new: { + id: 8, + title: 'Live', + startsAt: START, + endsAt: null, + tags: ['live'], + }, + }) + + expect(collection.get('8')).toMatchObject({ + id: 8n, + startsAt: new Date(START), + endsAt: null, + tags: new Set(['live']), + }) + }) + + test('updated rows are parsed', async () => { + const { collection, channel } = await attached([ + { id: 8, title: 'Live', startsAt: START, endsAt: null, tags: [] }, + ]) + + emit(channel, { + eventType: 'UPDATE', + new: { + id: 8, + title: 'Live', + startsAt: START, + endsAt: END, + tags: ['a', 'b'], + }, + }) + + expect(collection.get('8')?.endsAt).toEqual(new Date(END)) + }) + + test('a deleted row is found by its parsed key', async () => { + const { collection, channel } = await attached([ + { id: 8, title: 'Live', startsAt: START, endsAt: null, tags: [] }, + ]) + + // A delete payload carries only the key columns of the old row. + emit(channel, { eventType: 'DELETE', old: { id: 8 } }) + + expect(collection.has('8')).toBe(false) + }) + }) +}) diff --git a/packages/supabase-db-collection/tests/split-load-fanout.test.ts b/packages/supabase-db-collection/tests/split-load-fanout.test.ts new file mode 100644 index 0000000000..317f866aee --- /dev/null +++ b/packages/supabase-db-collection/tests/split-load-fanout.test.ts @@ -0,0 +1,377 @@ +import { createClient } from '@supabase/supabase-js' +import { IR, and, inArray, not } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { describe, expect, test, vi } from 'vitest' +import { MAX_URL_LENGTH, supabaseQueryFn } from '../src/functions' +import { loadSubsetOptionsToSearch } from '../src/postgrest-filters' +import { + getRawUrls, + getSearches, + makePaginatingFetch, +} from './pagination.utils' +import { SUPABASE_KEY, SUPABASE_URL } from './test.utils' +import type { LoadSubsetOptions } from '@tanstack/db' + +// Same table shape `makePaginatingFetch` was built for: a bare numeric `id`. +const idRef = new IR.PropRef(['id']) + +// The base table URL `supabaseQueryFn` measures every subset's rendered URL +// against (see `measureSubset` in src/functions.ts) — computed once here so +// `idsExceedingLength` can size an id list against the same budget the +// production splitter uses. +const BASE_URL_LENGTH = createClient(SUPABASE_URL, SUPABASE_KEY) + .from('items') + .url.toString().length + +/** + * A dense `1..n` id list whose rendered `in(...)` filter, combined with the + * base table URL, is at least `minLength` characters — i.e. the URL a single, + * unsplit request for this list would produce. Sized against `MAX_URL_LENGTH` + * (rather than a hard-coded id count) so the tests below keep exercising the + * splitter even if that constant ever changes. + * + * A small sample list's rendered length estimates the average per-id cost + * (digits plus a percent-encoded comma); the list is then resized to that + * estimate. One correction pass is always enough — the only thing the sample + * can get wrong is the id width, which grows by at most a digit or two + * between the sample and the final count. + */ +function idsExceedingLength(minLength: number): Array { + let count = 100 + for (let attempt = 0; attempt < 10; attempt++) { + const ids = Array.from({ length: count }, (_, i) => i + 1) + const length = + BASE_URL_LENGTH + + loadSubsetOptionsToSearch({ + where: inArray(idRef, ids), + } as unknown as LoadSubsetOptions).toString().length + if (length >= minLength) { + return ids + } + const perId = (length - BASE_URL_LENGTH) / count + count = Math.ceil((minLength - BASE_URL_LENGTH) / perId) + 10 + } + throw new Error(`could not size an id list past ${minLength} characters`) +} + +const runWhere = ( + mockFetch: ReturnType | typeof fetch, + where: unknown, + signal: AbortSignal = new AbortController().signal, +) => { + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + return supabaseQueryFn(supabase, 'items', { + client: new QueryClient(), + queryKey: ['items'], + signal, + meta: { loadSubsetOptions: { where } } as never, + }) +} + +const run = ( + mockFetch: ReturnType | typeof fetch, + ids: Array, + signal: AbortSignal = new AbortController().signal, +) => runWhere(mockFetch, inArray(idRef, ids), signal) + +// The length of an `in(id, ids)` subset's first-page URL without its `?`: how +// `measureSubset` (src/functions.ts) used to measure it, before it counted the +// `?` and the `offset`/`limit` params later pages add. +const measuredLength = (ids: Array): number => + BASE_URL_LENGTH + + loadSubsetOptionsToSearch({ + where: inArray(idRef, ids), + } as unknown as LoadSubsetOptions).toString().length + +const FIVE_DIGIT_START = 10_000 +const SIX_DIGIT_START = 100_000 +const TRAILING_SIX_DIGIT_START = 200_000 + +/** + * An id list whose leading ids measure *exactly* `targetLength` (per + * `measuredLength`), followed by `trailing` more ids that cannot fit beside it + * and so spill into later chunks. Deterministic: the list is dense 5-digit ids + * up to the longest prefix within the target, then the last few of those are + * widened to 6 digits (+1 character each) to close the remaining gap exactly. + */ +function idsPackedTo(targetLength: number, trailing = 5): Array { + let count = 1 + const fiveDigit = (n: number) => + Array.from({ length: n }, (_, i) => FIVE_DIGIT_START + i) + while (measuredLength(fiveDigit(count + 1)) <= targetLength) { + count++ + } + const gap = targetLength - measuredLength(fiveDigit(count)) + const packed = fiveDigit(count).map((value, i) => + i >= count - gap ? SIX_DIGIT_START + i : value, + ) + if (measuredLength(packed) !== targetLength) { + throw new Error(`could not pack an id list to exactly ${targetLength}`) + } + const spill = Array.from( + { length: trailing }, + (_, i) => TRAILING_SIX_DIGIT_START + i, + ) + return [...packed, ...spill] +} + +describe('supabaseQueryFn: chunked fan-out for an oversized IN list', () => { + test('many ids produce multiple requests, every URL within budget, and every matching row comes back', async () => { + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const fixture = ids.map((id) => ({ id })) + const mockFetch = makePaginatingFetch(fixture) + + const rows = await run(mockFetch, ids) + + expect( + (rows as Array<{ id: number }>).map((r) => r.id).sort((a, b) => a - b), + ).toEqual(ids) + const searches = getSearches(mockFetch) + // The list does not fit in one request at this budget, so it was split. + expect(searches.length).toBeGreaterThan(1) + // Every request the mock saw actually carried an `in.(...)` slice of the + // original list, proving the split happened on the `id` column. + for (const search of searches) { + expect(search).toMatch(/id=in\.\(/) + } + // Every rendered request line stays within the fixed budget. + for (const rawUrl of getRawUrls(mockFetch)) { + expect(rawUrl.length).toBeLessThanOrEqual(MAX_URL_LENGTH) + } + }) + + test('splitting and the db-max-rows cap compose: a chunk larger than the cap still pages', async () => { + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const fixture = ids.map((id) => ({ id })) + // Small enough that each chunk (hundreds to low thousands of ids) needs + // several pages, but not so small that the paging loop needs thousands of + // requests to get through a chunk. + const cap = 100 + const mockFetch = makePaginatingFetch(fixture, { cap }) + + const rows = await run(mockFetch, ids) + + const returned = (rows as Array<{ id: number }>).map((r) => r.id) + expect(returned.sort((a, b) => a - b)).toEqual(ids) + expect(new Set(returned).size).toBe(ids.length) + const searches = getSearches(mockFetch) + // More requests than there are chunks: each chunk's own paging loop ran. + expect(searches.length).toBeGreaterThan(2) + }) + + test('one failing chunk rejects the load and aborts its sibling requests', async () => { + const ids = idsExceedingLength(MAX_URL_LENGTH * 3) + // Any id lands in exactly one chunk (chunks are disjoint slices of the + // sorted list), so failing whichever chunk holds this one exercises the + // abort path without depending on how many chunks came out. + const failId = ids[0]! + let abortedCount = 0 + + const failingFetch = vi + .fn() + .mockImplementation((input, init) => { + const params = new URL(String(input)).searchParams + const idFilter = params.get('id') ?? '' + const members = idFilter + .replace(/^in\.\(/, '') + .replace(/\)$/, '') + .split(',') + .map(Number) + // The chunk holding `failId` fails fast; every other chunk is slow + // enough that the abort raised by the failure reaches it first. + const isFailingChunk = members.includes(failId) + const signal = init?.signal as AbortSignal | undefined + + return new Promise((resolve, reject) => { + const settle = () => { + if (isFailingChunk) { + resolve( + new Response(JSON.stringify({ message: 'boom' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ) + return + } + resolve( + new Response(JSON.stringify([]), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-range': '*/0', + }, + }), + ) + } + const timer = setTimeout(settle, isFailingChunk ? 0 : 50) + signal?.addEventListener('abort', () => { + clearTimeout(timer) + abortedCount += 1 + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + await expect(run(failingFetch, ids)).rejects.toBeDefined() + // Give the aborted siblings' rejection handlers a turn to run. + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(abortedCount).toBeGreaterThan(0) + }) + + test("concurrency never exceeds the fan-out's concurrency cap", async () => { + // MAX_CONCURRENT_CHUNKS (src/functions.ts) is 6; size the list well past + // that so the cap actually binds instead of every chunk fitting in one + // wave. + const ids = idsExceedingLength(MAX_URL_LENGTH * 10) + const fixture = ids.map((id) => ({ id })) + const base = makePaginatingFetch(fixture) + + let active = 0 + let peak = 0 + const trackedFetch = vi.fn(async (input, init) => { + active += 1 + peak = Math.max(peak, active) + // Yield so overlapping calls actually overlap instead of resolving + // synchronously one at a time. + await new Promise((resolve) => setTimeout(resolve, 5)) + try { + return await base(input, init) + } finally { + active -= 1 + } + }) + + const rows = await run(trackedFetch, ids) + + expect((rows as Array<{ id: number }>).length).toBe(ids.length) + const searches = getSearches(trackedFetch) + // Sizing above actually produced more chunks than the concurrency cap. + expect(searches.length).toBeGreaterThan(6) + // MAX_CONCURRENT_CHUNKS in src/functions.ts. + expect(peak).toBeLessThanOrEqual(6) + // The cap should actually bind here — otherwise this test would not have + // exercised the concurrency limiter at all. + expect(peak).toBeGreaterThan(1) + }) + + test('an unsplittable not(in) keeps the subset oversized: one request, not one per value', async () => { + const ids = Array.from({ length: 10 }, (_, i) => i + 1) + const hugeExcluded = Array.from({ length: 2000 }, (_, i) => `other-${i}`) + const where = and( + inArray(idRef, ids), + not(inArray(new IR.PropRef(['other']), hugeExcluded)), + ) + // Precondition: the unsplit URL is over budget because of the `not(in)`. + expect( + BASE_URL_LENGTH + + loadSubsetOptionsToSearch({ + where, + } as unknown as LoadSubsetOptions).toString().length, + ).toBeGreaterThan(MAX_URL_LENGTH) + const mockFetch = makePaginatingFetch(ids.map((id) => ({ id }))) + + const rows = await runWhere(mockFetch, where) + + expect((rows as Array<{ id: number }>).length).toBe(ids.length) + // Splitting the small `id` list cannot shrink the URL, so the subset goes + // out as-is, as a single request. + expect(getSearches(mockFetch)).toHaveLength(1) + }) + + test('a chunk packed to exactly the URL limit stays within it (the `?` counts)', async () => { + const ids = idsPackedTo(MAX_URL_LENGTH) + const mockFetch = makePaginatingFetch(ids.map((id) => ({ id }))) + + const rows = await run(mockFetch, ids) + + expect((rows as Array<{ id: number }>).length).toBe(ids.length) + const rawUrls = getRawUrls(mockFetch) + // The list was split, and one chunk measures exactly MAX_URL_LENGTH + // without its `?`. + expect(rawUrls.length).toBeGreaterThan(1) + for (const rawUrl of rawUrls) { + expect(rawUrl.length).toBeLessThanOrEqual(MAX_URL_LENGTH) + } + }) + + test("paging a packed chunk keeps every page's URL, with offset and limit, within the limit", async () => { + // One below the limit, so the first page's URL (with its `?`) is exactly + // at it; later pages then add `&offset=...&limit=...` on top. + const ids = idsPackedTo(MAX_URL_LENGTH - 1) + const mockFetch = makePaginatingFetch( + ids.map((id) => ({ id })), + { cap: 100 }, + ) + + const rows = await run(mockFetch, ids) + + expect((rows as Array<{ id: number }>).length).toBe(ids.length) + const rawUrls = getRawUrls(mockFetch) + // Pages past the first really were requested. + expect(rawUrls.some((rawUrl) => rawUrl.includes('offset='))).toBe(true) + for (const rawUrl of rawUrls) { + expect(rawUrl.length).toBeLessThanOrEqual(MAX_URL_LENGTH) + } + }) + + test("splitting and the key-order tiebreak compose: every page's URL, with the tiebreak, stays within the limit", async () => { + // Long key columns make the tiebreak order a large part of every paged + // URL, far wider than the slack the widest-offset measurement leaves, so a + // budget that ignored the tiebreak would send over-long page requests. + const keys = ['organization_identifier_column', 'id'] + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const fixture = ids.map((id) => ({ + id, + organization_identifier_column: 1, + rank: id % 3, + })) + const mockFetch = makePaginatingFetch(fixture, { cap: 100 }) + + const rows = await supabaseQueryFn<{ id: number }>( + createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + 'items', + { + client: new QueryClient(), + queryKey: ['items'], + signal: new AbortController().signal, + meta: { + loadSubsetOptions: { + where: inArray(idRef, ids), + orderBy: [ + { + expression: new IR.PropRef(['rank']), + compareOptions: { + direction: 'asc' as const, + nulls: 'last' as const, + }, + }, + ], + }, + } as never, + }, + { keys }, + ) + + // Every matching row comes back exactly once. + expect(rows.map((row) => row.id).sort((a, b) => a - b)).toEqual(ids) + const searches = getSearches(mockFetch) + // The list was split, and its chunks paged past the cap. + const pagedSearches = searches.filter((search) => + search.includes('offset='), + ) + expect(pagedSearches.length).toBeGreaterThan(1) + // Multi-page chunks are read in key order after the query's own order. + for (const search of pagedSearches) { + expect(search).toContain( + 'order=rank.asc,organization_identifier_column.asc,id.asc', + ) + } + for (const rawUrl of getRawUrls(mockFetch)) { + expect(rawUrl.length).toBeLessThanOrEqual(MAX_URL_LENGTH) + } + }) +}) diff --git a/packages/supabase-db-collection/tests/split-load-subset-options.test.ts b/packages/supabase-db-collection/tests/split-load-subset-options.test.ts new file mode 100644 index 0000000000..1f0fedcabe --- /dev/null +++ b/packages/supabase-db-collection/tests/split-load-subset-options.test.ts @@ -0,0 +1,284 @@ +import { IR, and, eq, gt, inArray, not, or } from '@tanstack/db' +import { describe, expect, test } from 'vitest' +import { + cursorCurrentToSearch, + loadSubsetOptionsToSearch, + splitLoadSubsetOptions, +} from '../src/postgrest-filters' +import { flattenAnd } from '../src/postgrest-filters/common' +import type { LoadSubsetOptions } from '@tanstack/db' + +// A short stand-in for `supabase.from(table).url`; only its length matters. +const BASE_URL = 'http://localhost:54321/rest/v1/items' + +const id = new IR.PropRef(['id']) +const other = new IR.PropRef(['other']) +const active = new IR.PropRef(['active']) + +// A simplified `measureSubset` (src/functions.ts) without the real supabase +// client: renders the same query strings the adapter would send and measures +// them, leaving out the `?` and the paging params the real one also counts. +const measure = (options: LoadSubsetOptions): number => { + const mainLength = + BASE_URL.length + loadSubsetOptionsToSearch(options).toString().length + const tiesSearch = cursorCurrentToSearch(options) + return tiesSearch + ? Math.max(mainLength, BASE_URL.length + tiesSearch.toString().length) + : mainLength +} + +const split = (options: LoadSubsetOptions, maxUrlLength: number) => + splitLoadSubsetOptions(options, { maxUrlLength, measure }) + +// Every chunk's rendered URL (main request, plus the tie request when the +// subset has a cursor) must fit the budget. +const expectEveryChunkFits = ( + chunks: Array, + maxUrlLength: number, +) => { + for (const chunk of chunks) { + expect(measure(chunk)).toBeLessThanOrEqual(maxUrlLength) + } +} + +// Collects the values of every top-level `in(ref, [...])` conjunct matching +// `path`, across every chunk — the union a caller reconstructs by +// concatenating each chunk's rows. +const inValuesFor = ( + chunks: Array, + path: Array, +): Array => + chunks.flatMap((chunk) => { + if (!chunk.where) return [] + return flattenAnd(chunk.where).flatMap((expr) => { + if (expr.type !== 'func' || expr.name !== 'in') return [] + const [left, right] = expr.args + if ( + left?.type !== 'ref' || + JSON.stringify(left.path) !== JSON.stringify(path) || + right?.type !== 'val' || + !Array.isArray(right.value) + ) { + return [] + } + return right.value + }) + }) + +describe('splitLoadSubsetOptions', () => { + test('returns the identical object when the subset already fits', () => { + const options: LoadSubsetOptions = { where: inArray(id, [1, 2, 3]) } + const result = split(options, 8000) + expect(result).toEqual([options]) + expect(result[0]).toBe(options) + }) + + test('splits string ids with commas and quotes, and Dates, so every chunk fits', () => { + const values = [ + 'Alice, "A"', + 'Bob (the builder)', + 'back\\slash', + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-06-15T12:30:00.000Z'), + 'plain', + 'another, one', + ] + const options: LoadSubsetOptions = { where: inArray(id, values) } + const maxUrlLength = BASE_URL.length + 60 + const chunks = split(options, maxUrlLength) + + expect(chunks.length).toBeGreaterThan(1) + expectEveryChunkFits(chunks, maxUrlLength) + expect(new Set(inValuesFor(chunks, ['id']))).toEqual(new Set(values)) + }) + + test('the union of chunk values equals the deduped, null-free input', () => { + const values = [1, 2, 3, null, 2, 4, 5, null, 6, 7, 8, 9, 10] + const options: LoadSubsetOptions = { where: inArray(id, values) } + const maxUrlLength = BASE_URL.length + 30 + const chunks = split(options, maxUrlLength) + + expect(chunks.length).toBeGreaterThan(1) + expectEveryChunkFits(chunks, maxUrlLength) + const expected = new Set(values.filter((v) => v != null)) + expect(new Set(inValuesFor(chunks, ['id']))).toEqual(expected) + }) + + test('leaves a top-level not(in) unsplit', () => { + const values = Array.from({ length: 200 }, (_, i) => `id-${i}`) + const options: LoadSubsetOptions = { where: not(inArray(id, values)) } + const result = split(options, BASE_URL.length + 20) + expect(result).toEqual([options]) + expect(result[0]).toBe(options) + }) + + test('leaves an IN nested inside OR unsplit', () => { + const values = Array.from({ length: 200 }, (_, i) => `id-${i}`) + const options: LoadSubsetOptions = { + where: or(inArray(id, values), eq(active, true)), + } + const result = split(options, BASE_URL.length + 20) + expect(result).toEqual([options]) + expect(result[0]).toBe(options) + }) + + test('sends a subset as-is when an unsplittable not(in) keeps it oversized, even with a small top-level IN', () => { + const smallIds = Array.from({ length: 10 }, (_, i) => `id-${i}`) + const hugeExcluded = Array.from({ length: 2000 }, (_, i) => `other-${i}`) + const options: LoadSubsetOptions = { + where: and(inArray(id, smallIds), not(inArray(other, hugeExcluded))), + } + const maxUrlLength = 8000 + // Precondition: the subset really is oversized, and stays oversized for any + // single `id` value because of the `not(in)` alone. + expect(measure(options)).toBeGreaterThan(maxUrlLength) + + const result = split(options, maxUrlLength) + + // Splitting `id` cannot make this fit, so it must not fan out one request + // per value: the subset is sent as-is. + expect(result).toHaveLength(1) + expect(result[0]).toBe(options) + }) + + test('sends a subset as-is when an IN nested in OR keeps it oversized, even with a small top-level IN', () => { + const smallIds = Array.from({ length: 10 }, (_, i) => `id-${i}`) + const hugeNested = Array.from({ length: 2000 }, (_, i) => `other-${i}`) + const options: LoadSubsetOptions = { + where: and( + inArray(id, smallIds), + or(inArray(other, hugeNested), eq(active, true)), + ), + } + const maxUrlLength = 8000 + expect(measure(options)).toBeGreaterThan(maxUrlLength) + + const result = split(options, maxUrlLength) + + expect(result).toHaveLength(1) + expect(result[0]).toBe(options) + }) + + test('an unsplittable subset (no where) returns [options] unchanged', () => { + // Nothing to split, yet still "too long" per the fake budget: the + // splitter must not loop or throw, just hand the request back as-is. + const options: LoadSubsetOptions = {} + const result = split(options, -1) + expect(result).toEqual([options]) + expect(result[0]).toBe(options) + }) + + describe('limit/offset/cursor rewriting', () => { + const values = Array.from({ length: 100 }, (_, i) => i) + // Room for about ten values per chunk, so the list must split. + const budgetFor = (options: LoadSubsetOptions) => + measure({ ...options, where: inArray(id, values.slice(0, 10)) }) + + test('keeps limit unchanged and does not touch offset when a cursor is present', () => { + const options: LoadSubsetOptions = { + where: inArray(id, values), + limit: 20, + cursor: { whereFrom: gt(id, 5), whereCurrent: eq(id, 5) }, + } + const chunks = split(options, budgetFor(options)) + + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + expect(chunk.limit).toBe(20) + expect(chunk.cursor).toBe(options.cursor) + } + }) + + test('folds offset into limit and drops offset when there is no cursor', () => { + const options: LoadSubsetOptions = { + where: inArray(id, values), + limit: 10, + offset: 5, + } + const chunks = split(options, budgetFor(options)) + + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + expect(chunk.limit).toBe(15) + expect(chunk.offset).toBeUndefined() + } + }) + + test('drops offset without adding a limit when the caller had none', () => { + const options: LoadSubsetOptions = { + where: inArray(id, values), + offset: 5, + } + const chunks = split(options, budgetFor(options)) + + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + expect(chunk.limit).toBeUndefined() + expect(chunk.offset).toBeUndefined() + } + }) + }) + + test('recurses into the next-largest IN when the largest alone cannot shrink enough', () => { + const idValues = Array.from({ length: 60 }, (_, i) => `id-${i}`) + const otherValues = Array.from({ length: 60 }, (_, i) => `other-${i}`) + const options: LoadSubsetOptions = { + where: and(inArray(id, idValues), inArray(other, otherValues)), + } + // Small enough that even a single `id` value alongside the full `other` + // list does not fit, forcing a split of `other` first. + const maxUrlLength = BASE_URL.length + 80 + const chunks = split(options, maxUrlLength) + + expect(chunks.length).toBeGreaterThan(1) + expectEveryChunkFits(chunks, maxUrlLength) + expect(new Set(inValuesFor(chunks, ['id']))).toEqual(new Set(idValues)) + expect(new Set(inValuesFor(chunks, ['other']))).toEqual( + new Set(otherValues), + ) + + // Every original (id, other) pair is covered by exactly one chunk's + // cartesian product, and chunks stay disjoint on both split columns. + const pairs = new Set() + for (const chunk of chunks) { + const chunkIds = inValuesFor([chunk], ['id']) + const chunkOthers = inValuesFor([chunk], ['other']) + for (const a of chunkIds) { + for (const b of chunkOthers) { + pairs.add(`${a}|${b}`) + } + } + } + expect(pairs.size).toBe(idValues.length * otherValues.length) + // Disjoint: no pair is fetched by more than one chunk. + const chunkPairCount = chunks.reduce( + (sum, chunk) => + sum + + inValuesFor([chunk], ['id']).length * + inValuesFor([chunk], ['other']).length, + 0, + ) + expect(chunkPairCount).toBe(pairs.size) + }) + + test('shrinks the next-largest IN against a single value of the largest, not its full list', () => { + const idValues = Array.from({ length: 300 }, (_, i) => `id-${i}`) + const otherValues = Array.from({ length: 300 }, (_, i) => `other-${i}`) + const options: LoadSubsetOptions = { + where: and(inArray(id, idValues), inArray(other, otherValues)), + } + // Neither full list fits alongside the other, but half of `other` does. + // Measuring `other`'s split against the full `id` list would shrink it to + // one value per chunk (~600 requests); measuring against one `id` value + // keeps it to a handful of `other` slices. + const maxUrlLength = BASE_URL.length + 2000 + const chunks = split(options, maxUrlLength) + + expectEveryChunkFits(chunks, maxUrlLength) + expect(new Set(inValuesFor(chunks, ['id']))).toEqual(new Set(idValues)) + expect(new Set(inValuesFor(chunks, ['other']))).toEqual( + new Set(otherValues), + ) + expect(chunks.length).toBeLessThan(150) + }) +}) diff --git a/packages/supabase-db-collection/tests/sync-mode.test.ts b/packages/supabase-db-collection/tests/sync-mode.test.ts new file mode 100644 index 0000000000..c184e18c59 --- /dev/null +++ b/packages/supabase-db-collection/tests/sync-mode.test.ts @@ -0,0 +1,183 @@ +import { createClient } from '@supabase/supabase-js' +import { + createCollection, + createLiveQueryCollection, + eq, + gt, +} from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { z } from 'zod' +import { supabaseCollectionOptions } from '../src/index' +import { getSearches, makePaginatingFetch } from './pagination.utils' +import { + SUPABASE_KEY, + SUPABASE_URL, + createMockChannel, + createMockFetch, + createRealtimeUsersCollection, + filtersFor, +} from './test.utils' + +const itemsSchema = z.object({ id: z.number(), rank: z.number() }) + +const ROWS = [ + { id: 1, rank: 10 }, + { id: 2, rank: 20 }, + { id: 3, rank: 30 }, + { id: 4, rank: 40 }, + { id: 5, rank: 50 }, +] + +const cleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup() + } +}) + +function makeItemsCollection( + mockFetch: ReturnType, + syncMode?: 'on-demand' | 'eager', +) { + const queryClient = new QueryClient() + const collection = createCollection( + supabaseCollectionOptions({ + tableName: 'items', + keys: ['id'], + schema: itemsSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + queryClient, + syncMode, + }), + ) + cleanups.push(() => collection.cleanup()) + return { collection, queryClient } +} + +describe('syncMode: eager', () => { + test('fetches the whole table when the collection starts, paging past the server cap', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const { collection } = makeItemsCollection(mockFetch, 'eager') + + await collection.preload() + + expect(collection.size).toBe(ROWS.length) + // No subset params at all: no where/limit. The first read only sizes the + // server cap; because more pages follow, they are read again ordered by the + // key (the table is unordered, and offset paging needs a total order), with + // the offset advancing by the rows received on each page. + expect(getSearches(mockFetch)).toEqual([ + 'select=*', + 'select=*&order=id.asc&offset=0&limit=2', + 'select=*&order=id.asc&offset=2&limit=2', + 'select=*&order=id.asc&offset=4&limit=1', + ]) + }) + + test('requests count=exact on the first page only', async () => { + const mockFetch = makePaginatingFetch(ROWS, { cap: 2 }) + const { collection } = makeItemsCollection(mockFetch, 'eager') + + await collection.preload() + + const prefers = mockFetch.mock.calls.map((call) => + new Headers(call[1]?.headers).get('prefer'), + ) + expect(prefers[0]).toContain('count=exact') + for (const prefer of prefers.slice(1)) { + expect(prefer ?? '').not.toContain('count=') + } + }) + + test('live queries filter locally without issuing further requests', async () => { + const mockFetch = makePaginatingFetch(ROWS) + const { collection } = makeItemsCollection(mockFetch, 'eager') + + const live = createLiveQueryCollection((q) => + q.from({ item: collection }).where(({ item }) => gt(item.id, 3)), + ) + cleanups.push(() => live.cleanup()) + await live.preload() + + expect(live.toArray.map((item) => item.id).sort()).toEqual([4, 5]) + // Only the single eager fetch: the where clause is not pushed to PostgREST. + expect(getSearches(mockFetch)).toEqual(['select=*']) + }) + + test('keys the query by table name alone, whatever the live queries ask for', async () => { + const mockFetch = makePaginatingFetch(ROWS) + const { collection, queryClient } = makeItemsCollection(mockFetch, 'eager') + + const live = createLiveQueryCollection((q) => + q.from({ item: collection }).where(({ item }) => eq(item.id, 2)), + ) + cleanups.push(() => live.cleanup()) + await live.preload() + + expect( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryKey), + ).toEqual([['items']]) + }) + + test('mutations write through without refetching the table', async () => { + const mockFetch = makePaginatingFetch(ROWS) + const { collection } = makeItemsCollection(mockFetch, 'eager') + await collection.preload() + + const tx = collection.insert({ id: 6, rank: 60 }) + await tx.when('settled') + + expect(collection.get('6')).toMatchObject({ id: 6, rank: 60 }) + expect(getSearches(mockFetch)).toEqual(['select=*']) + }) + + test('on-demand remains the default: the where clause is pushed to PostgREST', async () => { + const mockFetch = makePaginatingFetch(ROWS) + const { collection, queryClient } = makeItemsCollection(mockFetch) + + const live = createLiveQueryCollection((q) => + q.from({ item: collection }).where(({ item }) => gt(item.id, 3)), + ) + cleanups.push(() => live.cleanup()) + await live.preload() + + expect(getSearches(mockFetch)).toEqual(['select=*&id=gt.3']) + expect( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryKey), + ).toEqual([['items', 'id=gt.3']]) + }) + + describe('realtime', () => { + test('subscribes to every change on the table even when filters are enabled', async () => { + const mockFetch = createMockFetch() + const mockChannel = createMockChannel() + const { collection } = createRealtimeUsersCollection( + mockFetch, + mockChannel, + { realtimeUseFilter: true, syncMode: 'eager' }, + ) + cleanups.push(() => collection.cleanup()) + + const live = createLiveQueryCollection((q) => + q.from({ user: collection }).where(({ user }) => eq(user.id, 1)), + ) + cleanups.push(() => live.cleanup()) + await live.preload() + await vi.waitFor(() => expect(mockChannel.on).toHaveBeenCalled()) + + expect(filtersFor(mockChannel, 'INSERT')).toEqual([null]) + expect(filtersFor(mockChannel, 'UPDATE')).toEqual([null]) + expect(filtersFor(mockChannel, 'DELETE')).toEqual([null]) + }) + }) +}) diff --git a/packages/supabase-db-collection/tests/test.utils.ts b/packages/supabase-db-collection/tests/test.utils.ts new file mode 100644 index 0000000000..0e26baaab7 --- /dev/null +++ b/packages/supabase-db-collection/tests/test.utils.ts @@ -0,0 +1,296 @@ +import { createClient } from '@supabase/supabase-js' +import { createCollection, liveQueryCollectionOptions } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { expect, vi } from 'vitest' +import { z } from 'zod' +import { supabaseCollectionOptions } from '../src/index' + +// --- Schemas --- + +export const usersSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + active: z.boolean(), +}) + +export const usersTodosSchema = z.object({ + user_id: z.number(), + todo_id: z.number(), +}) + +export const todosSchema = z.object({ + id: z.number(), + title: z.string(), + description: z.string(), + completed: z.boolean(), +}) + +// --- Mock infrastructure --- + +export const SUPABASE_URL = 'http://localhost:54321' +export const SUPABASE_KEY = 'test-key' + +export const mockResponses: Record> = { + users: [ + { id: 'user_1', name: 'Alice', email: 'alice@test.com', active: true }, + ], + users_todos: [{ user_id: 'user_1', todo_id: 'todo_1' }], + todos: [ + { + id: 'todo_1', + title: 'Buy milk', + description: 'From the store', + completed: false, + }, + ], +} + +export function createMockFetch() { + return vi.fn().mockImplementation((input, init) => { + const url = new URL(typeof input === 'string' ? input : input.toString()) + const table = url.pathname.replace('/rest/v1/', '') + const method = init?.method ?? 'GET' + + // For insert/update requests, echo the request body back as the + // "representation" so `.select().single()` has something schema-shaped + // to parse, mirroring what PostgREST would return. + if (method === 'POST' || method === 'PATCH') { + const body = init?.body ? JSON.parse(init.body as string) : {} + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + } + + const all = mockResponses[table] ?? [] + // Honour offset/limit so the pagination loop's follow-up pages terminate + // (a page past the data returns empty) instead of re-serving the same rows. + const params = url.searchParams + const offset = params.get('offset') ? Number(params.get('offset')) : 0 + const limit = params.get('limit') + const rows = all.slice( + offset, + limit === null ? undefined : offset + Number(limit), + ) + const end = offset + rows.length - 1 + return Promise.resolve( + new Response(JSON.stringify(rows), { + status: 200, + headers: { + 'content-type': 'application/json', + // The loop reads the total off Content-Range to size its paging; the + // format mirrors PostgREST's `-/`. + 'content-range': rows.length + ? `${offset}-${end}/${all.length}` + : `*/${all.length}`, + }, + }), + ) + }) +} + +/** Extract the headers of a captured `fetch` call as a `Headers` instance. */ +export function getRequestHeaders( + mockFetch: ReturnType, + callIndex = 0, +): Headers { + const init = mockFetch.mock.calls[callIndex]?.[1] + return new Headers(init?.headers) +} + +export function createMockedUsersCollection(mockFetch: typeof fetch) { + return createCollection( + supabaseCollectionOptions({ + tableName: 'users', + keys: ['id'], + schema: usersSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + }), + ) +} + +export function createMockedUsersTodosCollection(mockFetch: typeof fetch) { + return createCollection( + supabaseCollectionOptions({ + tableName: 'users_todos', + keys: ['user_id', 'todo_id'], + schema: usersTodosSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + }), + ) +} + +export function createMockedTodosCollection(mockFetch: typeof fetch) { + return createCollection( + supabaseCollectionOptions({ + tableName: 'todos', + keys: ['id'], + schema: todosSchema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }), + }), + ) +} + +// --- Realtime mock infrastructure --- + +type MockChannelOnCall = { + type: string + config: { event: string; schema: string; table: string; filter?: string } + handler: (payload: any) => void +} + +export type MockChannel = { + onCalls: Array + on: ReturnType + subscribe: ReturnType + /** + * Reports a subscription status (SUBSCRIBED by default) when the channel was + * created with `autoSubscribe: false`. + */ + confirmSubscribed: (status?: string) => void +} + +export function createMockChannel({ + autoSubscribe = true, +}: { + autoSubscribe?: boolean +} = {}): MockChannel { + const onCalls: Array = [] + let pending: ((status: string) => void) | undefined + + const channel: MockChannel = { + onCalls, + on: vi.fn((type: string, config: any, handler: (payload: any) => void) => { + onCalls.push({ type, config, handler }) + return channel + }), + // The adapter drives channel swaps and the rejection fallback off the + // subscription status, so the mock reports one like the real channel does. + subscribe: vi.fn((callback?: (status: string) => void) => { + if (autoSubscribe) { + callback?.('SUBSCRIBED') + } else { + pending = callback + } + return channel + }), + confirmSubscribed: (status = 'SUBSCRIBED') => { + pending?.(status) + pending = undefined + }, + } + return channel +} + +/** Returns the postgres_changes listeners registered for a given event. */ +export function listenersFor(mockChannel: MockChannel, event: string) { + return mockChannel.onCalls.filter((call) => call.config.event === event) +} + +/** The filter strings of a given event's listeners (null when unfiltered). */ +export function filtersFor( + mockChannel: MockChannel, + event: string, +): Array { + return listenersFor(mockChannel, event).map( + (call) => call.config.filter ?? null, + ) +} + +/** Dispatches a payload to every listener registered for its event type. */ +export function emit(mockChannel: MockChannel, payload: any) { + for (const call of listenersFor(mockChannel, payload.eventType)) { + call.handler(payload) + } +} + +export function createRealtimeUsersCollection( + mockFetch: typeof fetch, + mockChannel: MockChannel | (() => MockChannel), + options: { + realtimeUseFilter?: boolean + syncMode?: 'on-demand' | 'eager' + } = {}, +) { + // A fresh QueryClient keeps the module-level realtime registry in db.ts + // isolated per test. + const queryClient = new QueryClient() + const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: mockFetch }, + }) + // The real client would open a live WebSocket, so stub the realtime surface. + supabase.channel = vi.fn(() => + typeof mockChannel === 'function' ? mockChannel() : mockChannel, + ) as unknown as typeof supabase.channel + supabase.removeChannel = vi.fn() as unknown as typeof supabase.removeChannel + + const collection = createCollection( + supabaseCollectionOptions({ + tableName: 'users', + keys: ['id'], + schema: usersSchema, + supabase, + realtime: true, + realtimeUseFilter: options.realtimeUseFilter, + syncMode: options.syncMode, + queryClient, + }), + ) + + return { collection, supabase, queryClient } +} + +// --- Query helpers --- + +export async function queryResult( + queryFn: Parameters[0]['query'], +) { + const opts = liveQueryCollectionOptions({ query: queryFn }) + const collection = createCollection( + opts as Extract, + ) + await collection.preload() + const data = await collection.toArrayWhenReady() + collection.cleanup() + return data +} + +// --- Assertion helpers --- + +export function normalizeFetchUrl(raw: string | URL | Request): string { + const url = new URL(typeof raw === 'string' ? raw : raw.toString()) + url.searchParams.sort() + return `${url.pathname}${decodeURIComponent(url.search)}` +} + +export function expectFetchUrls( + mockFetch: ReturnType, + expectedPaths: Array, +) { + const actual = mockFetch.mock.calls.map(([url]) => normalizeFetchUrl(url)) + const expected = expectedPaths.map((p) => + normalizeFetchUrl(new URL(p, SUPABASE_URL)), + ) + expect([...actual]).toEqual([...expected]) +} + +/** + * Index into an array, failing loudly when the element is missing. Keeps test + * assertions concise under `noUncheckedIndexedAccess`. + */ +export function nth(items: ReadonlyArray, index: number): T { + const item = items[index] + if (item === undefined) { + throw new Error(`Expected an element at index ${index}`) + } + return item +} diff --git a/packages/supabase-db-collection/tsconfig.docs.json b/packages/supabase-db-collection/tsconfig.docs.json new file mode 100644 index 0000000000..5a73feb023 --- /dev/null +++ b/packages/supabase-db-collection/tsconfig.docs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@tanstack/db": ["../db/src"] + } + }, + "include": ["src"] +} diff --git a/packages/supabase-db-collection/tsconfig.json b/packages/supabase-db-collection/tsconfig.json new file mode 100644 index 0000000000..9e639d56a7 --- /dev/null +++ b/packages/supabase-db-collection/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2024", "DOM"], + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "paths": { + "@tanstack/store": ["../store/src"], + "@tanstack/db-ivm": ["../db-ivm/src"], + "@tanstack/db": ["../db/src"], + "@tanstack/query-db-collection": ["../query-db-collection/src"] + } + }, + "include": ["src", "tests", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/supabase-db-collection/vite.config.ts b/packages/supabase-db-collection/vite.config.ts new file mode 100644 index 0000000000..ac93446e74 --- /dev/null +++ b/packages/supabase-db-collection/vite.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + // Inlines the published version at build time (and under vitest) so the + // `X-Client-Info` header can never drift from package.json. + define: { + __SUPABASE_DB_COLLECTION_VERSION__: JSON.stringify(packageJson.version), + }, + test: { + name: packageJson.name, + include: [`tests/**/*.test.ts`], + exclude: [`e2e/**/*`], + // Not jsdom: `isServer` from query-core is false under jsdom, which makes + // `getQueryClient()` hand every collection the same module-level + // QueryClient, so cached queries leak between tests. + environment: `node`, + coverage: { enabled: true, provider: `istanbul`, include: [`src/**/*`] }, + typecheck: { + enabled: true, + include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: `./src/index.ts`, + srcDir: `./src`, + }), +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 995a1afb4f..d720080423 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1780,6 +1780,37 @@ importers: specifier: ^5.1.0 version: 5.1.0(@types/node@25.2.2)(@vitest/coverage-istanbul@5.1.0)(@vitest/ui@5.1.0)(jsdom@27.4.0)(vite@8.3.6(@types/node@25.2.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + packages/supabase-db-collection: + dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 + '@supabase/postgrest-js': + specifier: ^2.107.0 + version: 2.119.0 + '@tanstack/db': + specifier: workspace:* + version: link:../db + '@tanstack/query-db-collection': + specifier: workspace:* + version: link:../query-db-collection + typescript: + specifier: '>=4.7' + version: 5.9.3 + devDependencies: + '@supabase/supabase-js': + specifier: ^2.107.0 + version: 2.119.0 + '@tanstack/query-core': + specifier: ^5.90.20 + version: 5.107.1 + '@vitest/coverage-istanbul': + specifier: ^5.0.5 + version: 5.1.0(vitest@5.1.0) + zod: + specifier: ^4.6.7 + version: 4.6.7 + packages/svelte-db: dependencies: '@tanstack/db': @@ -6181,6 +6212,38 @@ packages: peerDependencies: eslint: '>=9.0.0' + '@supabase/auth-js@2.119.0': + resolution: {integrity: sha512-WI7KqJRxqZjm77PlRO/VfKretL4SG8g8stWfBmpyx9To+fFapZlSbXA8QkduoMvr72rRpIF24ZLrjktBrp+ZFA==} + engines: {node: '>=22.0.0'} + + '@supabase/functions-js@2.119.0': + resolution: {integrity: sha512-0aOdF/Y4lA+62K02nwZClfAthXmFEml0od42q3yy/9t3bBitomAOYEatTFSnxT/5UeeIDNn2DOnXTdAP0sqGNQ==} + engines: {node: '>=22.0.0'} + + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} + + '@supabase/postgrest-js@2.119.0': + resolution: {integrity: sha512-qBmcjEFO7Ptyf5qvOXYJa1s4bfA4zsMIFf2i+h2zd6RRTDLwquX8z1mnS9djNOJjbiO1OGFh5Y2Lc2k2pA8TMw==} + engines: {node: '>=22.0.0'} + + '@supabase/realtime-js@2.119.0': + resolution: {integrity: sha512-fPzxt+SAYweqsZUCSZma2dvN+HT6lVjwWj8BbkVwapW6wNf1odUi+ATYN7svX4Zm9vt0kz0l0VzINAUAA38FEg==} + engines: {node: '>=22.0.0'} + + '@supabase/storage-js@2.119.0': + resolution: {integrity: sha512-htx1dSCnSJVPlx2BNSgLSUDvLMxuGbYa6pUPl5xoCs4as4cIhBVwuxj78zbggCyPHxLnEXjsV/qvlDsVjpodKw==} + engines: {node: '>=22.0.0'} + + '@supabase/supabase-js@2.119.0': + resolution: {integrity: sha512-MKP49G6bJbPhvsZDegZ/IXS/ITApPgplDCF5+F65KZ5kIArS5Y90WFxwvWYALx2bsxdrSuHvkCiXHYr3dU8+sw==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@sveltejs/acorn-typescript@1.0.13': resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: @@ -9522,6 +9585,10 @@ packages: engines: {node: '>=18'} hasBin: true + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -15226,33 +15293,33 @@ snapshots: '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@emnapi/runtime@1.11.2': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@emnapi/runtime@1.11.3': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@emnapi/wasi-threads@1.1.0': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@emnapi/wasi-threads@1.2.2': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@esbuild-kit/core-utils@3.3.2': @@ -16754,14 +16821,14 @@ snapshots: dependencies: '@ionic/utils-terminal': 2.3.5 debug: 4.4.3 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color '@ionic/utils-array@2.1.6': dependencies: debug: 4.4.3 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color @@ -16770,14 +16837,14 @@ snapshots: '@types/fs-extra': 8.1.5 debug: 4.4.3 fs-extra: 9.1.0 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color '@ionic/utils-object@2.1.6': dependencies: debug: 4.4.3 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color @@ -16788,14 +16855,14 @@ snapshots: debug: 4.4.3 signal-exit: 3.0.7 tree-kill: 1.2.2 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color '@ionic/utils-stream@3.1.7': dependencies: debug: 4.4.3 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color @@ -16808,7 +16875,7 @@ snapshots: '@ionic/utils-terminal': 2.3.5 cross-spawn: 7.0.6 debug: 4.4.3 - tslib: 2.8.1 + tslib: 2.9.1 transitivePeerDependencies: - supports-color @@ -16820,7 +16887,7 @@ snapshots: slice-ansi: 4.0.0 string-width: 4.2.3 strip-ansi: 6.0.1 - tslib: 2.8.1 + tslib: 2.9.1 untildify: 4.0.0 wrap-ansi: 7.0.0 transitivePeerDependencies: @@ -18445,6 +18512,38 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.7 + '@supabase/auth-js@2.119.0': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.119.0': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.5': {} + + '@supabase/postgrest-js@2.119.0': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.119.0': + dependencies: + '@supabase/phoenix': 0.4.5 + tslib: 2.8.1 + + '@supabase/storage-js@2.119.0': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.119.0': + dependencies: + '@supabase/auth-js': 2.119.0 + '@supabase/functions-js': 2.119.0 + '@supabase/postgrest-js': 2.119.0 + '@supabase/realtime-js': 2.119.0 + '@supabase/storage-js': 2.119.0 + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': dependencies: acorn: 8.18.0 @@ -19090,12 +19189,12 @@ snapshots: '@tybys/wasm-util@0.10.1': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@tybys/wasm-util@0.10.4': dependencies: - tslib: 2.8.1 + tslib: 2.9.1 optional: true '@types/argparse@1.0.38': {} @@ -22567,6 +22666,8 @@ snapshots: husky@9.1.7: {} + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -23826,7 +23927,7 @@ snapshots: plist: 3.1.0 split2: 4.2.0 through2: 4.0.2 - tslib: 2.8.1 + tslib: 2.9.1 yauzl: 2.10.0 transitivePeerDependencies: - supports-color From 52f398e9d48b4ba887e4a240e0a7bfd65f8e94ef Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 10 Nov 2026 12:32:56 +0100 Subject: [PATCH 02/10] fix(supabase-db-collection): encode composite keys unambiguously 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) --- packages/supabase-db-collection/src/db.ts | 9 ++- .../tests/collection-key.test.ts | 70 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/supabase-db-collection/tests/collection-key.test.ts diff --git a/packages/supabase-db-collection/src/db.ts b/packages/supabase-db-collection/src/db.ts index 4537b16abe..0298f813f7 100644 --- a/packages/supabase-db-collection/src/db.ts +++ b/packages/supabase-db-collection/src/db.ts @@ -137,10 +137,13 @@ export const supabaseCollectionOptions = ({ type TItem = StandardSchemaV1.InferOutput - // Derive the collection key from the configured key columns. The values are - // always joined into a string, so a single numeric `id` of 6 becomes `'6'`. + // 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 => { - return keys.map((key) => item[key]).join('-') + const values = keys.map((key) => `${item[key] ?? ``}`) + return values.length === 1 ? values[0]! : JSON.stringify(values) } const conversions = { parse, serialize } as ColumnConversions diff --git a/packages/supabase-db-collection/tests/collection-key.test.ts b/packages/supabase-db-collection/tests/collection-key.test.ts new file mode 100644 index 0000000000..94b96742c4 --- /dev/null +++ b/packages/supabase-db-collection/tests/collection-key.test.ts @@ -0,0 +1,70 @@ +import { createClient } from '@supabase/supabase-js' +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect } from 'vitest' +import { z } from 'zod' +import { supabaseCollectionOptions } from '../src/index' +import { SUPABASE_KEY, SUPABASE_URL } from './test.utils' + +// Collection keys are built from the configured key columns, whose values are +// strings. Two rows must share a key exactly when every key column is equal; +// otherwise one row overwrites the other in the collection. + +const columns = [`a`, `b`, `c`] as const + +const getKeyFor = (keys: Array<(typeof columns)[number]>) => + supabaseCollectionOptions({ + tableName: `rows`, + keys, + schema: z.object({ a: z.string(), b: z.string(), c: z.string() }), + supabase: createClient(SUPABASE_URL, SUPABASE_KEY), + }).getKey + +const rowOf = (values: Array) => ({ + a: values[0] ?? ``, + b: values[1] ?? ``, + c: values[2] ?? ``, +}) + +// Short strings over separator-like characters. +const unit = fc.constantFrom(`a`, `-`, `"`, `\\`, `,`, `[`, `]`) +const value = fc.string({ unit, maxLength: 3 }) + +// Splits `text` into `width` parts at the given cut points. +const split = (text: string, cuts: Array): Array => { + const bounds = [0, ...cuts.sort((x, y) => x - y), text.length] + return bounds.slice(1).map((end, index) => text.slice(bounds[index], end)) +} + +// Two tuples of `width` values cut from the same text. Independently drawn +// tuples almost never line up, so this is what makes distinct tuples with +// overlapping contents (`['a-', 'b']` and `['a', '-b']`) common. Equal cuts +// give equal tuples. +const compositePair = fc + .tuple(fc.integer({ min: 2, max: 3 }), fc.string({ unit, maxLength: 6 })) + .chain(([width, text]) => { + const cuts = fc.array(fc.integer({ min: 0, max: text.length }), { + minLength: width - 1, + maxLength: width - 1, + }) + return fc + .tuple(cuts, cuts) + .map(([left, right]) => [split(text, left), split(text, right)] as const) + }) + +describe(`collection keys`, () => { + fcTest.prop([compositePair], { numRuns: 500 })( + `composite keys are equal exactly when every key column is equal`, + ([left, right]) => { + const getKey = getKeyFor(columns.slice(0, left.length)) + const sameKey = getKey(rowOf(left)) === getKey(rowOf(right)) + expect(sameKey).toBe(left.every((part, index) => part === right[index])) + }, + ) + + fcTest.prop([value], { numRuns: 100 })( + `a single key column is the column value itself`, + (id) => { + expect(getKeyFor([`a`])(rowOf([id]))).toBe(id) + }, + ) +}) From 3decdc0acc2dfa1e5150608e2b68bfc5f3cafb22 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 10 Nov 2026 12:44:02 +0100 Subject: [PATCH 03/10] fix(supabase-db-collection): keep query collection sync hooks 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) --- packages/supabase-db-collection/src/db.ts | 3 ++ .../tests/direct-writes.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 packages/supabase-db-collection/tests/direct-writes.test.ts diff --git a/packages/supabase-db-collection/src/db.ts b/packages/supabase-db-collection/src/db.ts index 0298f813f7..8faff98085 100644 --- a/packages/supabase-db-collection/src/db.ts +++ b/packages/supabase-db-collection/src/db.ts @@ -192,6 +192,9 @@ export const supabaseCollectionOptions = ({ return { ...config, 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 => { diff --git a/packages/supabase-db-collection/tests/direct-writes.test.ts b/packages/supabase-db-collection/tests/direct-writes.test.ts new file mode 100644 index 0000000000..1ee2b74b00 --- /dev/null +++ b/packages/supabase-db-collection/tests/direct-writes.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { createMockFetch, createMockedUsersCollection } from './test.utils' + +// query-db-collection starts an idle collection when one of its direct-write +// utils is called (its ownership-lifecycle tests own that law). The Supabase +// adapter wraps the query collection's sync config, so the wrapped collection +// must keep the behavior. + +describe(`direct writes on an idle collection`, () => { + let collection: ReturnType | undefined + + afterEach(async () => { + await collection?.cleanup() + collection = undefined + }) + + test(`a direct write starts sync and stores the row without fetching`, () => { + const mockFetch = createMockFetch() + collection = createMockedUsersCollection(mockFetch) + expect(collection.status).toBe(`idle`) + + const row = { id: 7, name: `Grace`, email: `grace@test.com`, active: true } + collection.utils.writeUpsert(row) + + expect(collection.status).toBe(`ready`) + expect(collection.get(`7`)).toMatchObject(row) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) From 8960682afed47ebb3e79310a84ee2bff042b067d Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 10 Nov 2026 12:48:43 +0100 Subject: [PATCH 04/10] fix(supabase-db-collection): read through a DbClient's queryClient 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) --- packages/supabase-db-collection/src/db.ts | 48 ++++++++++----- .../tests/db-client.test.ts | 61 +++++++++++++++++++ 2 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 packages/supabase-db-collection/tests/db-client.test.ts diff --git a/packages/supabase-db-collection/src/db.ts b/packages/supabase-db-collection/src/db.ts index 8faff98085..3f1de21178 100644 --- a/packages/supabase-db-collection/src/db.ts +++ b/packages/supabase-db-collection/src/db.ts @@ -1,4 +1,4 @@ -import { BasicIndex } from '@tanstack/db' +import { BasicIndex, withCollectionConfigFactory } from '@tanstack/db' import { queryCollectionOptions } from '@tanstack/query-db-collection' import { subsetOptionsToQueryKey, @@ -119,21 +119,23 @@ const registerTable = ( return entry } -export const supabaseCollectionOptions = ({ - tableName, - keys, - schema, - queryClient, - supabase, - realtime, - realtimeUseFilter = false, - syncMode = 'on-demand', - defaultStringCollation, - parse, - serialize, -}: SupabaseCollectionOptions) => { +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 - queryClient = queryClient ?? getQueryClient() + const queryClient = options.queryClient ?? getQueryClient() type TItem = StandardSchemaV1.InferOutput @@ -207,3 +209,19 @@ export const supabaseCollectionOptions = ({ }, } } + +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/tests/db-client.test.ts b/packages/supabase-db-collection/tests/db-client.test.ts new file mode 100644 index 0000000000..2049264ba5 --- /dev/null +++ b/packages/supabase-db-collection/tests/db-client.test.ts @@ -0,0 +1,61 @@ +import { createClient } from '@supabase/supabase-js' +import { DbClient, collectionOptions } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { afterEach, describe, expect, test } from 'vitest' +import { supabaseCollectionOptions } from '../src/index' +import { + SUPABASE_KEY, + SUPABASE_URL, + createMockFetch, + usersSchema, +} from './test.utils' + +// A collection descriptor is materialized once per DbClient. Like the query +// collection it builds on, a Supabase collection reads through the client's +// `queryClient` dependency, so each client's queries live in that client's +// cache rather than in the default QueryClient. + +describe(`DbClient materialization`, () => { + const cleanups: Array<() => Promise> = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + const createDbClient = () => { + const queryClient = new QueryClient() + const client = new DbClient({ queryClient }) + cleanups.push(async () => { + await client.cleanup() + queryClient.clear() + }) + return { client, queryClient } + } + + test(`each DbClient reads through its own queryClient`, async () => { + const descriptor = collectionOptions( + supabaseCollectionOptions({ + tableName: `users`, + keys: [`id`], + schema: usersSchema, + syncMode: `eager`, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: createMockFetch() }, + }), + }), + ) + + for (const { client, queryClient } of [ + createDbClient(), + createDbClient(), + ]) { + const collection = client.collection(descriptor) + await collection.preload() + + expect(collection.size).toBe(1) + expect( + queryClient.getQueryCache().findAll({ queryKey: [`users`] }), + ).not.toHaveLength(0) + } + }) +}) From e948b330952fbff047b8d43be62a9cf3b97e56c4 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 10 Nov 2026 12:54:56 +0100 Subject: [PATCH 05/10] fix(supabase-db-collection): combine abort signals without AbortSignal.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) --- .../supabase-db-collection/src/functions.ts | 25 ++++-- .../tests/split-load-fanout.test.ts | 89 ++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/packages/supabase-db-collection/src/functions.ts b/packages/supabase-db-collection/src/functions.ts index b3437bc9e7..7dadd9c2c1 100644 --- a/packages/supabase-db-collection/src/functions.ts +++ b/packages/supabase-db-collection/src/functions.ts @@ -293,11 +293,10 @@ function measureSubset( * matter and needs no dedupe. * * On the first failure, every other chunk's request is aborted through a - * local `AbortController` combined with the caller's own signal - * (`AbortSignal.any`) — 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. + * 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, @@ -306,8 +305,14 @@ async function loadChunks( chunks: Array, signal: AbortSignal, ): Promise> { + // Not `AbortSignal.any`: React Native (Hermes) and Safari before 17.4 lack it. const controller = new AbortController() - const combinedSignal = AbortSignal.any([signal, controller.signal]) + 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 @@ -320,7 +325,7 @@ async function loadChunks( tableName, keys, chunks[index]!, - combinedSignal, + controller.signal, ) } catch (error) { controller.abort() @@ -330,7 +335,11 @@ async function loadChunks( } const workerCount = Math.min(MAX_CONCURRENT_CHUNKS, chunks.length) - await Promise.all(Array.from({ length: workerCount }, runWorker)) + try { + await Promise.all(Array.from({ length: workerCount }, runWorker)) + } finally { + signal.removeEventListener('abort', abortFromCaller) + } return results.flat() } diff --git a/packages/supabase-db-collection/tests/split-load-fanout.test.ts b/packages/supabase-db-collection/tests/split-load-fanout.test.ts index 317f866aee..5d77e62796 100644 --- a/packages/supabase-db-collection/tests/split-load-fanout.test.ts +++ b/packages/supabase-db-collection/tests/split-load-fanout.test.ts @@ -1,7 +1,7 @@ import { createClient } from '@supabase/supabase-js' import { IR, and, inArray, not } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' -import { describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { MAX_URL_LENGTH, supabaseQueryFn } from '../src/functions' import { loadSubsetOptionsToSearch } from '../src/postgrest-filters' import { @@ -374,4 +374,91 @@ describe('supabaseQueryFn: chunked fan-out for an oversized IN list', () => { expect(rawUrl.length).toBeLessThanOrEqual(MAX_URL_LENGTH) } }) + + describe('abort signal plumbing', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + // Counts the caller's `abort` listeners still attached, so a test can + // check that the load removed what it added once it settled. + const liveAbortListeners = (signal: AbortSignal) => { + const add = vi.spyOn(signal, 'addEventListener') + const remove = vi.spyOn(signal, 'removeEventListener') + return () => { + const removed = new Set( + remove.mock.calls + .filter(([type]) => type === 'abort') + .map(([, listener]) => listener), + ) + return add.mock.calls.filter( + ([type, listener]) => type === 'abort' && !removed.has(listener), + ).length + } + } + + test('a split read works where AbortSignal.any is unavailable', async () => { + // React Native (Hermes) and Safari before 17.4 have no AbortSignal.any. + vi.spyOn(AbortSignal, 'any').mockImplementation(() => { + throw new TypeError('AbortSignal.any is not a function') + }) + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const signal = new AbortController().signal + const live = liveAbortListeners(signal) + + const rows = await run( + makePaginatingFetch(ids.map((id) => ({ id }))), + ids, + signal, + ) + + expect(rows).toHaveLength(ids.length) + expect(live()).toBe(0) + }) + + test("a caller abort reaches every chunk request with the caller's reason", async () => { + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const caller = new AbortController() + const reason = new Error('caller stopped') + const requestSignals: Array = [] + const hangingFetch = vi + .fn() + .mockImplementation((_input, init) => { + const signal = init!.signal! + requestSignals.push(signal) + // Like `fetch`, reject at once for a signal that is already aborted + // (supabase-js retries an aborted request with the same signal). + return new Promise((_resolve, reject) => { + if (signal.aborted) reject(signal.reason) + signal.addEventListener('abort', () => reject(signal.reason)) + }) + }) + const live = liveAbortListeners(caller.signal) + + const load = run(hangingFetch, ids, caller.signal) + await vi.waitFor(() => expect(requestSignals.length).toBeGreaterThan(1)) + caller.abort(reason) + + await expect(load).rejects.toThrow(reason.message) + for (const signal of requestSignals) { + expect(signal.reason).toBe(reason) + } + expect(live()).toBe(0) + }) + + test("a failed split read releases the caller's signal", async () => { + const ids = idsExceedingLength(MAX_URL_LENGTH * 1.5) + const signal = new AbortController().signal + const live = liveAbortListeners(signal) + const failingFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'boom' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ) + + await expect(run(failingFetch, ids, signal)).rejects.toBeDefined() + expect(live()).toBe(0) + }) + }) }) From f58781baaf95675740fce53daa090d40b7c3a8fc Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 10 Nov 2026 13:03:16 +0100 Subject: [PATCH 06/10] fix(supabase-db-collection): send null placement in the order param 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) --- .../src/postgrest-filters/common.ts | 13 +- .../subset-params-to-search.ts | 1 + .../tests/index.test.ts | 24 ++- .../tests/order-nulls.test.ts | 157 ++++++++++++++++++ .../tests/pagination.test.ts | 48 +++--- .../tests/postgrest-filters.test.ts | 10 +- .../tests/split-load-fanout.test.ts | 2 +- 7 files changed, 214 insertions(+), 41 deletions(-) create mode 100644 packages/supabase-db-collection/tests/order-nulls.test.ts diff --git a/packages/supabase-db-collection/src/postgrest-filters/common.ts b/packages/supabase-db-collection/src/postgrest-filters/common.ts index 78f5aa8182..a26683dc53 100644 --- a/packages/supabase-db-collection/src/postgrest-filters/common.ts +++ b/packages/supabase-db-collection/src/postgrest-filters/common.ts @@ -18,16 +18,23 @@ interface FilterOptions { stripAlias?: boolean } -/** Set the comma-joined `order` param (independent of limit/offset). */ +/** + * Set the comma-joined `order` param (independent of limit/offset). Null + * placement is always explicit: Postgres puts nulls last for `asc`, while + * TanStack DB puts them first unless told otherwise. + */ export function appendOrder( search: URLSearchParams, - sorts: Array<{ column: string; ascending: boolean }>, + sorts: Array<{ column: string; ascending: boolean; nulls: 'first' | 'last' }>, ): void { if (sorts.length === 0) return search.set( 'order', sorts - .map((sort) => `${sort.column}.${sort.ascending ? 'asc' : 'desc'}`) + .map( + (sort) => + `${sort.column}.${sort.ascending ? 'asc' : 'desc'}.nulls${sort.nulls}`, + ) .join(','), ) } diff --git a/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts b/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts index d6a87f83ad..7f13b05fcd 100644 --- a/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts +++ b/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts @@ -24,6 +24,7 @@ export function subsetParamsToSearch( parseOrderByExpression(orderBy).map((sort) => ({ column: sort.field.join('.'), ascending: sort.direction === 'asc', + nulls: sort.nulls, })), ) // `limit: 0` is a real, distinct subset (an empty window). Guard on diff --git a/packages/supabase-db-collection/tests/index.test.ts b/packages/supabase-db-collection/tests/index.test.ts index 94ecb0a107..969f4f9a3d 100644 --- a/packages/supabase-db-collection/tests/index.test.ts +++ b/packages/supabase-db-collection/tests/index.test.ts @@ -643,7 +643,9 @@ describe('PostgREST query generation', () => { await queryResult((q) => q.from({ user: usersCollection }).orderBy(({ user }) => user.name), ) - expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.asc']) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=name.asc.nullsfirst', + ]) }) test('explicit asc', async () => { @@ -652,7 +654,9 @@ describe('PostgREST query generation', () => { .from({ user: usersCollection }) .orderBy(({ user }) => user.name, 'asc'), ) - expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.asc']) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=name.asc.nullsfirst', + ]) }) test('descending', async () => { @@ -661,7 +665,9 @@ describe('PostgREST query generation', () => { .from({ user: usersCollection }) .orderBy(({ user }) => user.name, 'desc'), ) - expectFetchUrls(mockFetch, ['/rest/v1/users?select=*&order=name.desc']) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=name.desc.nullsfirst', + ]) }) test('multi-column orderBy', async () => { @@ -672,7 +678,7 @@ describe('PostgREST query generation', () => { .orderBy(({ user }) => user.id, 'desc'), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&order=name.asc,id.desc', + '/rest/v1/users?select=*&order=name.asc.nullsfirst,id.desc.nullsfirst', ]) }) }) @@ -686,7 +692,7 @@ describe('PostgREST query generation', () => { .limit(10), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&order=id.asc&limit=10', + '/rest/v1/users?select=*&order=id.asc.nullsfirst&limit=10', ]) }) }) @@ -701,7 +707,7 @@ describe('PostgREST query generation', () => { .offset(20), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&order=id.asc&limit=20&offset=20', + '/rest/v1/users?select=*&order=id.asc.nullsfirst&limit=20&offset=20', ]) }) }) @@ -774,7 +780,7 @@ describe('PostgREST query generation', () => { .limit(10), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&active=eq.true&order=name.asc&limit=10', + '/rest/v1/users?select=*&active=eq.true&order=name.asc.nullsfirst&limit=10', ]) }) @@ -786,7 +792,7 @@ describe('PostgREST query generation', () => { .orderBy(({ user }) => user.name, 'desc'), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&active=eq.true&order=name.desc', + '/rest/v1/users?select=*&active=eq.true&order=name.desc.nullsfirst', ]) }) @@ -799,7 +805,7 @@ describe('PostgREST query generation', () => { .limit(5), ) expectFetchUrls(mockFetch, [ - '/rest/v1/users?select=*&id=gt.3&order=id.asc&limit=5', + '/rest/v1/users?select=*&id=gt.3&order=id.asc.nullsfirst&limit=5', ]) }) diff --git a/packages/supabase-db-collection/tests/order-nulls.test.ts b/packages/supabase-db-collection/tests/order-nulls.test.ts new file mode 100644 index 0000000000..72bb530376 --- /dev/null +++ b/packages/supabase-db-collection/tests/order-nulls.test.ts @@ -0,0 +1,157 @@ +import { createClient } from '@supabase/supabase-js' +import { createCollection, localOnlyCollectionOptions } from '@tanstack/db' +import { describe, expect, test, vi } from 'vitest' +import { z } from 'zod' +import { supabaseCollectionOptions } from '../src/index' +import { SUPABASE_KEY, SUPABASE_URL, queryResult } from './test.utils' + +// A limited, ordered live query over an on-demand collection only sees the +// rows the server sends for that window. The server must therefore cut the +// window from the same order the client applies, including where nulls sort. +// TanStack DB places nulls first unless told otherwise, while Postgres places +// them last for ascending order, so the adapter has to say where they go. +// +// The reference is the same live query over a local-only collection holding +// every row: TanStack DB's own in-memory ordering, with no server involved. + +type Row = { id: number; rank: number | null } + +const rows: Array = [ + { id: 1, rank: 2 }, + { id: 2, rank: null }, + { id: 3, rank: 1 }, + { id: 4, rank: null }, + { id: 5, rank: 3 }, +] + +const schema = z.object({ id: z.number(), rank: z.number().nullable() }) + +/** + * Orders rows the way PostgREST does for an `order` param of + * `col.dir[.nullsfirst|.nullslast]` sorts: Postgres puts nulls last for `asc` + * and first for `desc` unless the sort names a placement. + */ +function postgresOrder(order: string) { + const sorts = order.split(`,`).map((sort) => { + const [column = ``, direction = `asc`, nulls] = sort.split(`.`) + const nullsFirst = + nulls === undefined ? direction === `desc` : nulls === `nullsfirst` + return { + column: column as keyof Row, + descending: direction === `desc`, + nullsFirst, + } + }) + return (a: Row, b: Row): number => { + for (const { column, descending, nullsFirst } of sorts) { + const left = a[column] + const right = b[column] + if (left === right) continue + if (left === null) return nullsFirst ? -1 : 1 + if (right === null) return nullsFirst ? 1 : -1 + return (left < right ? -1 : 1) * (descending ? -1 : 1) + } + return 0 + } +} + +const comparisons: Record boolean> = + { + eq: (value, target) => value === target, + gt: (value, target) => value > target, + gte: (value, target) => value >= target, + lt: (value, target) => value < target, + lte: (value, target) => value <= target, + } + +/** + * Keeps the rows a `col=op.value` filter matches, with SQL semantics: a null + * column matches only `is.null`. A filter the mock does not know throws, so a + * request it cannot serve fails the test instead of returning every row. + */ +function matches(row: Row, column: string, filter: string): boolean { + const [op = ``, operand = ``] = filter.split(/\.(.*)/s) + const value = row[column as keyof Row] + if (op === `is` && operand === `null`) return value === null + const compare = comparisons[op] + if (!compare) throw new Error(`mock cannot serve filter ${column}=${filter}`) + return value !== null && compare(value, Number(operand)) +} + +/** Serves `rows` for `order`, `limit` and simple column filters. */ +const postgrestFetch = () => + vi.fn().mockImplementation((input) => { + const params = new URL(String(input)).searchParams + let selected = [...rows] + for (const [name, filter] of params) { + if ([`select`, `order`, `limit`, `offset`].includes(name)) continue + selected = selected.filter((row) => matches(row, name, filter)) + } + const order = params.get(`order`) + if (order) selected.sort(postgresOrder(order)) + const total = selected.length + const limit = params.get(`limit`) + const body = limit === null ? selected : selected.slice(0, Number(limit)) + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { + 'content-type': `application/json`, + 'content-range': body.length + ? `0-${body.length - 1}/${total}` + : `*/${total}`, + }, + }), + ) + }) + +const cases = [ + { direction: `asc`, nulls: undefined }, + { direction: `asc`, nulls: `first` }, + { direction: `asc`, nulls: `last` }, + { direction: `desc`, nulls: undefined }, + { direction: `desc`, nulls: `first` }, + { direction: `desc`, nulls: `last` }, +] as const + +describe(`ordering with nulls`, () => { + test.each(cases)( + `a limited window ordered $direction with nulls $nulls matches in-memory ordering`, + async ({ direction, nulls }) => { + const supabaseRows = createCollection( + supabaseCollectionOptions({ + tableName: `items`, + keys: [`id`], + schema, + supabase: createClient(SUPABASE_URL, SUPABASE_KEY, { + global: { fetch: postgrestFetch() }, + }), + }), + ) + const localRows = createCollection( + localOnlyCollectionOptions({ + id: `local-items-${direction}-${nulls}`, + getKey: (row) => String(row.id), + initialData: rows, + }), + ) + const window = async (collection: typeof localRows) => + ( + await queryResult((q) => + q + .from({ item: collection }) + .orderBy(({ item }) => item.rank, { direction, nulls }) + .limit(2) + .select(({ item }) => ({ id: item.id })), + ) + ).map((row) => row.id) + + const expected = await window(localRows) + const actual = await window(supabaseRows as unknown as typeof localRows) + + expect(actual).toEqual(expected) + await supabaseRows.cleanup() + await localRows.cleanup() + }, + ) +}) diff --git a/packages/supabase-db-collection/tests/pagination.test.ts b/packages/supabase-db-collection/tests/pagination.test.ts index dc4ffe5421..5c30d481b0 100644 --- a/packages/supabase-db-collection/tests/pagination.test.ts +++ b/packages/supabase-db-collection/tests/pagination.test.ts @@ -121,7 +121,7 @@ describe('live infinite query (windowed) pagination', () => { // The first page is a plain ordered window (one peek row past the page) // plus core's boundary probe — no keyset cursor (`gt`) and no offset yet. expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc&limit=3', // window + 1 peek row + 'select=*&order=id.asc.nullsfirst&limit=3', // window + 1 peek row 'select=*&id=eq.3', // boundary probe (loadBoundary) settles the peek ]) }) @@ -146,10 +146,10 @@ describe('live infinite query (windowed) pagination', () => { // request from the boundary (id 3): ordered, limited, and — crucially — // with no offset re-skipping rows. No request carries an offset. expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc&limit=3', // page 1: window + 1 peek row + 'select=*&order=id.asc.nullsfirst&limit=3', // page 1: window + 1 peek row 'select=*&id=eq.3', // boundary probe (loadBoundary) settles the peek - 'select=*&order=id.asc&id=eq.3', // page 2: whereCurrent tie read - 'select=*&order=id.asc&limit=2&id=gt.3', // page 2: whereFrom keyset read + 'select=*&order=id.asc.nullsfirst&id=eq.3', // page 2: whereCurrent tie read + 'select=*&order=id.asc.nullsfirst&limit=2&id=gt.3', // page 2: whereFrom keyset read 'select=*&id=eq.5', // boundary probe for the next peek ]) }) @@ -184,10 +184,10 @@ describe('live infinite query (windowed) pagination', () => { // the window path; see the module note. The adapter emits it regardless // because it honors the cursor contract.) expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=rank.asc&limit=3', // page 1: window + 1 peek row + 'select=*&order=rank.asc.nullsfirst&limit=3', // page 1: window + 1 peek row 'select=*&rank=eq.20', // boundary probe (loadBoundary): full tie class - 'select=*&order=rank.asc&rank=eq.20', // page 2: whereCurrent tie read - 'select=*&order=rank.asc&limit=1&rank=gt.20', // page 2: whereFrom keyset read + 'select=*&order=rank.asc.nullsfirst&rank=eq.20', // page 2: whereCurrent tie read + 'select=*&order=rank.asc.nullsfirst&limit=1&rank=gt.20', // page 2: whereFrom keyset read 'select=*&rank=eq.30', // boundary probe for the next peek ]) }) @@ -239,9 +239,9 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { // and each later page advances the offset by the rows received, capped by // the rows still owed (the last page asks for just 1). expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc', - 'select=*&order=id.asc&offset=2&limit=2', - 'select=*&order=id.asc&offset=4&limit=1', + 'select=*&order=id.asc.nullslast', + 'select=*&order=id.asc.nullslast&offset=2&limit=2', + 'select=*&order=id.asc.nullslast&offset=4&limit=1', ]) }) @@ -265,8 +265,8 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { ]) // Two requests, not three: the count tells the loop it is done at offset 4. expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc', - 'select=*&order=id.asc&offset=2&limit=2', + 'select=*&order=id.asc.nullslast', + 'select=*&order=id.asc.nullslast&offset=2&limit=2', ]) }) @@ -281,8 +281,8 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { 3, 4, 5, 6, ]) expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc&offset=2', - 'select=*&order=id.asc&offset=4&limit=2', + 'select=*&order=id.asc.nullslast&offset=2', + 'select=*&order=id.asc.nullslast&offset=4&limit=2', ]) }) @@ -291,7 +291,7 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { const rows = await run(mockFetch, { orderBy: ORDER_BY }) expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([1, 2]) - expect(getSearches(mockFetch)).toEqual(['select=*&order=id.asc']) + expect(getSearches(mockFetch)).toEqual(['select=*&order=id.asc.nullslast']) }) test('the loop owns limit: a caller limit above the cap still pages', async () => { @@ -301,8 +301,8 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { // Exactly the caller's 3 rows, fetched across cap-sized pages. expect((rows as Array<{ id: number }>).map((r) => r.id)).toEqual([1, 2, 3]) expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=id.asc&limit=3', - 'select=*&order=id.asc&limit=1&offset=2', + 'select=*&order=id.asc.nullslast&limit=3', + 'select=*&order=id.asc.nullslast&limit=1&offset=2', ]) }) @@ -396,10 +396,10 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { await readTied(mockFetch, ['id']) expect(getSearches(mockFetch)).toEqual([ - 'select=*&order=rank.asc', - 'select=*&order=rank.asc,id.asc&offset=0&limit=2', - 'select=*&order=rank.asc,id.asc&offset=2&limit=2', - 'select=*&order=rank.asc,id.asc&offset=4&limit=2', + 'select=*&order=rank.asc.nullslast', + 'select=*&order=rank.asc.nullslast,id.asc&offset=0&limit=2', + 'select=*&order=rank.asc.nullslast,id.asc&offset=2&limit=2', + 'select=*&order=rank.asc.nullslast,id.asc&offset=4&limit=2', ]) }) @@ -421,7 +421,7 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { // `rank` is already sorted by, so only `id` is added. expect(getSearches(mockFetch)[1]).toBe( - 'select=*&order=rank.asc,id.asc&offset=0&limit=2', + 'select=*&order=rank.asc.nullslast,id.asc&offset=0&limit=2', ) }) @@ -429,7 +429,9 @@ describe('db-max-rows paging loop (supabaseQueryFn)', () => { const mockFetch = makeTieShufflingFetch(TIED.slice(0, 2), 2) await readTied(mockFetch, ['id']) - expect(getSearches(mockFetch)).toEqual(['select=*&order=rank.asc']) + expect(getSearches(mockFetch)).toEqual([ + 'select=*&order=rank.asc.nullslast', + ]) }) }) diff --git a/packages/supabase-db-collection/tests/postgrest-filters.test.ts b/packages/supabase-db-collection/tests/postgrest-filters.test.ts index ffe80b0491..11b59650e2 100644 --- a/packages/supabase-db-collection/tests/postgrest-filters.test.ts +++ b/packages/supabase-db-collection/tests/postgrest-filters.test.ts @@ -239,17 +239,17 @@ describe('subsetParamsToSearch', () => { test('appends a single order direction', () => { expect( subsetParamsToSearch({ orderBy: [sort(id, 'asc')] }).get('order'), - ).toBe('id.asc') + ).toBe('id.asc.nullslast') expect( subsetParamsToSearch({ orderBy: [sort(id, 'desc')] }).get('order'), - ).toBe('id.desc') + ).toBe('id.desc.nullslast') }) test('merges multiple order columns into one comma-joined value', () => { const search = subsetParamsToSearch({ orderBy: [sort(id, 'asc'), sort(name, 'desc')], }) - expect(search.get('order')).toBe('id.asc,name.desc') + expect(search.get('order')).toBe('id.asc.nullslast,name.desc.nullslast') expect(search.getAll('order')).toHaveLength(1) }) @@ -297,7 +297,7 @@ describe('loadSubsetOptionsToSearch', () => { limit: 5, }) expect(search.get('id')).toBe('eq.1') - expect(search.get('order')).toBe('id.asc') + expect(search.get('order')).toBe('id.asc.nullslast') expect(search.get('limit')).toBe('5') }) @@ -375,7 +375,7 @@ describe('cursorCurrentToSearch', () => { cursor: { whereFrom: gt(id, 40), whereCurrent: eq(id, 40) }, }) expect(search?.get('active')).toBe('eq.true') - expect(search?.get('order')).toBe('id.asc') + expect(search?.get('order')).toBe('id.asc.nullslast') expect(search?.get('id')).toBe('eq.40') expect(search?.has('limit')).toBe(false) }) diff --git a/packages/supabase-db-collection/tests/split-load-fanout.test.ts b/packages/supabase-db-collection/tests/split-load-fanout.test.ts index 5d77e62796..502bf1bab0 100644 --- a/packages/supabase-db-collection/tests/split-load-fanout.test.ts +++ b/packages/supabase-db-collection/tests/split-load-fanout.test.ts @@ -367,7 +367,7 @@ describe('supabaseQueryFn: chunked fan-out for an oversized IN list', () => { // Multi-page chunks are read in key order after the query's own order. for (const search of pagedSearches) { expect(search).toContain( - 'order=rank.asc,organization_identifier_column.asc,id.asc', + 'order=rank.asc.nullslast,organization_identifier_column.asc,id.asc', ) } for (const rawUrl of getRawUrls(mockFetch)) { From dbc2b1f355cf857e445b4858c8ff6adce87c8c6d Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 11 Nov 2026 14:41:10 +0100 Subject: [PATCH 07/10] chore(supabase-db-collection): align dev dependencies with main Match the workspace's @tanstack/query-core and @vitest/coverage-istanbul dev versions, which main bumped, so sherif passes. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/supabase-db-collection/package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/supabase-db-collection/package.json b/packages/supabase-db-collection/package.json index df3302ed21..c0cbfa9cbd 100644 --- a/packages/supabase-db-collection/package.json +++ b/packages/supabase-db-collection/package.json @@ -59,8 +59,8 @@ }, "devDependencies": { "@supabase/supabase-js": "^2.107.0", - "@tanstack/query-core": "^5.90.20", - "@vitest/coverage-istanbul": "^5.0.5", + "@tanstack/query-core": "^5.107.1", + "@vitest/coverage-istanbul": "^5.1.0", "zod": "^4.6.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d720080423..d144d41661 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1802,10 +1802,10 @@ importers: specifier: ^2.107.0 version: 2.119.0 '@tanstack/query-core': - specifier: ^5.90.20 + specifier: ^5.107.1 version: 5.107.1 '@vitest/coverage-istanbul': - specifier: ^5.0.5 + specifier: ^5.1.0 version: 5.1.0(vitest@5.1.0) zod: specifier: ^4.6.7 @@ -6387,7 +6387,7 @@ packages: eslint: ^8.0.0 || ^9.0.0 '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a': - resolution: {integrity: sha512-qjW5wQbicLQTID6iILaEn632NuTI+l58ZMRDTv4+EFlLOJe2NWG2CTrk3tyq1vVVkxOuTTfiJ+4dBqya3gAD+g==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a} + resolution: {tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a} version: 0.0.0 engines: {node: '>=20'} peerDependencies: @@ -6398,7 +6398,7 @@ packages: engines: {node: '>=20.19'} '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a': - resolution: {integrity: sha512-i+D+r7zcr1yHsrp8GzLYrbJ4F+Nxg+gfPQO42XcRJQfgBdHyoHW1YywFinC6RD2uyaWqhFgBhTmuvl2Rf0qOPA==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a} + resolution: {tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a} version: 0.0.0 engines: {node: '>=20'} hasBin: true From 53de962c1c7534e7b45a01dd468a1372ac31d3a1 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 11 Nov 2026 16:28:26 +0100 Subject: [PATCH 08/10] fix(supabase-db-collection): reject a collection without key columns Updates and deletes match their row by the key columns. With `keys: []` the request had no row filter, so deleting one row sent an unfiltered DELETE that PostgREST applied to every row the policy allows. Creating such a collection now throws. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/supabase-db-collection/src/db.ts | 8 ++++++++ .../supabase-db-collection/tests/collection-key.test.ts | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/supabase-db-collection/src/db.ts b/packages/supabase-db-collection/src/db.ts index 3f1de21178..1e63f12210 100644 --- a/packages/supabase-db-collection/src/db.ts +++ b/packages/supabase-db-collection/src/db.ts @@ -134,6 +134,14 @@ const createSupabaseConfig = ( parse, serialize, } = options + // Updates and deletes match their row by the key columns. Without any, the + // request has no row filter and PostgREST applies it to every row the policy + // allows. + if (keys.length === 0) { + throw new Error( + `supabaseCollectionOptions for "${tableName}" needs at least one key column in \`keys\``, + ) + } // if the query client is not provided, use the global query client const queryClient = options.queryClient ?? getQueryClient() diff --git a/packages/supabase-db-collection/tests/collection-key.test.ts b/packages/supabase-db-collection/tests/collection-key.test.ts index 94b96742c4..20cd9614c6 100644 --- a/packages/supabase-db-collection/tests/collection-key.test.ts +++ b/packages/supabase-db-collection/tests/collection-key.test.ts @@ -1,6 +1,6 @@ import { createClient } from '@supabase/supabase-js' import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect } from 'vitest' +import { describe, expect, test } from 'vitest' import { z } from 'zod' import { supabaseCollectionOptions } from '../src/index' import { SUPABASE_KEY, SUPABASE_URL } from './test.utils' @@ -61,6 +61,12 @@ describe(`collection keys`, () => { }, ) + test(`a collection without key columns is rejected`, () => { + // With no key columns an update or delete would have no row filter, and + // PostgREST would apply it to every row the policy allows. + expect(() => getKeyFor([])).toThrow(/at least one key column/) + }) + fcTest.prop([value], { numRuns: 100 })( `a single key column is the column value itself`, (id) => { From 13700ca4687560e5d9349ce837ab556ae56dcd78 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 11 Nov 2026 16:28:32 +0100 Subject: [PATCH 09/10] fix(supabase-db-collection): send only changed columns in updates The PATCH body was the whole client-side row, so an update overwrote newer values that other clients had written to columns it did not change, and failed on columns the role cannot update, such as GENERATED ALWAYS columns. The body is now just the mutation's changes; the row is still matched by the original key values. Test servers now return the whole stored row for a PATCH, as PostgREST does, instead of echoing the request body. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../supabase-db-collection/src/functions.ts | 4 +- .../tests/row-conversions.test.ts | 53 ++++++++++++++----- .../tests/test.utils.ts | 22 ++++++-- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/supabase-db-collection/src/functions.ts b/packages/supabase-db-collection/src/functions.ts index 7dadd9c2c1..6f41662424 100644 --- a/packages/supabase-db-collection/src/functions.ts +++ b/packages/supabase-db-collection/src/functions.ts @@ -432,7 +432,9 @@ export const supabaseOnUpdate = async ( const { data } = await postgrestRequest(supabase, tableName, { method: 'PATCH', search, - body: serializeRow({ ...original, ...changes }, conversions), + // Only the changed columns: resending the rest would overwrite newer + // values from other clients and fail on columns the role cannot update. + body: serializeRow(changes, conversions), returnRows: true, single: true, }) diff --git a/packages/supabase-db-collection/tests/row-conversions.test.ts b/packages/supabase-db-collection/tests/row-conversions.test.ts index 809aeda6f8..6a2e297945 100644 --- a/packages/supabase-db-collection/tests/row-conversions.test.ts +++ b/packages/supabase-db-collection/tests/row-conversions.test.ts @@ -59,9 +59,20 @@ function makeServer(rows: Array) { return Promise.resolve(json(rows, `0-${rows.length - 1}/${rows.length}`)) } const body = JSON.parse(String(init?.body ?? '{}')) - writes.push({ method, search: new URL(String(input)).search, body }) - // PATCH and POST return the stored row, as `Prefer: return=representation` - // asks: bigints as JSON numbers, with a column the server filled in. + const search = new URL(String(input)).search + writes.push({ method, search, body }) + if (method === 'PATCH') { + // PATCH writes only the columns in its body to the stored row and returns + // the whole row, as `Prefer: return=representation` asks. + const id = Number( + new URLSearchParams(search).get('id')?.replace('eq.', ''), + ) + const stored = rows.find((row) => row.id === id)! + Object.assign(stored, body, { id }) + return Promise.resolve(json(stored)) + } + // POST returns the stored row, as `Prefer: return=representation` asks: + // bigints as JSON numbers, with a column the server filled in. return Promise.resolve( json({ ...body, id: Number(body.id ?? 1), endsAt: body.endsAt ?? null }), ) @@ -185,7 +196,7 @@ describe('parse and serialize', () => { }) }) - test('an update matches the row by its serialized key and sends serialized columns', async () => { + test('an update matches the row by its serialized key and sends only the changed columns, serialized', async () => { const { collection, writes } = makeEvents([ { id: 7, @@ -206,16 +217,34 @@ describe('parse and serialize', () => { { method: 'PATCH', search: '?id=eq.7&select=*', - body: { - id: '7', - title: 'Relaunch', - startsAt: '2024-05-06T07:08:09.123Z', - endsAt: '2024-05-07T07:08:09.123Z', - tags: ['a', 'b'], - }, + body: { title: 'Relaunch' }, }, ]) - expect(collection.get('7')?.title).toBe('Relaunch') + expect(collection.get('7')).toMatchObject({ + title: 'Relaunch', + startsAt: new Date(START), + tags: new Set(['a', 'b']), + }) + }) + + test('an update keeps the server values of the columns it did not change', async () => { + const rows: Array = [ + { id: 7, title: 'Launch', startsAt: START, endsAt: END, tags: ['a'] }, + ] + const { collection, writes } = makeEvents(rows) + await collection.preload() + // Another client moves the end time after this collection read the row. + const LATER = '2024-05-08T07:08:09.123+00:00' + rows[0]!.endsAt = LATER + + const tx = collection.update('7', (draft) => { + draft.title = 'Relaunch' + }) + await tx.when('settled') + + expect(writes.map((write) => write.body)).toEqual([{ title: 'Relaunch' }]) + expect(rows[0]!.endsAt).toBe(LATER) + expect(collection.get('7')?.endsAt).toEqual(new Date(LATER)) }) test('a delete matches the row by its serialized key', async () => { diff --git a/packages/supabase-db-collection/tests/test.utils.ts b/packages/supabase-db-collection/tests/test.utils.ts index 0e26baaab7..4c4fed1448 100644 --- a/packages/supabase-db-collection/tests/test.utils.ts +++ b/packages/supabase-db-collection/tests/test.utils.ts @@ -47,18 +47,32 @@ export const mockResponses: Record> = { } export function createMockFetch() { + // Rows inserted or updated through this mock, by table and `id`, so an + // update can return the whole stored row as PostgREST does. + const written = new Map>() return vi.fn().mockImplementation((input, init) => { const url = new URL(typeof input === 'string' ? input : input.toString()) const table = url.pathname.replace('/rest/v1/', '') const method = init?.method ?? 'GET' - // For insert/update requests, echo the request body back as the - // "representation" so `.select().single()` has something schema-shaped - // to parse, mirroring what PostgREST would return. + // Insert and update requests return the stored row as the + // "representation", so `.select().single()` has something schema-shaped to + // parse. An insert stores its body; an update writes its body's columns + // over the stored row it matches by `id`. if (method === 'POST' || method === 'PATCH') { const body = init?.body ? JSON.parse(init.body as string) : {} + const id = + method === 'POST' ? body.id : url.searchParams.get('id')?.slice(3) + const storedKey = `${table}:${id}` + const stored = + method === 'PATCH' + ? (written.get(storedKey) ?? + mockResponses[table]?.find((row) => `${row.id}` === `${id}`)) + : undefined + const row = { ...stored, ...body } + written.set(storedKey, row) return Promise.resolve( - new Response(JSON.stringify(body), { + new Response(JSON.stringify(row), { status: 200, headers: { 'content-type': 'application/json' }, }), From 449611148bbe59e13f4849c6a6d16e4269b5c694 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 11 Nov 2026 16:28:36 +0100 Subject: [PATCH 10/10] ci: add supabase-db-collection to the collections test group scripts/ci-tests.mjs check requires every package with a test script to be in exactly one CI group, and fails before every CI test command otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci-tests.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ci-tests.mjs b/scripts/ci-tests.mjs index a5f96486ca..049c2205ab 100644 --- a/scripts/ci-tests.mjs +++ b/scripts/ci-tests.mjs @@ -34,6 +34,7 @@ const groups = { `@tanstack/query-db-collection`, `@tanstack/powersync-db-collection`, `@tanstack/rxdb-db-collection`, + `@tanstack/supabase-db-collection`, `@tanstack/trailbase-db-collection`, `@tanstack/db-collection-e2e`, `@tanstack/db-adapter-conformance`,