diff --git a/packages/supabase-db-collection/package.json b/packages/supabase-db-collection/package.json new file mode 100644 index 0000000000..c0cbfa9cbd --- /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.107.1", + "@vitest/coverage-istanbul": "^5.1.0", + "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..1e63f12210 --- /dev/null +++ b/packages/supabase-db-collection/src/db.ts @@ -0,0 +1,235 @@ +import { BasicIndex, withCollectionConfigFactory } from '@tanstack/db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { + subsetOptionsToQueryKey, + supabaseOnDelete, + supabaseOnInsert, + supabaseOnUpdate, + supabaseQueryFn, +} from './functions' +import { getQueryClient } from './query-client' +import { syncTableSubscription } from './realtime' +import type { TableEntry } from './realtime' +import type { ColumnConversions, RowConversions } from './row-conversions' +import type { 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 +} + +const createSupabaseConfig = ( + options: SupabaseCollectionOptions, +) => { + const { + tableName, + keys, + schema, + supabase, + realtime, + realtimeUseFilter = false, + syncMode = 'on-demand', + defaultStringCollation, + 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() + + type TItem = StandardSchemaV1.InferOutput + + // Derive the collection key from the configured key columns. A single column + // keeps its string form (`6` becomes `'6'`). Composite keys encode each + // value's string form as a JSON array, so distinct tuples cannot share a key + // the way `'-'` joining let `['a-b', 'c']` and `['a', 'b-c']` do. + const getKey = (item: TItem): string | number => { + const values = keys.map((key) => `${item[key] ?? ``}`) + return values.length === 1 ? values[0]! : JSON.stringify(values) + } + + const conversions = { parse, serialize } as ColumnConversions + + // Key columns used to match rows on update and delete. + const keyColumns = keys as Array + + // A server-side Supabase client has no `channel`, so Realtime is a no-op there + // and the table is never registered. Checking here keeps attachSupabaseListeners + // able to always return a live subscription. + let entry: TableEntry | null = null + if (realtime && typeof supabase.channel === 'function') { + entry = registerTable(queryClient, tableName, supabase, realtimeUseFilter) + } + const config = queryCollectionOptions({ + id: tableName, + queryClient, + getKey, + schema, + queryKey: (ctx) => subsetOptionsToQueryKey(tableName, ctx), + syncMode, + // Known limitation: the initial fetch does not wait for the Realtime channel + // to finish subscribing. A row written in the window between this fetch and + // the subscription going live can be missed by both — the fetch ran before + // the row existed, and the subscription started after the change was + // published. Gating the fetch on the subscription closes this gap but couples + // every first load to Realtime connect latency, so it is intentionally left + // out and tracked separately. + queryFn: (ctx) => + supabaseQueryFn(supabase, tableName, ctx, { + keys: keyColumns, + conversions, + }), + onInsert: (ctx) => supabaseOnInsert(supabase, tableName, ctx, conversions), + onUpdate: (ctx) => + supabaseOnUpdate(supabase, tableName, keyColumns, ctx, conversions), + onDelete: (ctx) => + supabaseOnDelete(supabase, tableName, keyColumns, ctx, conversions), + autoIndex: 'eager', + defaultIndexType: BasicIndex, + defaultStringCollation, + }) + + const originalSync = config.sync.sync + + return { + ...config, + sync: { + // Keeps the hooks query-db-collection attaches to its sync config, such + // as the factory that lets a direct write start an idle collection. + ...config.sync, + sync: ( + ...args: Parameters + ): ReturnType => { + if (entry) { + entry.collectionRef = args[0].collection + entry.conversions = conversions + } + return originalSync(...args) + }, + }, + } +} + +export const supabaseCollectionOptions = ( + options: SupabaseCollectionOptions, +): ReturnType> => + // Each DbClient materializes its own config, reading through the client's + // `queryClient` dependency the way query-db-collection does. The config above + // spreads the query collection's options, which drops that collection's own + // factory; it would also skip the Realtime wrapper, so this one rebuilds the + // whole Supabase config. + withCollectionConfigFactory(createSupabaseConfig(options), (client) => + createSupabaseConfig({ + ...options, + queryClient: + client.getDependency(`queryClient`) ?? options.queryClient, + }), + ) diff --git a/packages/supabase-db-collection/src/functions.ts b/packages/supabase-db-collection/src/functions.ts new file mode 100644 index 0000000000..6f41662424 --- /dev/null +++ b/packages/supabase-db-collection/src/functions.ts @@ -0,0 +1,487 @@ +import { DeleteOperationItemNotFoundError } from '@tanstack/query-db-collection' +import { + cursorCurrentToSearch, + cursorWhereFromToSearch, + keyColumnsToSearch, + loadSubsetOptionsToSearch, + splitLoadSubsetOptions, + subsetParamsToSearch, +} from './postgrest-filters' +import { postgrestRequest } from './postgrest-request' +import { isSynced } from './realtime' +import { parseRow, serializeRow } from './row-conversions' +import type { ColumnConversions } from './row-conversions' +import type { + DeleteMutationFnParams, + InsertMutationFnParams, + LoadSubsetOptions, + UpdateMutationFnParams, +} from '@tanstack/db' +import type { QueryClient, QueryMeta } from '@tanstack/query-core' +import type { SupabaseClient } from '@supabase/supabase-js' + +export const subsetOptionsToQueryKey = ( + tableName: string, + ctx: LoadSubsetOptions, +): Array => { + // The key shares the request URL's where/order/limit encoding + // (`subsetParamsToSearch`) so the two cannot drift; the constant `select` is + // excluded because it never distinguishes one subset from another. + // + // Pagination params (cursor/offset) ARE included: query-db-collection keys row + // ownership by this key, so distinct windows of the same subset (same + // where/order/limit, different cursor/offset) must not collide — otherwise a + // later page's result would take over the earlier page's rows and delete them. + // The key still starts with `[tableName]` so `syncTableSubscription` keeps + // matching every window of the table by prefix. + const search = subsetParamsToSearch(ctx) + if (ctx.cursor) { + try { + for (const [k, v] of cursorWhereFromToSearch(ctx.cursor)) { + search.append(k, v) + } + } catch { + // The queryKey function must never throw — query-db-collection calls it + // synchronously to key row ownership. If the cursor cannot be rendered + // (an un-pushable predicate), fall back to a deterministic discriminator + // so distinct windows still get distinct keys and cannot take over and + // delete each other's rows. + search.append('cursor', JSON.stringify(ctx.cursor.whereFrom)) + } + } + if (ctx.offset && !ctx.cursor) { + search.append('offset', `${ctx.offset}`) + } + const key = search.toString() + return key ? [tableName, key] : [tableName] +} + +type PageOptions = { + /** The caller's row limit; `undefined` means "the full matching set". */ + limit?: number + /** The starting offset (always 0 for cursor reads). */ + offset: number + signal?: AbortSignal + /** + * Columns that uniquely identify a row. Pages are ordered by them after the + * caller's order so that the rows tied on that order keep one position + * across the page requests; see {@link withKeyOrder}. + */ + keys: Array +} + +/** + * Extends the `order` param with the key columns it does not already sort by. + * + * Offset paging is only sound over a total order. When the caller's order has + * ties (or there is none), Postgres may order the tied rows differently for + * each page request, so a row can show up on two pages while another shows up + * on none. Ordering by the unique key last makes every page agree. + */ +function withKeyOrder( + search: URLSearchParams, + keys: Array, +): URLSearchParams { + const sorts = search.get('order')?.split(',') ?? [] + // Each sort is `column.direction[.nulls]`; the column is the first segment. + const sortedColumns = new Set(sorts.map((sort) => sort.split('.')[0])) + const missing = keys.filter((key) => !sortedColumns.has(key)) + if (missing.length === 0) { + return search + } + const ordered = new URLSearchParams(search) + ordered.set( + 'order', + [...sorts, ...missing.map((key) => `${key}.asc`)].join(','), + ) + return ordered +} + +/** + * Read a full matching set (or the caller's `limit` rows) with offset paging, + * looping past PostgREST's `db-max-rows` cap instead of silently truncating at + * it. The first request asks for `count=exact` so the loop knows the total up + * front and stops without a trailing empty probe; each later page advances the + * offset by the rows actually received (self-correcting under concurrent + * writes) and is capped by the observed page size and the rows still owed. + * + * A result that fits in one page is returned as read. When more pages are + * needed, the first page is read again under a total order (see + * {@link withKeyOrder}) so that all pages are cut from the same sequence. + */ +async function fetchAllPages( + supabase: SupabaseClient, + tableName: string, + baseSearch: URLSearchParams, + { limit, offset: startOffset, signal, keys }: PageOptions, +): Promise> { + // A zero limit is an empty window: return it without touching the network. + if (limit === 0) { + return [] + } + + // The base search already carries the caller's limit/offset (from the search + // builders), so the first request is sent verbatim — its URL stays identical + // to the single-request behaviour, and the query key that mirrors it. + const first = await postgrestRequest>(supabase, tableName, { + method: 'GET', + search: baseSearch, + signal, + count: 'exact', + }) + let rows: Array = first.data ? [...first.data] : [] + + // The rows the first page returned size the server's cap; a later page that + // comes back shorter than this means the server has no more rows. + const pageSize = rows.length + // `Content-Range` reports the whole matching set, so the rows reachable from + // the starting offset are what remains past it. When the count header is + // missing we fall back to paging until a short page appears. + const total = first.count ?? Number.POSITIVE_INFINITY + const remaining = Math.max(0, total - startOffset) + const target = limit === undefined ? remaining : Math.min(limit, remaining) + + // The first page was read under the caller's order, whose ties the server may + // break differently on the next request. If more pages follow, discard it and + // page through a total order instead. + let pagedSearch = baseSearch + if (pageSize > 0 && rows.length < target) { + pagedSearch = withKeyOrder(baseSearch, keys) + if (pagedSearch !== baseSearch) { + rows = [] + } + } + + let lastPageSize = pageSize + while (lastPageSize === pageSize && pageSize > 0 && rows.length < target) { + const pageSearch = new URLSearchParams(pagedSearch) + pageSearch.set('offset', `${startOffset + rows.length}`) + pageSearch.set('limit', `${Math.min(pageSize, target - rows.length)}`) + const page = await postgrestRequest>(supabase, tableName, { + method: 'GET', + search: pageSearch, + signal, + }) + const pageRows = page.data ?? [] + rows.push(...pageRows) + lastPageSize = pageRows.length + } + + return rows +} + +/** + * Read one already-in-budget subset's full window: the keyset tie request (if + * any) plus the main paged request. This is what runs once per chunk when + * `splitLoadSubsetOptions` had to split an oversized subset into several. + */ +async function loadWindow( + supabase: SupabaseClient, + tableName: string, + keys: Array, + options: LoadSubsetOptions, + signal: AbortSignal, +): Promise> { + const search = loadSubsetOptionsToSearch(options) + // Cursor reads pin their own window start, so they never carry an offset (see + // loadSubsetOptionsToSearch); only a cursor-less read advances from `offset`. + const startOffset = options.offset && !options.cursor ? options.offset : 0 + + // A keyset request whose boundary column has ties (e.g. `orderBy(created_at)` + // with repeated values) needs a second, unlimited request for the rows equal + // to the boundary; the limited `whereFrom` page alone would skip them. + // + // Do not drop this on the grounds that core's ordered-source loader also + // probes the boundary (`requestSnapshot({ where: eq(col, boundary) })`) and + // usually pre-loads the same tie class: that probe is a window-path + // optimization the adapter cannot assume ran. It is skipped when the boundary + // value is unchanged, falls back to a full-source load for non-keyset orders, + // and never runs for `loadSubset({ cursor })` calls made outside that path. + // Honouring `whereCurrent` here is what makes the cursor correct on its own. + const tiesSearch = cursorCurrentToSearch(options) + if (!tiesSearch) { + return await fetchAllPages(supabase, tableName, search, { + limit: options.limit, + offset: startOffset, + signal, + keys, + }) + } + + const [ties, rows] = await Promise.all([ + // The tie read is deliberately unbounded by the caller's limit — every row + // tied at the boundary must come back — but it is still paged so a tie class + // larger than the cap is not truncated. + fetchAllPages(supabase, tableName, tiesSearch, { + offset: 0, + signal, + keys, + }), + fetchAllPages(supabase, tableName, search, { + limit: options.limit, + offset: startOffset, + signal, + keys, + }), + ]) + // `whereCurrent` (== boundary) and `whereFrom` (> / < boundary) are disjoint, + // so concatenation never duplicates; the collection re-sorts locally. + return [...ties, ...rows] +} + +// PostgREST has no body-carried filters for reads and the Supabase API gateway +// rejects request lines over about 8 KB with 414, so a big `inArray(...)` +// predicate (the common shape a lazy join's on-demand collection produces) +// can make a subset's URL too long to send. This matches postgrest-js's own +// `urlLengthLimit` default so the two agree on what counts as "too long". +export const MAX_URL_LENGTH = 8000 + +// At most this many chunk requests run at once; splitting a large IN list can +// produce far more chunks than are worth having in flight simultaneously. +const MAX_CONCURRENT_CHUNKS = 6 + +// Pages after the first (`fetchAllPages`) set `offset` and `limit` on top of +// the subset's own search; no real value is wider than this one. +const WIDEST_PAGE_PARAM = `${Number.MAX_SAFE_INTEGER}` + +/** + * The length of the longest URL `fetchAllPages` could send for `search`: the + * base URL, the `?`, and the query string with the key-column tiebreak order + * that multi-page reads add (see {@link withKeyOrder}) and `offset`/`limit` at + * their widest, so every page — not only the first — stays within budget. + */ +function pagedUrlLength( + baseLength: number, + search: URLSearchParams, + keys: Array, +): number { + const paged = new URLSearchParams(withKeyOrder(search, keys)) + paged.set('offset', WIDEST_PAGE_PARAM) + paged.set('limit', WIDEST_PAGE_PARAM) + return baseLength + '?'.length + paged.toString().length +} + +/** + * The length of the longest URL a subset would produce: the main request's + * pages, and — when the subset has a cursor — the unlimited `whereCurrent` tie + * request's pages too, since `loadWindow` sends both for a cursor read. + * Measuring rendered URLs (rather than estimating) accounts for quoting and + * percent-encoding exactly. + */ +function measureSubset( + supabase: SupabaseClient, + tableName: string, + keys: Array, + options: LoadSubsetOptions, +): number { + const base = supabase.from(tableName).url.toString().length + const mainLength = pagedUrlLength( + base, + loadSubsetOptionsToSearch(options), + keys, + ) + const tiesSearch = cursorCurrentToSearch(options) + return tiesSearch + ? Math.max(mainLength, pagedUrlLength(base, tiesSearch, keys)) + : mainLength +} + +/** + * Runs one `loadWindow` per chunk with at most `MAX_CONCURRENT_CHUNKS` in + * flight at a time, and concatenates their rows. Chunks are disjoint on the + * split column (see `splitLoadSubsetOptions`), so concatenation order does not + * matter and needs no dedupe. + * + * On the first failure, every other chunk's request is aborted through a + * local `AbortController` that also follows the caller's own signal — this + * keeps the same all-or-nothing semantics an unsplit request has ("one failed + * page fails the whole load") instead of surfacing a partial result, and the + * first error encountered is what `Promise.all` rejects with. + */ +async function loadChunks( + supabase: SupabaseClient, + tableName: string, + keys: Array, + chunks: Array, + signal: AbortSignal, +): Promise> { + // Not `AbortSignal.any`: React Native (Hermes) and Safari before 17.4 lack it. + const controller = new AbortController() + const abortFromCaller = () => controller.abort(signal.reason) + if (signal.aborted) { + abortFromCaller() + } else { + signal.addEventListener('abort', abortFromCaller, { once: true }) + } + const results: Array> = new Array(chunks.length) + let nextIndex = 0 + + const runWorker = async (): Promise => { + while (nextIndex < chunks.length) { + const index = nextIndex++ + try { + results[index] = await loadWindow( + supabase, + tableName, + keys, + chunks[index]!, + controller.signal, + ) + } catch (error) { + controller.abort() + throw error + } + } + } + + const workerCount = Math.min(MAX_CONCURRENT_CHUNKS, chunks.length) + try { + await Promise.all(Array.from({ length: workerCount }, runWorker)) + } finally { + signal.removeEventListener('abort', abortFromCaller) + } + return results.flat() +} + +/** + * Reads the rows a query needs, converted to the rows the collection holds + * (see {@link RowConversions}). + */ +export const supabaseQueryFn = async ( + supabase: SupabaseClient, + tableName: string, + ctx: { + client: QueryClient + queryKey: ReadonlyArray + signal: AbortSignal + meta: QueryMeta | undefined + pageParam?: unknown + direction?: unknown + }, + { + keys = [], + conversions = {}, + }: { keys?: Array; conversions?: ColumnConversions } = {}, +): Promise> => { + const options = ctx.meta?.loadSubsetOptions ?? {} + // The query key stays the full, unchunked subset (subsetOptionsToQueryKey + // never sees these chunks), so this fan-out is invisible to + // query-db-collection: one query still owns every row it returns. + const chunks = splitLoadSubsetOptions(options, { + maxUrlLength: MAX_URL_LENGTH, + measure: (chunkOptions) => + measureSubset(supabase, tableName, keys, chunkOptions), + }) + + const rows = + chunks.length === 1 + ? await loadWindow( + 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, + // 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, + }) + + // 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..a26683dc53 --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/common.ts @@ -0,0 +1,237 @@ +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). 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; nulls: 'first' | 'last' }>, +): void { + if (sorts.length === 0) return + search.set( + 'order', + sorts + .map( + (sort) => + `${sort.column}.${sort.ascending ? 'asc' : 'desc'}.nulls${sort.nulls}`, + ) + .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..7f13b05fcd --- /dev/null +++ b/packages/supabase-db-collection/src/postgrest-filters/subset-params-to-search.ts @@ -0,0 +1,38 @@ +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', + nulls: sort.nulls, + })), + ) + // `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/collection-key.test.ts b/packages/supabase-db-collection/tests/collection-key.test.ts new file mode 100644 index 0000000000..20cd9614c6 --- /dev/null +++ b/packages/supabase-db-collection/tests/collection-key.test.ts @@ -0,0 +1,76 @@ +import { createClient } from '@supabase/supabase-js' +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, test } 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])) + }, + ) + + 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) => { + expect(getKeyFor([`a`])(rowOf([id]))).toBe(id) + }, + ) +}) 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) + } + }) +}) 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() + }) +}) 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..969f4f9a3d --- /dev/null +++ b/packages/supabase-db-collection/tests/index.test.ts @@ -0,0 +1,1040 @@ +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.nullsfirst', + ]) + }) + + 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.nullsfirst', + ]) + }) + + test('descending', async () => { + await queryResult((q) => + q + .from({ user: usersCollection }) + .orderBy(({ user }) => user.name, 'desc'), + ) + expectFetchUrls(mockFetch, [ + '/rest/v1/users?select=*&order=name.desc.nullsfirst', + ]) + }) + + 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.nullsfirst,id.desc.nullsfirst', + ]) + }) + }) + + 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.nullsfirst&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.nullsfirst&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.nullsfirst&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.nullsfirst', + ]) + }) + + 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.nullsfirst&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/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 new file mode 100644 index 0000000000..5c30d481b0 --- /dev/null +++ b/packages/supabase-db-collection/tests/pagination.test.ts @@ -0,0 +1,492 @@ +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.nullsfirst&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.nullsfirst&limit=3', // page 1: window + 1 peek row + 'select=*&id=eq.3', // boundary probe (loadBoundary) settles the peek + '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 + ]) + }) + + 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.nullsfirst&limit=3', // page 1: window + 1 peek row + 'select=*&rank=eq.20', // boundary probe (loadBoundary): full tie class + '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 + ]) + }) +}) + +// 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.nullslast', + 'select=*&order=id.asc.nullslast&offset=2&limit=2', + 'select=*&order=id.asc.nullslast&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.nullslast', + 'select=*&order=id.asc.nullslast&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.nullslast&offset=2', + 'select=*&order=id.asc.nullslast&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.nullslast']) + }) + + 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.nullslast&limit=3', + 'select=*&order=id.asc.nullslast&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.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', + ]) + }) + + 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.nullslast,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.nullslast', + ]) + }) + }) + + 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..11b59650e2 --- /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.nullslast') + expect( + subsetParamsToSearch({ orderBy: [sort(id, 'desc')] }).get('order'), + ).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.nullslast,name.desc.nullslast') + 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.nullslast') + 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.nullslast') + 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..6a2e297945 --- /dev/null +++ b/packages/supabase-db-collection/tests/row-conversions.test.ts @@ -0,0 +1,323 @@ +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 ?? '{}')) + 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 }), + ) + }) + 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 only the changed columns, serialized', 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: { title: '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 () => { + 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..502bf1bab0 --- /dev/null +++ b/packages/supabase-db-collection/tests/split-load-fanout.test.ts @@ -0,0 +1,464 @@ +import { createClient } from '@supabase/supabase-js' +import { IR, and, inArray, not } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { afterEach, 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.nullslast,organization_identifier_column.asc,id.asc', + ) + } + for (const rawUrl of getRawUrls(mockFetch)) { + 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) + }) + }) +}) 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..4c4fed1448 --- /dev/null +++ b/packages/supabase-db-collection/tests/test.utils.ts @@ -0,0 +1,310 @@ +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() { + // 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' + + // 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(row), { + 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..d144d41661 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.107.1 + version: 5.107.1 + '@vitest/coverage-istanbul': + specifier: ^5.1.0 + 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: @@ -6324,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: @@ -6335,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 @@ -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 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`,