From 70003018885e63caf60ffb5ff95f93f5cb9e9605 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 1 Oct 2025 20:59:28 +1000 Subject: [PATCH 1/4] feature: add subscription.find --- src/LiveQueryClient.ts | 12 +++- src/LiveQuerySubscription.ts | 20 +++++- src/__tests__/LiveQueryClient-test.js | 93 ++++++++++++++++++++++++++- types/LiveQuerySubscription.d.ts | 8 ++- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/LiveQueryClient.ts b/src/LiveQueryClient.ts index 44f1c552f..4ce6008e7 100644 --- a/src/LiveQueryClient.ts +++ b/src/LiveQueryClient.ts @@ -21,6 +21,7 @@ const OP_TYPES = { SUBSCRIBE: 'subscribe', UNSUBSCRIBE: 'unsubscribe', ERROR: 'error', + QUERY: 'query', }; // The event we get back from LiveQuery server @@ -34,6 +35,7 @@ const OP_EVENTS = { ENTER: 'enter', LEAVE: 'leave', DELETE: 'delete', + RESULT: 'result', }; // The event the LiveQuery client should emit @@ -53,6 +55,7 @@ const SUBSCRIPTION_EMMITER_TYPES = { ENTER: 'enter', LEAVE: 'leave', DELETE: 'delete', + RESULT: 'result', }; // Exponentially-growing random delay @@ -212,7 +215,7 @@ class LiveQueryClient { subscribeRequest.sessionToken = sessionToken; } - const subscription = new LiveQuerySubscription(this.requestId, query, sessionToken); + const subscription = new LiveQuerySubscription(this.requestId, query, sessionToken, this); this.subscriptions.set(this.requestId, subscription); this.requestId += 1; this.connectPromise @@ -425,6 +428,13 @@ class LiveQueryClient { } break; } + case OP_EVENTS.RESULT: { + if (subscription) { + const objects = data.results.map(json => ParseObject.fromJSON(json, false)); + subscription.emit(SUBSCRIPTION_EMMITER_TYPES.RESULT, objects); + } + break; + } default: { // create, update, enter, leave, delete cases if (!subscription) { diff --git a/src/LiveQuerySubscription.ts b/src/LiveQuerySubscription.ts index 79a59d822..0759a5349 100644 --- a/src/LiveQuerySubscription.ts +++ b/src/LiveQuerySubscription.ts @@ -92,6 +92,7 @@ class LiveQuerySubscription { subscribePromise: any; unsubscribePromise: any; subscribed: boolean; + client: any; emitter: EventEmitter; on: EventEmitter['on']; emit: EventEmitter['emit']; @@ -99,11 +100,13 @@ class LiveQuerySubscription { * @param {string | number} id - subscription id * @param {string} query - query to subscribe to * @param {string} sessionToken - optional session token + * @param {object} client - LiveQueryClient instance */ - constructor(id: string | number, query: ParseQuery, sessionToken?: string) { + constructor(id: string | number, query: ParseQuery, sessionToken?: string, client?: any) { this.id = id; this.query = query; this.sessionToken = sessionToken; + this.client = client; this.subscribePromise = resolvingPromise(); this.unsubscribePromise = resolvingPromise(); this.subscribed = false; @@ -130,6 +133,21 @@ class LiveQuerySubscription { return liveQueryClient.unsubscribe(this); }); } + + /** + * Execute a query on this subscription. + * The results will be delivered via the 'result' event. + */ + find() { + if (this.client) { + this.client.connectPromise.then(() => { + this.client.socket.send(JSON.stringify({ + op: 'query', + requestId: this.id, + })); + }); + } + } } export default LiveQuerySubscription; diff --git a/src/__tests__/LiveQueryClient-test.js b/src/__tests__/LiveQueryClient-test.js index c8fa0ba0c..050dcbd83 100644 --- a/src/__tests__/LiveQueryClient-test.js +++ b/src/__tests__/LiveQueryClient-test.js @@ -1,3 +1,4 @@ +jest.dontMock('../LiveQuerySubscription'); jest.dontMock('../LiveQueryClient'); jest.dontMock('../arrayContainsObject'); jest.dontMock('../canBeSerialized'); @@ -23,7 +24,6 @@ jest.dontMock('../UniqueInstanceStateController'); jest.dontMock('../unsavedChildren'); jest.dontMock('../ParseACL'); jest.dontMock('../ParseQuery'); -jest.dontMock('../LiveQuerySubscription'); jest.dontMock('../LocalDatastore'); jest.dontMock('../WebSocketController'); @@ -38,6 +38,7 @@ jest.setMock('../LocalDatastore', mockLocalDatastore); const CoreManager = require('../CoreManager').default; const EventEmitter = require('../EventEmitter').default; const LiveQueryClient = require('../LiveQueryClient').default; +const LiveQuerySubscription = require('../LiveQuerySubscription').default; const ParseObject = require('../ParseObject').default; const ParseQuery = require('../ParseQuery').default; const WebSocketController = require('../WebSocketController').default; @@ -1091,4 +1092,94 @@ describe('LiveQueryClient', () => { const subscription = liveQueryClient.subscribe(); expect(subscription).toBe(undefined); }); + + it('can handle WebSocket result response message', () => { + const liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken', + }); + // Add mock subscription + const subscription = new events.EventEmitter(); + liveQueryClient.subscriptions.set(1, subscription); + const object1 = new ParseObject('Test'); + object1.set('key', 'value1'); + const object2 = new ParseObject('Test'); + object2.set('key', 'value2'); + const data = { + op: 'result', + clientId: 1, + requestId: 1, + results: [object1._toFullJSON(), object2._toFullJSON()], + }; + const event = { + data: JSON.stringify(data), + }; + // Register checked in advance + let isChecked = false; + subscription.on('result', function (objects) { + isChecked = true; + expect(objects.length).toBe(2); + expect(objects[0].get('key')).toEqual('value1'); + expect(objects[1].get('key')).toEqual('value2'); + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + }); + + it('LiveQuerySubscription class has find method', () => { + expect(typeof LiveQuerySubscription.prototype.find).toBe('function'); + }); + + it('subscription has find method', () => { + const liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken', + }); + const query = new ParseQuery('Test'); + query.equalTo('key', 'value'); + + const subscription = liveQueryClient.subscribe(query); + + expect(subscription).toBeInstanceOf(LiveQuerySubscription); + expect(typeof subscription.find).toBe('function'); + }); + + it('can send query message via subscription', async () => { + const liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken', + }); + liveQueryClient.socket = { + send: jest.fn(), + }; + const query = new ParseQuery('Test'); + query.equalTo('key', 'value'); + + const subscription = liveQueryClient.subscribe(query); + liveQueryClient.connectPromise.resolve(); + await liveQueryClient.connectPromise; + + subscription.find(); + + // Need to wait for the sendMessage promise to resolve + await Promise.resolve(); + + const messageStr = liveQueryClient.socket.send.mock.calls[1][0]; + const message = JSON.parse(messageStr); + expect(message).toEqual({ + op: 'query', + requestId: 1, + }); + }); }); diff --git a/types/LiveQuerySubscription.d.ts b/types/LiveQuerySubscription.d.ts index 7bc4df0e7..e47a78c8d 100644 --- a/types/LiveQuerySubscription.d.ts +++ b/types/LiveQuerySubscription.d.ts @@ -89,15 +89,21 @@ declare class LiveQuerySubscription { subscribePromise: any; unsubscribePromise: any; subscribed: boolean; + client: any; emitter: EventEmitter; on: EventEmitter['on']; emit: EventEmitter['emit']; - constructor(id: string | number, query: ParseQuery, sessionToken?: string); + constructor(id: string | number, query: ParseQuery, sessionToken?: string, client?: any); /** * Close the subscription * * @returns {Promise} */ unsubscribe(): Promise; + /** + * Execute a query on this subscription. + * The results will be delivered via the 'result' event. + */ + find(): void; } export default LiveQuerySubscription; From 3d950abf85e47b755aea6b28ba1ab4b70d35331c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 1 Nov 2025 19:49:35 +1100 Subject: [PATCH 2/4] Update LiveQueryClient.ts --- src/LiveQueryClient.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/LiveQueryClient.ts b/src/LiveQueryClient.ts index 4ce6008e7..1b08e3d01 100644 --- a/src/LiveQueryClient.ts +++ b/src/LiveQueryClient.ts @@ -430,7 +430,12 @@ class LiveQueryClient { } case OP_EVENTS.RESULT: { if (subscription) { - const objects = data.results.map(json => ParseObject.fromJSON(json, false)); + const objects = data.results.map(json => { + if (!json.className && subscription.query) { + json.className = subscription.query.className; + } + return ParseObject.fromJSON(json, false); + }); subscription.emit(SUBSCRIPTION_EMMITER_TYPES.RESULT, objects); } break; From b0aa37518c24b4fbfdce500d8849e18e4cb97fa8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 1 Nov 2025 21:23:46 +1100 Subject: [PATCH 3/4] increase test coverage --- src/LiveQueryClient.ts | 33 +++++++++++++-------- src/__tests__/LiveQueryClient-test.js | 42 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/LiveQueryClient.ts b/src/LiveQueryClient.ts index 1b08e3d01..58a07654e 100644 --- a/src/LiveQueryClient.ts +++ b/src/LiveQueryClient.ts @@ -59,7 +59,7 @@ const SUBSCRIPTION_EMMITER_TYPES = { }; // Exponentially-growing random delay -const generateInterval = k => { +const generateInterval = (k: number): number => { return Math.random() * Math.min(30, Math.pow(2, k) - 1) * 1000; }; @@ -126,13 +126,15 @@ class LiveQueryClient { emit: any; /** - * @param {object} options - * @param {string} options.applicationId - applicationId of your Parse app - * @param {string} options.serverURL - the URL of your LiveQuery server - * @param {string} options.javascriptKey (optional) - * @param {string} options.masterKey (optional) Your Parse Master Key. (Node.js only!) - * @param {string} options.sessionToken (optional) - * @param {string} options.installationId (optional) + * Creates a new LiveQueryClient instance. + * + * @param options - Configuration options for the LiveQuery client + * @param options.applicationId - The applicationId of your Parse app + * @param options.serverURL - The URL of your LiveQuery server (must start with 'ws' or 'wss') + * @param options.javascriptKey - (Optional) The JavaScript key for your Parse app + * @param options.masterKey - (Optional) Your Parse Master Key (Node.js only!) + * @param options.sessionToken - (Optional) Session token for authenticated requests + * @param options.installationId - (Optional) Installation ID for the client */ constructor({ applicationId, @@ -141,6 +143,13 @@ class LiveQueryClient { masterKey, sessionToken, installationId, + }: { + applicationId: string; + serverURL: string; + javascriptKey?: string; + masterKey?: string; + sessionToken?: string; + installationId?: string; }) { if (!serverURL || serverURL.indexOf('ws') !== 0) { throw new Error( @@ -165,8 +174,8 @@ class LiveQueryClient { const EventEmitter = CoreManager.getEventEmitter(); this.emitter = new EventEmitter(); - this.on = (eventName, listener) => this.emitter.on(eventName, listener); - this.emit = (eventName, ...args) => this.emitter.emit(eventName, ...args); + this.on = (eventName: string, listener: (...args: unknown[]) => void) => this.emitter.on(eventName, listener); + this.emit = (eventName: string, ...args: unknown[]) => this.emitter.emit(eventName, ...args); // adding listener so process does not crash // best practice is for developer to register their own listener this.on('error', () => {}); @@ -222,7 +231,7 @@ class LiveQueryClient { .then(() => { this.socket.send(JSON.stringify(subscribeRequest)); }) - .catch(error => { + .catch((error: Error) => { subscription.subscribePromise.reject(error); }); @@ -430,7 +439,7 @@ class LiveQueryClient { } case OP_EVENTS.RESULT: { if (subscription) { - const objects = data.results.map(json => { + const objects = data.results.map((json: Record) => { if (!json.className && subscription.query) { json.className = subscription.query.className; } diff --git a/src/__tests__/LiveQueryClient-test.js b/src/__tests__/LiveQueryClient-test.js index 050dcbd83..ccb192494 100644 --- a/src/__tests__/LiveQueryClient-test.js +++ b/src/__tests__/LiveQueryClient-test.js @@ -1131,6 +1131,48 @@ describe('LiveQueryClient', () => { expect(isChecked).toBe(true); }); + it('can handle WebSocket result response message with missing className', () => { + const liveQueryClient = new LiveQueryClient({ + applicationId: 'applicationId', + serverURL: 'ws://test', + javascriptKey: 'javascriptKey', + masterKey: 'masterKey', + sessionToken: 'sessionToken', + }); + // Add mock subscription with query + const subscription = new events.EventEmitter(); + const query = new ParseQuery('TestClass'); + subscription.query = query; + liveQueryClient.subscriptions.set(1, subscription); + + // Create results without className property + const data = { + op: 'result', + clientId: 1, + requestId: 1, + results: [ + { objectId: 'obj1', key: 'value1' }, + { objectId: 'obj2', key: 'value2' }, + ], + }; + const event = { + data: JSON.stringify(data), + }; + + // Register checked in advance + let isChecked = false; + subscription.on('result', function (objects) { + isChecked = true; + expect(objects.length).toBe(2); + expect(objects[0].className).toEqual('TestClass'); + expect(objects[1].className).toEqual('TestClass'); + }); + + liveQueryClient._handleWebSocketMessage(event); + + expect(isChecked).toBe(true); + }); + it('LiveQuerySubscription class has find method', () => { expect(typeof LiveQuerySubscription.prototype.find).toBe('function'); }); From fae55120fe9e36ce725dc1f8d135a196a272f685 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 1 Nov 2025 21:40:44 +1100 Subject: [PATCH 4/4] Update LiveQueryClient.d.ts --- types/LiveQueryClient.d.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/types/LiveQueryClient.d.ts b/types/LiveQueryClient.d.ts index 4f41545dc..e4b96d957 100644 --- a/types/LiveQueryClient.d.ts +++ b/types/LiveQueryClient.d.ts @@ -65,21 +65,23 @@ declare class LiveQueryClient { on: any; emit: any; /** - * @param {object} options - * @param {string} options.applicationId - applicationId of your Parse app - * @param {string} options.serverURL - the URL of your LiveQuery server - * @param {string} options.javascriptKey (optional) - * @param {string} options.masterKey (optional) Your Parse Master Key. (Node.js only!) - * @param {string} options.sessionToken (optional) - * @param {string} options.installationId (optional) + * Creates a new LiveQueryClient instance. + * + * @param options - Configuration options for the LiveQuery client + * @param options.applicationId - The applicationId of your Parse app + * @param options.serverURL - The URL of your LiveQuery server (must start with 'ws' or 'wss') + * @param options.javascriptKey - (Optional) The JavaScript key for your Parse app + * @param options.masterKey - (Optional) Your Parse Master Key (Node.js only!) + * @param options.sessionToken - (Optional) Session token for authenticated requests + * @param options.installationId - (Optional) Installation ID for the client */ constructor({ applicationId, serverURL, javascriptKey, masterKey, sessionToken, installationId, }: { - applicationId: any; - serverURL: any; - javascriptKey: any; - masterKey: any; - sessionToken: any; - installationId: any; + applicationId: string; + serverURL: string; + javascriptKey?: string; + masterKey?: string; + sessionToken?: string; + installationId?: string; }); shouldOpen(): any; /**