From 1a5234e50ce590ccc75730f3416a066413a30c82 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:53:00 +0530 Subject: [PATCH] fix(realtime): handle null values in postgres changes filter comparison the server may return null for optional fields (schema, table, filter) while the client has undefined. strict equality comparison fails when comparing null === undefined, causing false mismatch errors. this normalizes both values before comparison to handle the null/undefined discrepancy that occurs during json serialization. closes #1917 --- .../core/realtime-js/src/RealtimeChannel.ts | 20 +++++- .../test/RealtimeChannel.postgres.test.ts | 71 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) 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',