From f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e Mon Sep 17 00:00:00 2001 From: "mavriel@gmail.com" Date: Sat, 25 Oct 2025 03:58:44 +0900 Subject: [PATCH] fix: Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation (#9859) --- spec/DatabaseController.spec.js | 70 +++++++++++++++++++++++++++ src/Controllers/DatabaseController.js | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index e1b50a5a..d8ce5161 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -615,6 +615,76 @@ describe('DatabaseController', function () { expect(result2.length).toEqual(1); }); }); + + describe('update with validateOnly', () => { + const mockStorageAdapter = { + findOneAndUpdate: () => Promise.resolve({}), + find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: 'TestObject', + fields: { testField: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + + it('should use primary readPreference when validateOnly is true', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger) + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + true, // skipSanitization: true (matches RestWrite behavior) + true // validateOnly: true + ); + } catch (error) { + // validateOnly may throw, but we're checking the find call options + } + + // Verify that find was called with primary readPreference + expect(findSpy).toHaveBeenCalled(); + const findCall = findSpy.calls.mostRecent(); + expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter + + // Verify that findOneAndUpdate was NOT called (only validation, no actual update) + expect(findOneAndUpdateSpy).not.toHaveBeenCalled(); + }); + + it('should not use primary readPreference when validateOnly is false', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: false + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + false, // skipSanitization + false // validateOnly + ); + } catch (error) { + // May throw for other reasons, but we're checking the call pattern + } + + // When validateOnly is false, find should not be called for validation + // Instead, findOneAndUpdate should be called + expect(findSpy).not.toHaveBeenCalled(); + expect(findOneAndUpdateSpy).toHaveBeenCalled(); + }); + }); }); function buildCLP(pointerNames) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 095c2e83..15207a72 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -593,7 +593,7 @@ class DatabaseController { convertUsernameToLowercase(update, className, this.options); transformAuthData(className, update, schema); if (validateOnly) { - return this.adapter.find(className, schema, query, {}).then(result => { + return this.adapter.find(className, schema, query, { readPreference: 'primary' }).then(result => { if (!result || !result.length) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); }