From b56b0e336ff925c65949feb09d262e7b2c7ff9e9 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Tue, 27 Oct 2026 09:44:20 +0100 Subject: [PATCH 1/2] fix: prevent schema migration from deleting internal indexes created at startup --- spec/DefinedSchemas.spec.js | 94 ++++++++++++++++++- spec/MongoStorageAdapter.spec.js | 31 ++++++ spec/PostgresStorageAdapter.spec.js | 16 ++++ .../Storage/Mongo/MongoStorageAdapter.js | 8 +- .../Postgres/PostgresStorageAdapter.js | 2 +- src/SchemaMigrations/DefinedSchemas.js | 10 +- 6 files changed, 156 insertions(+), 5 deletions(-) diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js index b2cae864c1..8de793d8de 100644 --- a/spec/DefinedSchemas.spec.js +++ b/spec/DefinedSchemas.spec.js @@ -482,11 +482,22 @@ describe('DefinedSchemas', () => { it('should detect protected indexes for _User class', () => { const definedSchema = new DefinedSchemas({}, {}); - const protectedUserIndexes = ['_id_', 'case_insensitive_email', 'username_1', 'email_1']; + const protectedUserIndexes = [ + '_id_', + 'case_insensitive_username', + 'case_insensitive_email', + 'username_1', + 'email_1', + '_email_verify_token', + '_perishable_token', + '_auth_data_admin_id', + ]; protectedUserIndexes.forEach(field => { expect(definedSchema.isProtectedIndex('_User', field)).toEqual(true); }); expect(definedSchema.isProtectedIndex('_User', 'test')).toEqual(false); + expect(definedSchema.isProtectedIndex('_User', 'auth_data_admin_id')).toEqual(false); + expect(definedSchema.isProtectedIndex('_User', '_auth_data_admin')).toEqual(false); }); it('should detect protected indexes for _Role class', () => { @@ -498,16 +509,22 @@ describe('DefinedSchemas', () => { it('should detect protected indexes for _Idempotency class', () => { const definedSchema = new DefinedSchemas({}, {}); expect(definedSchema.isProtectedIndex('_Idempotency', 'reqId_1')).toEqual(true); + expect(definedSchema.isProtectedIndex('_Idempotency', 'ttl')).toEqual(true); expect(definedSchema.isProtectedIndex('_Idempotency', 'test')).toEqual(false); }); it('should not detect protected indexes on user defined class', () => { const definedSchema = new DefinedSchemas({}, {}); const protectedIndexes = [ + 'case_insensitive_username', 'case_insensitive_email', 'username_1', 'email_1', + '_email_verify_token', + '_perishable_token', + '_auth_data_admin_id', 'reqId_1', + 'ttl', 'name_1', ]; protectedIndexes.forEach(field => { @@ -515,6 +532,81 @@ describe('DefinedSchemas', () => { }); expect(definedSchema.isProtectedIndex('ExampleClass', '_id_')).toEqual(true); }); + + it('should not delete internal _User indexes absent from the local schema', async () => { + const server = await reconfigureServer(); + const config = Config.get(server.config.appId); + const adapter = config.database.adapter; + await adapter.ensureAuthDataUniqueness('admin'); + await adapter.updateSchemaWithIndexes(); + + const userSchema = new Parse.Schema('_User'); + userSchema.addIndex('custom_unprotected', { username: 1 }); + await userSchema.update(); + config.schemaCache.clear(); + + const before = await new Parse.Schema('_User').get(); + expect(before.indexes._email_verify_token).toEqual({ _email_verify_token: 1 }); + expect(before.indexes._perishable_token).toEqual({ _perishable_token: 1 }); + expect(before.indexes._auth_data_admin_id).toEqual({ '_auth_data_admin.id': 1 }); + expect(before.indexes.custom_unprotected).toEqual({ username: 1 }); + + await new DefinedSchemas( + { definitions: [{ className: '_User', indexes: {} }] }, + server.config + ).execute(); + + const after = await new Parse.Schema('_User').get(); + expect(after.indexes._email_verify_token).toEqual({ _email_verify_token: 1 }); + expect(after.indexes._perishable_token).toEqual({ _perishable_token: 1 }); + expect(after.indexes._auth_data_admin_id).toEqual({ '_auth_data_admin.id': 1 }); + expect(after.indexes.custom_unprotected).toBeUndefined(); + }); + + it('should not delete the internal ttl index on _Idempotency', async () => { + const server = await reconfigureServer(); + const config = Config.get(server.config.appId); + const adapter = config.database.adapter; + const mongoIndexes = await adapter.getIndexes('_Idempotency'); + expect(mongoIndexes.find(index => index.name === 'ttl')).toBeDefined(); + + // _Idempotency is volatile, so startup does not copy its indexes into _SCHEMA. + // Put them in the cloud schema that the migration reads. + await adapter.database.collection(`${adapter._collectionPrefix}_SCHEMA`).updateOne( + { _id: '_Idempotency' }, + { + $set: { + objectId: 'string', + updatedAt: 'string', + createdAt: 'string', + reqId: 'string', + expire: 'date', + _metadata: { + indexes: { + _id_: { _id: 1 }, + reqId_1: { reqId: 1 }, + ttl: { expire: 1 }, + custom_unprotected: { reqId: 1 }, + }, + }, + }, + }, + { upsert: true } + ); + config.schemaCache.clear(); + + await new DefinedSchemas( + { definitions: [{ className: '_Idempotency', indexes: {} }] }, + server.config + ).execute(); + + const after = await adapter.getClass('_Idempotency'); + expect(after.indexes.ttl).toEqual({ expire: 1 }); + expect(after.indexes.reqId_1).toEqual({ reqId: 1 }); + expect(after.indexes.custom_unprotected).toBeUndefined(); + const afterMongo = await adapter.getIndexes('_Idempotency'); + expect(afterMongo.find(index => index.name === 'ttl')).toBeDefined(); + }); }); describe('ClassLevelPermissions', () => { diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 01a3aded2c..4f979e77dd 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -912,6 +912,37 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { expect(userIndexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined(); expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined(); }); + + it('should not throw when concurrent updates drop the same index', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + const className = 'ConcurrentIndexDrop'; + await adapter.createClass(className, { + fields: { + name: { type: 'String' }, + }, + indexes: { + name_1: { name: 1 }, + }, + }); + const stored = await adapter.getClass(className); + const deleteOp = { name_1: { __op: 'Delete' } }; + await Promise.all([ + adapter.setIndexesWithSchemaFormat( + className, + deleteOp, + { ...stored.indexes }, + stored.fields + ), + adapter.setIndexesWithSchemaFormat( + className, + deleteOp, + { ...stored.indexes }, + stored.fields + ), + ]); + const indexes = await adapter.database.collection(className).indexes(); + expect(indexes.find(index => index.name === 'name_1')).toBeUndefined(); + }); }); describe('logClientEvents', () => { diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js index b46388d7ac..33054490d2 100644 --- a/spec/PostgresStorageAdapter.spec.js +++ b/spec/PostgresStorageAdapter.spec.js @@ -579,6 +579,22 @@ describe_only_db('postgres')('PostgresStorageAdapter', () => { await adapter.deleteIdempotencyFunction(); await client.none(qs); }); + + it('does not throw when dropping an index that is already gone', async () => { + const className = 'ConcurrentIndexDrop'; + await dropTable(adapter._client, className); + await adapter.createTable(className, { + fields: { + name: { type: 'String' }, + }, + }); + await adapter.createIndexes(className, [{ name: 'name_idx', key: 'name' }]); + await adapter.dropIndexes(className, ['name_idx']); + await adapter.dropIndexes(className, ['name_idx']); + const indexes = await adapter.getIndexes(className); + expect(indexes.find(index => index.indexname === 'name_idx')).toBeUndefined(); + await dropTable(adapter._client, className); + }); }); describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 3d4c09ea58..e8ad830c98 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -1259,7 +1259,13 @@ export class MongoStorageAdapter implements StorageAdapter { dropIndex(className: string, index: any) { return this._adaptiveCollection(className) .then(collection => collection._mongoCollection.dropIndex(index)) - .catch(err => this.handleError(err)); + .catch(err => { + // IndexNotFound: another instance already dropped this index. + if (err && err.code === 27) { + return; + } + return this.handleError(err); + }); } dropAllIndexes(className: string) { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index be7b5a6987..882c4f3929 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -2557,7 +2557,7 @@ export class PostgresStorageAdapter implements StorageAdapter { async dropIndexes(className: string, indexes: any, conn: any): Promise { const queries = indexes.map(i => ({ - query: 'DROP INDEX $1:name', + query: 'DROP INDEX IF EXISTS $1:name', values: i, })); await (conn || this._client).tx(t => t.none(this._pgp.helpers.concat(queries))); diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js index 0a94a5e4ad..ca10a4b95f 100644 --- a/src/SchemaMigrations/DefinedSchemas.js +++ b/src/SchemaMigrations/DefinedSchemas.js @@ -411,15 +411,21 @@ export class DefinedSchemas { 'case_insensitive_username', 'case_insensitive_email', 'username_1', - 'email_1' + 'email_1', + '_email_verify_token', + '_perishable_token' ); + // Unique indexes created for each auth provider: _auth_data__id + if (/^_auth_data_.+_id$/.test(indexName)) { + return true; + } break; case '_Role': indexes.push('name_1'); break; case '_Idempotency': - indexes.push('reqId_1'); + indexes.push('reqId_1', 'ttl'); break; } From 88702b7dc5cae8c798734fb18cc17424bcea1ab6 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Tue, 27 Oct 2026 10:32:21 +0100 Subject: [PATCH 2/2] test: run internal index migration specs only on MongoDB --- spec/DefinedSchemas.spec.js | 4 ++-- spec/MongoStorageAdapter.spec.js | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js index 8de793d8de..832cde22ad 100644 --- a/spec/DefinedSchemas.spec.js +++ b/spec/DefinedSchemas.spec.js @@ -533,7 +533,7 @@ describe('DefinedSchemas', () => { expect(definedSchema.isProtectedIndex('ExampleClass', '_id_')).toEqual(true); }); - it('should not delete internal _User indexes absent from the local schema', async () => { + it_only_db('mongo')('should not delete internal _User indexes absent from the local schema', async () => { const server = await reconfigureServer(); const config = Config.get(server.config.appId); const adapter = config.database.adapter; @@ -563,7 +563,7 @@ describe('DefinedSchemas', () => { expect(after.indexes.custom_unprotected).toBeUndefined(); }); - it('should not delete the internal ttl index on _Idempotency', async () => { + it_only_db('mongo')('should not delete the internal ttl index on _Idempotency', async () => { const server = await reconfigureServer(); const config = Config.get(server.config.appId); const adapter = config.database.adapter; diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 4f979e77dd..0991a6f484 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -943,6 +943,11 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { const indexes = await adapter.database.collection(className).indexes(); expect(indexes.find(index => index.name === 'name_1')).toBeUndefined(); }); + + it('should still reject dropIndex errors other than IndexNotFound', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await expectAsync(adapter.dropIndex('MissingDropIndexClass', 'missing_index')).toBeRejected(); + }); }); describe('logClientEvents', () => {