diff --git a/packages/core/realtime-js/src/RealtimeChannel.ts b/packages/core/realtime-js/src/RealtimeChannel.ts index 4bf49e686..4dfcecb66 100644 --- a/packages/core/realtime-js/src/RealtimeChannel.ts +++ b/packages/core/realtime-js/src/RealtimeChannel.ts @@ -327,9 +327,9 @@ export default class RealtimeChannel { if ( serverPostgresFilter && serverPostgresFilter.event === event && - serverPostgresFilter.schema === schema && - serverPostgresFilter.table === table && - serverPostgresFilter.filter === filter + RealtimeChannel.isFilterValueEqual(serverPostgresFilter.schema, schema) && + RealtimeChannel.isFilterValueEqual(serverPostgresFilter.table, table) && + RealtimeChannel.isFilterValueEqual(serverPostgresFilter.filter, filter) ) { newPostgresBindings.push({ ...clientPostgresBinding, @@ -949,6 +949,20 @@ export default class RealtimeChannel { return true } + /** + * Compares two optional filter values for equality. + * Treats undefined, null, and empty string as equivalent empty values. + * @internal + */ + private static isFilterValueEqual( + serverValue: string | undefined | null, + clientValue: string | undefined + ): boolean { + const normalizedServer = serverValue ?? undefined + const normalizedClient = clientValue ?? undefined + return normalizedServer === normalizedClient + } + /** @internal */ private _rejoinUntilConnected() { this.rejoinTimer.scheduleTimeout() diff --git a/packages/core/realtime-js/test/RealtimeChannel.postgres.test.ts b/packages/core/realtime-js/test/RealtimeChannel.postgres.test.ts index e28831072..4f598b5aa 100644 --- a/packages/core/realtime-js/test/RealtimeChannel.postgres.test.ts +++ b/packages/core/realtime-js/test/RealtimeChannel.postgres.test.ts @@ -247,6 +247,77 @@ describe('PostgreSQL binding matching behavior', () => { assert.equal(channel.bindings.postgres_changes[0].id, 'server-id-1') }) + test('should match postgres changes when server returns null for optional fields', () => { + const callbackSpy = vi.fn() + + channel.on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'notifications', + }, + callbackSpy + ) + + channel.subscribe() + + const mockServerResponse = { + postgres_changes: [ + { + event: 'INSERT', + schema: 'public', + table: 'notifications', + filter: null, + id: 'server-id-1', + }, + ], + } + + channel.joinPush._matchReceive({ + status: 'ok', + response: mockServerResponse, + }) + + assert.equal(channel.state, CHANNEL_STATES.joined) + assert.equal(channel.bindings.postgres_changes[0].id, 'server-id-1') + }) + + test('should match postgres changes when server omits optional filter field', () => { + const callbackSpy = vi.fn() + + channel.on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'notifications', + }, + callbackSpy + ) + + channel.subscribe() + + const mockServerResponse = { + postgres_changes: [ + { + event: '*', + schema: 'public', + table: 'notifications', + id: 'server-id-1', + }, + ], + } + + channel.joinPush._matchReceive({ + status: 'ok', + response: mockServerResponse, + }) + + assert.equal(channel.state, CHANNEL_STATES.joined) + assert.equal(channel.bindings.postgres_changes[0].id, 'server-id-1') + }) + test.each([ { description: 'should fail when event differs',