diff --git a/.changeset/swift-moles-fetch.md b/.changeset/swift-moles-fetch.md new file mode 100644 index 0000000000000..4ef5c69c78a4a --- /dev/null +++ b/.changeset/swift-moles-fetch.md @@ -0,0 +1,11 @@ +--- +"@refinedev/supabase": patch +--- + +fix(supabase): handle realtime subscriptions with multiple filters #6360 + +Supabase Realtime `postgres_changes` subscriptions support a single `filter` string. +When multiple filters are provided, `liveProvider` now uses only the first valid filter +and logs a warning instead of generating an invalid subscription payload. + +Resolves #6360 diff --git a/packages/supabase/src/liveProvider/index.ts b/packages/supabase/src/liveProvider/index.ts index 19200da1de4c4..ccdc4d8686218 100644 --- a/packages/supabase/src/liveProvider/index.ts +++ b/packages/supabase/src/liveProvider/index.ts @@ -50,11 +50,11 @@ export const liveProvider = ( }; const mapFilter = (filters?: CrudFilters): string | undefined => { - if (!filters || filters?.length === 0) { + if (!filters || filters.length === 0) { return; } - return filters + const mapped = filters .map((filter: CrudFilter): string | undefined => { if ("field" in filter) { return `${filter.field}=${mapOperator(filter.operator)}.${ @@ -63,17 +63,32 @@ export const liveProvider = ( } return; }) - .filter(Boolean) - .join(","); + .filter((x): x is string => Boolean(x)); + + if (mapped.length === 0) return; + + if (mapped.length > 1) { + // Supabase Realtime currently supports only a single `filter` string + // for postgres_changes. Joining multiple filters with commas + // results in an invalid payload and may break the subscription. + console.warn( + `[refine/supabase] Multiple filters are not supported for Supabase Realtime subscriptions. Using only the first filter: "${mapped[0]}".`, + ); + } + + return mapped[0]; }; const events = types .map((x) => supabaseTypes[x]) .sort((a, b) => a.localeCompare(b)); + const filter = mapFilter(params?.filters); + const ch = `${channel}:${events.join("|")}${filter ? `:${filter}` : ""}`; let client = supabaseClient.channel(ch); + for (let i = 0; i < events.length; i++) { client = client.on( "postgres_changes", diff --git a/packages/supabase/test/liveProvider/index.spec.ts b/packages/supabase/test/liveProvider/index.spec.ts new file mode 100644 index 0000000000000..453b6e119e96d --- /dev/null +++ b/packages/supabase/test/liveProvider/index.spec.ts @@ -0,0 +1,96 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { liveProvider } from "../../src/liveProvider"; + +describe("liveProvider", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createMockSupabaseClient = () => { + const realtimeChannel = { + on: vi.fn().mockReturnThis(), + subscribe: vi.fn().mockReturnThis(), + }; + + const client = { + channel: vi.fn().mockReturnValue(realtimeChannel), + removeChannel: vi.fn(), + rest: { schemaName: "public" }, + } as unknown as SupabaseClient; + + return { client, realtimeChannel }; + }; + + it("uses only the first realtime filter and warns when multiple filters are provided", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { client, realtimeChannel } = createMockSupabaseClient(); + const provider = liveProvider(client); + + provider.subscribe({ + channel: "resources/posts", + types: ["created", "updated"], + callback: vi.fn(), + params: { + filters: [ + { field: "id", operator: "eq", value: 1 }, + { field: "status", operator: "eq", value: "published" }, + ], + }, + }); + + expect(client.channel).toHaveBeenCalledWith( + "resources/posts:INSERT|UPDATE:id=eq.1", + ); + expect(realtimeChannel.on).toHaveBeenCalledTimes(2); + expect(realtimeChannel.on).toHaveBeenCalledWith( + "postgres_changes", + expect.objectContaining({ + event: "INSERT", + filter: "id=eq.1", + schema: "public", + table: "posts", + }), + expect.any(Function), + ); + expect(realtimeChannel.on).toHaveBeenCalledWith( + "postgres_changes", + expect.objectContaining({ + event: "UPDATE", + filter: "id=eq.1", + schema: "public", + table: "posts", + }), + expect.any(Function), + ); + expect(realtimeChannel.subscribe).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Using only the first filter: "id=eq.1".`), + ); + }); + + it("does not warn when a single realtime filter is provided", () => { + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const { client } = createMockSupabaseClient(); + const provider = liveProvider(client); + + provider.subscribe({ + channel: "resources/posts", + types: ["updated"], + callback: vi.fn(), + params: { + filters: [{ field: "status", operator: "eq", value: "published" }], + }, + }); + + expect(client.channel).toHaveBeenCalledWith( + "resources/posts:UPDATE:status=eq.published", + ); + expect(warnSpy).not.toHaveBeenCalled(); + }); +});