diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js
index 45f3461bc4..55a54c0bce 100644
--- a/spec/CloudCode.spec.js
+++ b/spec/CloudCode.spec.js
@@ -51,7 +51,7 @@ describe('Cloud Code', () => {
it('cloud code must be valid type', async () => {
spyOn(console, 'error').and.callFake(() => { });
- await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith(
+ await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWithError(
"argument 'cloud' must either be a string or a function"
);
});
diff --git a/spec/CloudCodeAdapter.integration.spec.js b/spec/CloudCodeAdapter.integration.spec.js
new file mode 100644
index 0000000000..71c6f499cf
--- /dev/null
+++ b/spec/CloudCodeAdapter.integration.spec.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager');
+const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter');
+
+describe('Cloud Code Adapter Integration', () => {
+ describe('composable adapters', () => {
+ it('supports multiple adapters registering different hooks', async () => {
+ const manager = new CloudCodeManager();
+
+ const inProcessCloud = {
+ getRouter() {
+ return {
+ getManifest() {
+ return {
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [{ name: 'inProcessFn' }],
+ triggers: [],
+ jobs: [],
+ },
+ };
+ },
+ async dispatchFunction() { return { success: 'from-in-process' }; },
+ async dispatchTrigger() { return { success: {} }; },
+ async dispatchJob() { return { success: null }; },
+ };
+ },
+ };
+
+ const inProcessAdapter = new InProcessAdapter(inProcessCloud);
+ const inProcessRegistry = manager.createRegistry(inProcessAdapter.name);
+ await inProcessAdapter.initialize(inProcessRegistry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ // Simulate legacy registration via manager directly
+ manager.defineFunction('legacyFn', () => 'from-legacy', 'legacy');
+
+ const legacyEntry = manager.getFunction('legacyFn');
+ expect(legacyEntry).toBeDefined();
+ expect(manager.getFunction('inProcessFn')).toBeDefined();
+ expect(manager.getFunctionNames().sort()).toEqual(['inProcessFn', 'legacyFn']);
+ });
+
+ it('throws on conflict between adapters', async () => {
+ const manager = new CloudCodeManager();
+
+ // Register a function from "legacy" source
+ manager.defineFunction('shared', () => 'from-legacy', 'legacy');
+
+ // InProcess adapter tries to register same function
+ const inProcessCloud = {
+ getRouter() {
+ return {
+ getManifest() {
+ return {
+ protocol: 'ParseCloud/1.0',
+ hooks: { functions: [{ name: 'shared' }], triggers: [], jobs: [] },
+ };
+ },
+ async dispatchFunction() { return { success: 'from-in-process' }; },
+ async dispatchTrigger() { return { success: {} }; },
+ async dispatchJob() { return { success: null }; },
+ };
+ },
+ };
+
+ const adapter = new InProcessAdapter(inProcessCloud);
+ const registry = manager.createRegistry(adapter.name);
+
+ await expectAsync(
+ adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' })
+ ).toBeRejectedWithError(/already registered/);
+ });
+ });
+
+ describe('shutdown', () => {
+ it('shuts down all adapters', async () => {
+ const manager = new CloudCodeManager();
+ let shutdownCalled = false;
+
+ const adapter = {
+ name: 'test',
+ async initialize() {},
+ async isHealthy() { return true; },
+ async shutdown() { shutdownCalled = true; },
+ };
+
+ await manager.initialize([adapter], { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+ await manager.shutdown();
+
+ expect(shutdownCalled).toBe(true);
+ });
+ });
+
+ describe('unregisterAll', () => {
+ it('allows re-registration after unregisterAll', () => {
+ const manager = new CloudCodeManager();
+ manager.defineFunction('fn', () => 'first', 'adapter-a');
+ manager.unregisterAll('adapter-a');
+
+ expect(() => {
+ manager.defineFunction('fn', () => 'second', 'adapter-b');
+ }).not.toThrow();
+ expect(manager.getFunction('fn')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/CloudCodeManager.spec.js b/spec/CloudCodeManager.spec.js
new file mode 100644
index 0000000000..7e1f1bfcd8
--- /dev/null
+++ b/spec/CloudCodeManager.spec.js
@@ -0,0 +1,658 @@
+'use strict';
+
+const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager');
+
+describe('CloudCodeManager', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new CloudCodeManager();
+ });
+
+ // ─── Function Registration ───────────────────────────────────────────────────
+
+ describe('defineFunction', () => {
+ it('registers a function', () => {
+ const handler = () => 'result';
+ manager.defineFunction('myFunc', handler, 'source-a');
+ const entry = manager.getFunction('myFunc');
+ expect(entry).not.toBeNull();
+ expect(entry.handler).toBe(handler);
+ expect(entry.source).toBe('source-a');
+ });
+
+ it('allows overwriting a function from the same source', () => {
+ const handler1 = () => 'v1';
+ const handler2 = () => 'v2';
+ manager.defineFunction('myFunc', handler1, 'source-a');
+ manager.defineFunction('myFunc', handler2, 'source-a');
+ expect(manager.getFunction('myFunc').handler).toBe(handler2);
+ });
+
+ it('throws when a different source tries to register the same function', () => {
+ manager.defineFunction('myFunc', () => {}, 'source-a');
+ expect(() => {
+ manager.defineFunction('myFunc', () => {}, 'source-b');
+ }).toThrowError(/already registered/i);
+ });
+
+ it('stores a validator with the function', () => {
+ const handler = () => {};
+ const validator = req => {};
+ manager.defineFunction('myFunc', handler, 'source-a', validator);
+ expect(manager.getFunction('myFunc').validator).toBe(validator);
+ });
+
+ it('returns null for an unknown function', () => {
+ expect(manager.getFunction('unknown')).toBeNull();
+ });
+ });
+
+ // ─── Trigger Registration ────────────────────────────────────────────────────
+
+ describe('defineTrigger', () => {
+ it('registers a trigger', () => {
+ const handler = () => {};
+ manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a');
+ const entry = manager.getTrigger('MyClass', 'beforeSave');
+ expect(entry).not.toBeNull();
+ expect(entry.handler).toBe(handler);
+ expect(entry.source).toBe('source-a');
+ });
+
+ it('allows overwriting a trigger from the same source', () => {
+ const handler1 = () => {};
+ const handler2 = () => {};
+ manager.defineTrigger('MyClass', 'beforeSave', handler1, 'source-a');
+ manager.defineTrigger('MyClass', 'beforeSave', handler2, 'source-a');
+ expect(manager.getTrigger('MyClass', 'beforeSave').handler).toBe(handler2);
+ });
+
+ it('throws when a different source tries to register the same trigger', () => {
+ manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a');
+ expect(() => {
+ manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-b');
+ }).toThrowError(/already registered/i);
+ });
+
+ it('stores a validator with the trigger', () => {
+ const handler = () => {};
+ const validator = { requireUser: true };
+ manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator);
+ expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator);
+ });
+
+ it('returns null for an unknown trigger', () => {
+ expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull();
+ });
+
+ it('triggerExists returns true for a registered trigger', () => {
+ manager.defineTrigger('MyClass', 'afterSave', () => {}, 'source-a');
+ expect(manager.triggerExists('MyClass', 'afterSave')).toBe(true);
+ });
+
+ it('triggerExists returns false for an unregistered trigger', () => {
+ expect(manager.triggerExists('MyClass', 'afterSave')).toBe(false);
+ });
+ });
+
+ // ─── Validation Rules ────────────────────────────────────────────────────────
+
+ describe('validation rules', () => {
+ it('rejects beforeSave on _PushStatus', () => {
+ expect(() => {
+ manager.defineTrigger('_PushStatus', 'beforeSave', () => {}, 'source-a');
+ }).toThrowError(/_PushStatus/);
+ });
+
+ it('allows afterSave on _PushStatus', () => {
+ expect(() => {
+ manager.defineTrigger('_PushStatus', 'afterSave', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('rejects beforeDelete on _PushStatus', () => {
+ expect(() => {
+ manager.defineTrigger('_PushStatus', 'beforeDelete', () => {}, 'source-a');
+ }).toThrowError(/_PushStatus/);
+ });
+
+ it('rejects beforeFind on _PushStatus', () => {
+ expect(() => {
+ manager.defineTrigger('_PushStatus', 'beforeFind', () => {}, 'source-a');
+ }).toThrowError(/_PushStatus/);
+ });
+
+ it('rejects beforeSave on _Session', () => {
+ expect(() => {
+ manager.defineTrigger('_Session', 'beforeSave', () => {}, 'source-a');
+ }).toThrowError(/_Session/);
+ });
+
+ it('rejects afterSave on _Session', () => {
+ expect(() => {
+ manager.defineTrigger('_Session', 'afterSave', () => {}, 'source-a');
+ }).toThrowError(/_Session/);
+ });
+
+ it('allows afterLogout on _Session', () => {
+ expect(() => {
+ manager.defineTrigger('_Session', 'afterLogout', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('rejects beforeLogin on a non-_User class', () => {
+ expect(() => {
+ manager.defineTrigger('SomeClass', 'beforeLogin', () => {}, 'source-a');
+ }).toThrowError(/_User/);
+ });
+
+ it('allows beforeLogin on _User', () => {
+ expect(() => {
+ manager.defineTrigger('_User', 'beforeLogin', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('rejects afterLogin on a non-_User class', () => {
+ expect(() => {
+ manager.defineTrigger('SomeClass', 'afterLogin', () => {}, 'source-a');
+ }).toThrowError(/_User/);
+ });
+
+ it('allows afterLogin on _User', () => {
+ expect(() => {
+ manager.defineTrigger('_User', 'afterLogin', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('rejects beforePasswordResetRequest on a non-_User class', () => {
+ expect(() => {
+ manager.defineTrigger('SomeClass', 'beforePasswordResetRequest', () => {}, 'source-a');
+ }).toThrowError(/_User/);
+ });
+
+ it('allows beforePasswordResetRequest on _User', () => {
+ expect(() => {
+ manager.defineTrigger('_User', 'beforePasswordResetRequest', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('rejects afterLogout on a non-_Session class', () => {
+ expect(() => {
+ manager.defineTrigger('_User', 'afterLogout', () => {}, 'source-a');
+ }).toThrowError(/_Session/);
+ });
+
+ it('allows beforeSave on @File virtual className', () => {
+ expect(() => {
+ manager.defineTrigger('@File', 'beforeSave', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+
+ it('allows beforeConnect on @Connect virtual className', () => {
+ expect(() => {
+ manager.defineTrigger('@Connect', 'beforeConnect', () => {}, 'source-a');
+ }).not.toThrow();
+ });
+ });
+
+ // ─── Job Registration ─────────────────────────────────────────────────────────
+
+ describe('defineJob', () => {
+ it('registers a job', () => {
+ const handler = () => {};
+ manager.defineJob('myJob', handler, 'source-a');
+ const entry = manager.getJob('myJob');
+ expect(entry).not.toBeNull();
+ expect(entry.handler).toBe(handler);
+ expect(entry.source).toBe('source-a');
+ });
+
+ it('allows overwriting a job from the same source', () => {
+ const handler1 = () => {};
+ const handler2 = () => {};
+ manager.defineJob('myJob', handler1, 'source-a');
+ manager.defineJob('myJob', handler2, 'source-a');
+ expect(manager.getJob('myJob').handler).toBe(handler2);
+ });
+
+ it('throws when a different source tries to register the same job', () => {
+ manager.defineJob('myJob', () => {}, 'source-a');
+ expect(() => {
+ manager.defineJob('myJob', () => {}, 'source-b');
+ }).toThrowError(/already registered/i);
+ });
+
+ it('returns null for an unknown job', () => {
+ expect(manager.getJob('unknown')).toBeNull();
+ });
+
+ it('getJobs returns all jobs as a Map', () => {
+ manager.defineJob('job1', () => {}, 'source-a');
+ manager.defineJob('job2', () => {}, 'source-a');
+ const jobs = manager.getJobs();
+ expect(jobs).toBeInstanceOf(Map);
+ expect(jobs.size).toBe(2);
+ expect(jobs.has('job1')).toBe(true);
+ expect(jobs.has('job2')).toBe(true);
+ });
+
+ it('getJobsObject returns all jobs as a plain object', () => {
+ manager.defineJob('job1', () => {}, 'source-a');
+ manager.defineJob('job2', () => {}, 'source-a');
+ const jobs = manager.getJobsObject();
+ expect(typeof jobs).toBe('object');
+ expect(jobs['job1']).toBeDefined();
+ expect(jobs['job2']).toBeDefined();
+ });
+ });
+
+ // ─── Live Query Handlers ─────────────────────────────────────────────────────
+
+ describe('defineLiveQueryHandler', () => {
+ it('registers a live query handler', () => {
+ const handler = data => {};
+ manager.defineLiveQueryHandler(handler, 'source-a');
+ // runLiveQueryEventHandlers should call the handler
+ let called = false;
+ const h = data => { called = true; };
+ manager.defineLiveQueryHandler(h, 'source-a');
+ manager.runLiveQueryEventHandlers({ event: 'test' });
+ expect(called).toBe(true);
+ });
+
+ it('runs all live query handlers when runLiveQueryEventHandlers is called', () => {
+ const calls = [];
+ manager.defineLiveQueryHandler(data => calls.push('h1'), 'source-a');
+ manager.defineLiveQueryHandler(data => calls.push('h2'), 'source-b');
+ manager.runLiveQueryEventHandlers({ event: 'test' });
+ expect(calls).toEqual(['h1', 'h2']);
+ });
+
+ it('passes data to each live query handler', () => {
+ let received;
+ manager.defineLiveQueryHandler(data => { received = data; }, 'source-a');
+ manager.runLiveQueryEventHandlers({ event: 'create', objectId: '123' });
+ expect(received).toEqual({ event: 'create', objectId: '123' });
+ });
+ });
+
+ // ─── Lookup Methods ──────────────────────────────────────────────────────────
+
+ describe('getFunctionNames', () => {
+ it('returns an empty array when no functions are registered', () => {
+ expect(manager.getFunctionNames()).toEqual([]);
+ });
+
+ it('returns names of all registered functions', () => {
+ manager.defineFunction('funcA', () => {}, 'source-a');
+ manager.defineFunction('funcB', () => {}, 'source-a');
+ const names = manager.getFunctionNames();
+ expect(names.sort()).toEqual(['funcA', 'funcB']);
+ });
+ });
+
+ describe('getValidator', () => {
+ it('returns the validator for a function', () => {
+ const validator = req => {};
+ manager.defineFunction('myFunc', () => {}, 'source-a', validator);
+ expect(manager.getValidator('myFunc')).toBe(validator);
+ });
+
+ it('returns null when function has no validator', () => {
+ manager.defineFunction('myFunc', () => {}, 'source-a');
+ expect(manager.getValidator('myFunc')).toBeNull();
+ });
+
+ it('returns null when function does not exist', () => {
+ expect(manager.getValidator('unknown')).toBeNull();
+ });
+ });
+
+ // ─── Removal ─────────────────────────────────────────────────────────────────
+
+ describe('removeFunction', () => {
+ it('removes a registered function', () => {
+ manager.defineFunction('myFunc', () => {}, 'source-a');
+ manager.removeFunction('myFunc');
+ expect(manager.getFunction('myFunc')).toBeNull();
+ });
+
+ it('does not throw when removing an unknown function', () => {
+ expect(() => manager.removeFunction('unknown')).not.toThrow();
+ });
+ });
+
+ describe('removeTrigger', () => {
+ it('removes a registered trigger', () => {
+ manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a');
+ manager.removeTrigger('MyClass', 'beforeSave');
+ expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull();
+ });
+
+ it('does not throw when removing an unknown trigger', () => {
+ expect(() => manager.removeTrigger('MyClass', 'beforeSave')).not.toThrow();
+ });
+ });
+
+ describe('unregisterAll', () => {
+ it('removes all hooks registered by a given source', () => {
+ manager.defineFunction('funcA', () => {}, 'source-a');
+ manager.defineFunction('funcB', () => {}, 'source-b');
+ manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a');
+ manager.defineJob('jobA', () => {}, 'source-a');
+
+ manager.unregisterAll('source-a');
+
+ expect(manager.getFunction('funcA')).toBeNull();
+ expect(manager.getFunction('funcB')).not.toBeNull();
+ expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull();
+ expect(manager.getJob('jobA')).toBeNull();
+ });
+
+ it('removes live query handlers registered by a given source', () => {
+ const calls = [];
+ manager.defineLiveQueryHandler(() => calls.push('a'), 'source-a');
+ manager.defineLiveQueryHandler(() => calls.push('b'), 'source-b');
+
+ manager.unregisterAll('source-a');
+ manager.runLiveQueryEventHandlers({});
+
+ expect(calls).toEqual(['b']);
+ });
+ });
+
+ describe('clearAll', () => {
+ it('removes all registered hooks regardless of source', () => {
+ manager.defineFunction('funcA', () => {}, 'source-a');
+ manager.defineFunction('funcB', () => {}, 'source-b');
+ manager.defineTrigger('MyClass', 'beforeSave', () => {}, 'source-a');
+ manager.defineJob('jobA', () => {}, 'source-a');
+
+ manager.clearAll();
+
+ expect(manager.getFunction('funcA')).toBeNull();
+ expect(manager.getFunction('funcB')).toBeNull();
+ expect(manager.getTrigger('MyClass', 'beforeSave')).toBeNull();
+ expect(manager.getJob('jobA')).toBeNull();
+ expect(manager.getFunctionNames()).toEqual([]);
+ });
+
+ it('clears live query handlers', () => {
+ const calls = [];
+ manager.defineLiveQueryHandler(() => calls.push('a'), 'source-a');
+ manager.clearAll();
+ manager.runLiveQueryEventHandlers({});
+ expect(calls).toEqual([]);
+ });
+ });
+
+ // ─── Registry Scoping ────────────────────────────────────────────────────────
+
+ describe('createRegistry', () => {
+ it('returns a registry scoped to the given source', () => {
+ const registry = manager.createRegistry('source-a');
+ const handler = () => {};
+ registry.defineFunction('myFunc', handler);
+ expect(manager.getFunction('myFunc').source).toBe('source-a');
+ });
+
+ it('scoped registry defineFunction stores the handler correctly', () => {
+ const registry = manager.createRegistry('source-a');
+ const handler = () => 'result';
+ registry.defineFunction('myFunc', handler);
+ expect(manager.getFunction('myFunc').handler).toBe(handler);
+ });
+
+ it('scoped registry defineTrigger stores the trigger correctly', () => {
+ const registry = manager.createRegistry('source-a');
+ const handler = () => {};
+ registry.defineTrigger('MyClass', 'beforeSave', handler);
+ expect(manager.getTrigger('MyClass', 'beforeSave').source).toBe('source-a');
+ });
+
+ it('scoped registry defineJob stores the job correctly', () => {
+ const registry = manager.createRegistry('source-a');
+ const handler = () => {};
+ registry.defineJob('myJob', handler);
+ expect(manager.getJob('myJob').source).toBe('source-a');
+ });
+
+ it('scoped registry defineLiveQueryHandler registers the handler', () => {
+ const registry = manager.createRegistry('source-a');
+ const calls = [];
+ registry.defineLiveQueryHandler(data => calls.push(data));
+ manager.runLiveQueryEventHandlers({ event: 'test' });
+ expect(calls.length).toBe(1);
+ });
+
+ it('scoped registry conflict detection uses the scoped source', () => {
+ const registryA = manager.createRegistry('source-a');
+ const registryB = manager.createRegistry('source-b');
+ registryA.defineFunction('myFunc', () => {});
+ expect(() => {
+ registryB.defineFunction('myFunc', () => {});
+ }).toThrowError(/already registered/i);
+ });
+ });
+
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
+
+ describe('initialize', () => {
+ it('calls initialize on each adapter', async () => {
+ const calls = [];
+ const adapterA = {
+ name: 'adapter-a',
+ initialize: async (registry, config) => { calls.push('a'); },
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const adapterB = {
+ name: 'adapter-b',
+ initialize: async (registry, config) => { calls.push('b'); },
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapterA, adapterB], config);
+ expect(calls).toEqual(['a', 'b']);
+ });
+
+ it('passes a scoped registry to each adapter', async () => {
+ let capturedRegistry;
+ const adapter = {
+ name: 'test-adapter',
+ initialize: async (registry, config) => {
+ capturedRegistry = registry;
+ registry.defineFunction('adapterFunc', () => {});
+ },
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapter], config);
+ expect(manager.getFunction('adapterFunc')).not.toBeNull();
+ expect(manager.getFunction('adapterFunc').source).toBe('test-adapter');
+ });
+
+ it('rolls back failed adapter and previously-initialized adapters', async () => {
+ const shutdownCalls = [];
+ const adapterA = {
+ name: 'adapter-a',
+ initialize: async (registry) => {
+ registry.defineFunction('funcFromA', () => {});
+ },
+ isHealthy: async () => true,
+ shutdown: async () => { shutdownCalls.push('a'); },
+ };
+ const adapterB = {
+ name: 'adapter-b',
+ initialize: async () => { throw new Error('adapter-b failed'); },
+ isHealthy: async () => true,
+ shutdown: async () => { shutdownCalls.push('b'); },
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await expectAsync(manager.initialize([adapterA, adapterB], config)).toBeRejectedWithError('adapter-b failed');
+ expect(manager.getFunction('funcFromA')).toBeNull();
+ expect(shutdownCalls).toContain('a');
+ expect(shutdownCalls).toContain('b');
+ // manager should have no adapters left — healthCheck with no adapters returns true
+ const healthy = await manager.healthCheck();
+ expect(healthy).toBe(true);
+ });
+
+ it('rolls back partial registrations from the failing adapter', async () => {
+ const adapter = {
+ name: 'partial-adapter',
+ initialize: async (registry) => {
+ registry.defineFunction('partialFunc', () => {});
+ throw new Error('partial failure');
+ },
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await expectAsync(manager.initialize([adapter], config)).toBeRejectedWithError('partial failure');
+ expect(manager.getFunction('partialFunc')).toBeNull();
+ });
+
+ it('throws when two adapters have the same name', async () => {
+ const adapterA = {
+ name: 'duplicate',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const adapterB = {
+ name: 'duplicate',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await expectAsync(manager.initialize([adapterA, adapterB], config)).toBeRejectedWithError(/duplicate/i);
+ });
+ });
+
+ describe('shutdown', () => {
+ it('calls shutdown on each initialized adapter', async () => {
+ const calls = [];
+ const adapter = {
+ name: 'adapter-a',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => { calls.push('shutdown'); },
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapter], config);
+ await manager.shutdown();
+ expect(calls).toEqual(['shutdown']);
+ });
+
+ it('continues shutting down other adapters when one fails', async () => {
+ spyOn(console, 'error').and.callFake(() => {});
+ const shutdownCalls = [];
+ const adapterA = {
+ name: 'adapter-a',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => {
+ shutdownCalls.push('a');
+ throw new Error('shutdown-a failed');
+ },
+ };
+ const adapterB = {
+ name: 'adapter-b',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => { shutdownCalls.push('b'); },
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapterA, adapterB], config);
+ await manager.shutdown();
+ expect(shutdownCalls).toContain('a');
+ expect(shutdownCalls).toContain('b');
+ });
+
+ it('clears all registrations after shutdown', async () => {
+ const adapter = {
+ name: 'adapter-a',
+ initialize: async (registry) => {
+ registry.defineFunction('myFunc', () => {});
+ },
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapter], config);
+ expect(manager.getFunction('myFunc')).not.toBeNull();
+ await manager.shutdown();
+ expect(manager.getFunction('myFunc')).toBeNull();
+ expect(manager.getFunctionNames()).toEqual([]);
+ });
+ });
+
+ describe('healthCheck', () => {
+ it('returns true when all adapters are healthy', async () => {
+ const adapter = {
+ name: 'adapter-a',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapter], config);
+ const healthy = await manager.healthCheck();
+ expect(healthy).toBe(true);
+ });
+
+ it('returns false when any adapter is unhealthy', async () => {
+ const adapterA = {
+ name: 'adapter-a',
+ initialize: async () => {},
+ isHealthy: async () => true,
+ shutdown: async () => {},
+ };
+ const adapterB = {
+ name: 'adapter-b',
+ initialize: async () => {},
+ isHealthy: async () => false,
+ shutdown: async () => {},
+ };
+ const config = { appId: 'testApp', masterKey: 'key', serverURL: 'http://localhost:1337/parse' };
+ await manager.initialize([adapterA, adapterB], config);
+ const healthy = await manager.healthCheck();
+ expect(healthy).toBe(false);
+ });
+
+ it('returns true when no adapters are registered', async () => {
+ const healthy = await manager.healthCheck();
+ expect(healthy).toBe(true);
+ });
+ });
+
+ // ─── Validators for triggers ─────────────────────────────────────────────────
+
+ describe('trigger validators', () => {
+ it('stores a function validator on a trigger', () => {
+ const handler = () => {};
+ const validator = req => {};
+ manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator);
+ expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator);
+ });
+
+ it('stores an object validator on a trigger', () => {
+ const handler = () => {};
+ const validator = { requireUser: true };
+ manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a', validator);
+ expect(manager.getTrigger('MyClass', 'beforeSave').validator).toBe(validator);
+ });
+
+ it('stores undefined validator when no validator provided', () => {
+ const handler = () => {};
+ manager.defineTrigger('MyClass', 'beforeSave', handler, 'source-a');
+ const entry = manager.getTrigger('MyClass', 'beforeSave');
+ expect(entry.validator).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/ExternalProcessAdapter.spec.js b/spec/ExternalProcessAdapter.spec.js
new file mode 100644
index 0000000000..c10dd0951e
--- /dev/null
+++ b/spec/ExternalProcessAdapter.spec.js
@@ -0,0 +1,252 @@
+// spec/ExternalProcessAdapter.spec.js
+const { ExternalProcessAdapter } = require('../lib/cloud-code/adapters/ExternalProcessAdapter');
+const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager');
+const http = require('http');
+
+function createMockCloudServer(manifest) {
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ if (req.url === '/' && req.method === 'GET') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(manifest));
+ } else if (req.url === '/health' && req.method === 'GET') {
+ res.writeHead(200);
+ res.end('OK');
+ } else if (req.url.startsWith('/functions/') && req.method === 'POST') {
+ let body = '';
+ req.on('data', d => body += d);
+ req.on('end', () => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: 'external-result' }));
+ });
+ } else {
+ res.writeHead(404);
+ res.end();
+ }
+ });
+ server.on('error', (err) => reject(err));
+ server.listen(0, () => resolve({ server, port: server.address().port }));
+ });
+}
+
+describe('ExternalProcessAdapter', () => {
+ it('derives name from webhookKey by default', () => {
+ const adapter = new ExternalProcessAdapter('echo test', 'secret-key');
+ expect(adapter.name).toBe('external-process-secret-k');
+ });
+
+ it('accepts a custom name', () => {
+ const adapter = new ExternalProcessAdapter('echo test', 'secret-key', undefined, 'my-adapter');
+ expect(adapter.name).toBe('my-adapter');
+ });
+
+ it('requires webhookKey', () => {
+ expect(() => new ExternalProcessAdapter('echo test', '')).toThrowError(/webhookKey/);
+ });
+
+ it('shutdown resolves cleanly when no process started', async () => {
+ const adapter = new ExternalProcessAdapter('echo test', 'key');
+ await expectAsync(adapter.shutdown()).toBeResolved();
+ });
+
+ it('isHealthy returns true for running server', async () => {
+ const manager = new CloudCodeManager();
+ const { server, port } = await createMockCloudServer(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } }
+ );
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ });
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const healthy = await adapter.isHealthy();
+ expect(healthy).toBe(true);
+ } finally {
+ if (adapter) {
+ await adapter.shutdown();
+ }
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+
+ it('isHealthy returns false when server is down', async () => {
+ const adapter = new ExternalProcessAdapter('echo test', 'test-key');
+ // Port is 0 (default) since we never initialized — any HTTP request will fail
+ const healthy = await adapter.isHealthy();
+ expect(healthy).toBe(false);
+ });
+
+ it('cleans up process on manifest fetch failure', async () => {
+ // Create a server that returns 500 for the manifest endpoint
+ const server = await new Promise((resolve, reject) => {
+ const srv = http.createServer((req, res) => {
+ if (req.url === '/' && req.method === 'GET') {
+ res.writeHead(500);
+ res.end('Internal Server Error');
+ } else {
+ res.writeHead(404);
+ res.end();
+ }
+ });
+ srv.on('error', reject);
+ srv.listen(0, () => resolve(srv));
+ });
+ const port = server.address().port;
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ });
+ const manager = new CloudCodeManager();
+ const registry = manager.createRegistry(adapter.name);
+
+ await expectAsync(
+ adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' })
+ ).toBeRejectedWithError(/500/);
+
+ // After failure, the process should have been cleaned up by shutdown()
+ // Calling shutdown again should resolve cleanly (process already null)
+ await expectAsync(adapter.shutdown()).toBeResolved();
+ } finally {
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+
+ it('registers triggers including beforeSave', async () => {
+ const manager = new CloudCodeManager();
+ const { server, port } = await createMockCloudServer({
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [],
+ triggers: [
+ { className: 'GameScore', triggerName: 'beforeSave' },
+ { className: 'GameScore', triggerName: 'afterSave' },
+ ],
+ jobs: [],
+ },
+ });
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ });
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getTrigger('GameScore', 'beforeSave')).toBeDefined();
+ expect(manager.getTrigger('GameScore', 'afterSave')).toBeDefined();
+ } finally {
+ if (adapter) {
+ await adapter.shutdown();
+ }
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+
+ it('registers jobs from manifest', async () => {
+ const manager = new CloudCodeManager();
+ const { server, port } = await createMockCloudServer({
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [],
+ triggers: [],
+ jobs: [{ name: 'cleanupJob' }],
+ },
+ });
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ });
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getJob('cleanupJob')).toBeDefined();
+ } finally {
+ if (adapter) {
+ await adapter.shutdown();
+ }
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+
+ it('shutdown terminates a running process', async () => {
+ const manager = new CloudCodeManager();
+ const { server, port } = await createMockCloudServer(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } }
+ );
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ shutdownTimeout: 2000,
+ });
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ // Shutdown should terminate the spawned process
+ await expectAsync(adapter.shutdown()).toBeResolved();
+
+ // After shutdown, isHealthy should return false (port no longer served by our process)
+ // and a second shutdown should be a no-op
+ await expectAsync(adapter.shutdown()).toBeResolved();
+ } finally {
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+
+ it('spawns process and reads manifest', async () => {
+ const manager = new CloudCodeManager();
+ const { server, port } = await createMockCloudServer(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'ext-fn' }], triggers: [], jobs: [] } }
+ );
+
+ let adapter;
+ try {
+ const cmd = `node -e "process.stdout.write('PARSE_CLOUD_READY:${port}\\n'); setTimeout(() => {}, 60000)"`;
+ adapter = new ExternalProcessAdapter(cmd, 'test-key', {
+ startupTimeout: 5000,
+ healthCheckInterval: 0,
+ });
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getFunction('ext-fn')).toBeDefined();
+ } finally {
+ if (adapter) {
+ await adapter.shutdown();
+ }
+ await new Promise((resolve, reject) => {
+ server.close(err => (err ? reject(err) : resolve()));
+ });
+ }
+ }, 10000);
+});
diff --git a/spec/InProcessAdapter.spec.js b/spec/InProcessAdapter.spec.js
new file mode 100644
index 0000000000..1822313493
--- /dev/null
+++ b/spec/InProcessAdapter.spec.js
@@ -0,0 +1,222 @@
+const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter');
+const { CloudCodeManager } = require('../lib/cloud-code/CloudCodeManager');
+
+function createMockCloudCode(manifest, handlers = {}) {
+ return {
+ getRouter() {
+ return {
+ getManifest() { return manifest; },
+ async dispatchFunction(name, body) {
+ if (handlers[`function:${name}`]) {
+ return handlers[`function:${name}`](body);
+ }
+ return { success: null };
+ },
+ async dispatchTrigger(className, triggerName, body) {
+ if (handlers[`trigger:${triggerName}.${className}`]) {
+ return handlers[`trigger:${triggerName}.${className}`](body);
+ }
+ return { success: {} };
+ },
+ async dispatchJob(name, body) {
+ if (handlers[`job:${name}`]) {
+ return handlers[`job:${name}`](body);
+ }
+ return { success: null };
+ },
+ };
+ },
+ };
+}
+
+describe('InProcessAdapter', () => {
+ let manager;
+
+ beforeEach(() => {
+ manager = new CloudCodeManager();
+ });
+
+ it('has name "in-process"', () => {
+ const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } });
+ const adapter = new InProcessAdapter(cloud);
+ expect(adapter.name).toBe('in-process');
+ });
+
+ it('registers functions from manifest', async () => {
+ const cloud = createMockCloudCode({
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [{ name: 'hello' }, { name: 'greet' }],
+ triggers: [],
+ jobs: [],
+ },
+ });
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getFunction('hello')).toBeDefined();
+ expect(manager.getFunction('greet')).toBeDefined();
+ });
+
+ it('registers triggers from manifest', async () => {
+ const cloud = createMockCloudCode({
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [],
+ triggers: [{ className: 'Todo', triggerName: 'beforeSave' }],
+ jobs: [],
+ },
+ });
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getTrigger('Todo', 'beforeSave')).toBeDefined();
+ });
+
+ it('registers jobs from manifest', async () => {
+ const cloud = createMockCloudCode({
+ protocol: 'ParseCloud/1.0',
+ hooks: {
+ functions: [],
+ triggers: [],
+ jobs: [{ name: 'cleanup' }],
+ },
+ });
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ expect(manager.getJob('cleanup')).toBeDefined();
+ });
+
+ it('bridge handler dispatches function and returns result', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'add' }], triggers: [], jobs: [] } },
+ { 'function:add': (body) => ({ success: body.params.a + body.params.b }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getFunction('add');
+ const result = await entry.handler({ params: { a: 2, b: 3 }, master: false, ip: '127.0.0.1', headers: {} });
+ expect(result).toBe(5);
+ });
+
+ it('bridge handler throws Parse.Error on error response', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [{ name: 'fail' }], triggers: [], jobs: [] } },
+ { 'function:fail': () => ({ error: { code: 141, message: 'boom' } }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getFunction('fail');
+ await expectAsync(entry.handler({ params: {}, master: false, ip: '', headers: {} }))
+ .toBeRejectedWithError(/boom/);
+ });
+
+ it('isHealthy returns true', async () => {
+ const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } });
+ const adapter = new InProcessAdapter(cloud);
+ expect(await adapter.isHealthy()).toBe(true);
+ });
+
+ it('beforeSave trigger with request.object calls applyBeforeSaveResponse', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: 'Todo', triggerName: 'beforeSave' }], jobs: [] } },
+ { 'trigger:beforeSave.Todo': () => ({ success: { field1: 'value1' } }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getTrigger('Todo', 'beforeSave');
+ const request = {
+ object: { set: jasmine.createSpy('set'), toJSON: () => ({}) },
+ master: false,
+ ip: '',
+ headers: {},
+ };
+ const result = await entry.handler(request);
+ expect(result).toBeUndefined();
+ expect(request.object.set).toHaveBeenCalledWith('field1', 'value1');
+ });
+
+ it('beforeSave trigger without request.object returns webhookResponseToResult', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: '@File', triggerName: 'beforeSave' }], jobs: [] } },
+ { 'trigger:beforeSave.@File': () => ({ success: { name: 'test.txt' } }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getTrigger('@File', 'beforeSave');
+ const request = {
+ file: { name: 'test.txt' },
+ master: false,
+ ip: '',
+ headers: {},
+ };
+ const result = await entry.handler(request);
+ expect(result).toEqual({ name: 'test.txt' });
+ });
+
+ it('beforeSave trigger without request.object returning empty object returns undefined', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: '@File', triggerName: 'beforeSave' }], jobs: [] } },
+ { 'trigger:beforeSave.@File': () => ({ success: {} }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getTrigger('@File', 'beforeSave');
+ const request = {
+ file: { name: 'test.txt' },
+ master: false,
+ ip: '',
+ headers: {},
+ };
+ const result = await entry.handler(request);
+ expect(result).toBeUndefined();
+ });
+
+ it('non-beforeSave trigger returns webhookResponseToResult', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [{ className: 'Todo', triggerName: 'afterSave' }], jobs: [] } },
+ { 'trigger:afterSave.Todo': () => ({ success: { saved: true } }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getTrigger('Todo', 'afterSave');
+ const result = await entry.handler({ object: { toJSON: () => ({}) }, master: false, ip: '', headers: {} });
+ expect(result).toEqual({ saved: true });
+ });
+
+ it('bridge handler dispatches job and returns result', async () => {
+ const cloud = createMockCloudCode(
+ { protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [{ name: 'cleanup' }] } },
+ { 'job:cleanup': () => ({ success: 'done' }) }
+ );
+ const adapter = new InProcessAdapter(cloud);
+ const registry = manager.createRegistry(adapter.name);
+ await adapter.initialize(registry, { appId: 'test', masterKey: 'mk', serverURL: 'http://localhost' });
+
+ const entry = manager.getJob('cleanup');
+ const result = await entry.handler({ params: {}, master: true, ip: '', headers: {} });
+ expect(result).toBe('done');
+ });
+
+ it('shutdown resolves cleanly', async () => {
+ const cloud = createMockCloudCode({ protocol: 'ParseCloud/1.0', hooks: { functions: [], triggers: [], jobs: [] } });
+ const adapter = new InProcessAdapter(cloud);
+ await expectAsync(adapter.shutdown()).toBeResolved();
+ });
+});
diff --git a/spec/LegacyAdapter.spec.js b/spec/LegacyAdapter.spec.js
new file mode 100644
index 0000000000..b67d707a7d
--- /dev/null
+++ b/spec/LegacyAdapter.spec.js
@@ -0,0 +1,74 @@
+const { LegacyAdapter } = require('../lib/cloud-code/adapters/LegacyAdapter');
+const path = require('path');
+
+const mockRegistry = {
+ defineFunction: () => {},
+ defineTrigger: () => {},
+ defineJob: () => {},
+ defineLiveQueryHandler: () => {},
+};
+const mockConfig = {
+ appId: 'test',
+ masterKey: 'mk',
+ serverURL: 'http://localhost',
+};
+
+describe('LegacyAdapter', () => {
+ it('has name "legacy"', () => {
+ const adapter = new LegacyAdapter(() => {});
+ expect(adapter.name).toBe('legacy');
+ });
+
+ it('initialize calls a function with Parse', async () => {
+ const cloudFn = jasmine.createSpy('cloudFn');
+ const adapter = new LegacyAdapter(cloudFn);
+ await adapter.initialize(mockRegistry, mockConfig);
+ expect(cloudFn).toHaveBeenCalledTimes(1);
+ const Parse = require('parse/node').Parse;
+ expect(cloudFn).toHaveBeenCalledWith(Parse);
+ });
+
+ it('initialize awaits a function that returns a Promise', async () => {
+ let resolved = false;
+ const cloudFn = () =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ resolved = true;
+ resolve();
+ }, 10);
+ });
+ const adapter = new LegacyAdapter(cloudFn);
+ await adapter.initialize(mockRegistry, mockConfig);
+ expect(resolved).toBe(true);
+ });
+
+ it('initialize with a valid cloud code file path loads the file', async () => {
+ // Use a minimal temp file that doesn't register global cloud functions
+ const fs = require('fs');
+ const tmpFile = path.resolve(__dirname, '_legacyAdapterTestTemp.js');
+ fs.writeFileSync(tmpFile, 'module.exports = {};');
+ try {
+ const adapter = new LegacyAdapter(tmpFile);
+ await expectAsync(adapter.initialize(mockRegistry, mockConfig)).toBeResolved();
+ } finally {
+ fs.unlinkSync(tmpFile);
+ delete require.cache[require.resolve(tmpFile)];
+ }
+ });
+
+ it('initialize with a non-existent path throws', async () => {
+ const adapter = new LegacyAdapter('/non/existent/path/cloud.js');
+ await expectAsync(adapter.initialize(mockRegistry, mockConfig)).toBeRejected();
+ });
+
+ it('isHealthy returns true', async () => {
+ const adapter = new LegacyAdapter(() => {});
+ const result = await adapter.isHealthy();
+ expect(result).toBe(true);
+ });
+
+ it('shutdown resolves cleanly', async () => {
+ const adapter = new LegacyAdapter(() => {});
+ await expectAsync(adapter.shutdown()).toBeResolved();
+ });
+});
diff --git a/spec/resolveAdapters.spec.js b/spec/resolveAdapters.spec.js
new file mode 100644
index 0000000000..021dd30f28
--- /dev/null
+++ b/spec/resolveAdapters.spec.js
@@ -0,0 +1,81 @@
+const { resolveAdapters } = require('../lib/cloud-code/resolveAdapters');
+const { LegacyAdapter } = require('../lib/cloud-code/adapters/LegacyAdapter');
+const { InProcessAdapter } = require('../lib/cloud-code/adapters/InProcessAdapter');
+const { ExternalProcessAdapter } = require('../lib/cloud-code/adapters/ExternalProcessAdapter');
+
+describe('resolveAdapters', () => {
+ it('should return an empty array when no relevant options are provided', () => {
+ const result = resolveAdapters({});
+ expect(result).toEqual([]);
+ });
+
+ it('should spread cloudCodeAdapters into the result', () => {
+ const adapter1 = { name: 'adapter1' };
+ const adapter2 = { name: 'adapter2' };
+ const result = resolveAdapters({ cloudCodeAdapters: [adapter1, adapter2] });
+ expect(result.length).toBe(2);
+ expect(result[0]).toBe(adapter1);
+ expect(result[1]).toBe(adapter2);
+ });
+
+ it('should create a LegacyAdapter when cloud is a string', () => {
+ const result = resolveAdapters({ cloud: './cloud/main.js' });
+ expect(result.length).toBe(1);
+ expect(result[0]).toBeInstanceOf(LegacyAdapter);
+ });
+
+ it('should create a LegacyAdapter when cloud is a function', () => {
+ const cloudFunction = () => {};
+ const result = resolveAdapters({ cloud: cloudFunction });
+ expect(result.length).toBe(1);
+ expect(result[0]).toBeInstanceOf(LegacyAdapter);
+ });
+
+ it('should create an InProcessAdapter when cloud is an object with getRouter', () => {
+ const cloudObject = { getRouter: () => {} };
+ const result = resolveAdapters({ cloud: cloudObject });
+ expect(result.length).toBe(1);
+ expect(result[0]).toBeInstanceOf(InProcessAdapter);
+ });
+
+ it('should throw when cloud is an invalid type (boolean)', () => {
+ expect(() => resolveAdapters({ cloud: true })).toThrowError(
+ "argument 'cloud' must either be a string or a function"
+ );
+ });
+
+ it('should throw when cloud is an invalid type (number)', () => {
+ expect(() => resolveAdapters({ cloud: 42 })).toThrowError(
+ "argument 'cloud' must either be a string or a function"
+ );
+ });
+
+ it('should throw when cloudCodeCommand is provided without webhookKey', () => {
+ expect(() => resolveAdapters({ cloudCodeCommand: 'node cloud.js' })).toThrowError(
+ 'webhookKey is required when using cloudCodeCommand'
+ );
+ });
+
+ it('should create an ExternalProcessAdapter when cloudCodeCommand and webhookKey are provided', () => {
+ const result = resolveAdapters({
+ cloudCodeCommand: 'node cloud.js',
+ webhookKey: 'secret-key',
+ });
+ expect(result.length).toBe(1);
+ expect(result[0]).toBeInstanceOf(ExternalProcessAdapter);
+ });
+
+ it('should combine multiple options into a single result array', () => {
+ const customAdapter = { name: 'custom' };
+ const result = resolveAdapters({
+ cloudCodeAdapters: [customAdapter],
+ cloud: './cloud/main.js',
+ cloudCodeCommand: 'node cloud.js',
+ webhookKey: 'secret-key',
+ });
+ expect(result.length).toBe(3);
+ expect(result[0]).toBe(customAdapter);
+ expect(result[1]).toBeInstanceOf(LegacyAdapter);
+ expect(result[2]).toBeInstanceOf(ExternalProcessAdapter);
+ });
+});
diff --git a/spec/webhook-bridge.spec.js b/spec/webhook-bridge.spec.js
new file mode 100644
index 0000000000..ef26639686
--- /dev/null
+++ b/spec/webhook-bridge.spec.js
@@ -0,0 +1,309 @@
+const {
+ requestToWebhookBody,
+ webhookResponseToResult,
+ applyBeforeSaveResponse,
+} = require('../lib/cloud-code/adapters/webhook-bridge');
+const Parse = require('parse/node').Parse;
+
+describe('webhook-bridge', () => {
+ // ── requestToWebhookBody ──────────────────────────────────────────────
+
+ describe('requestToWebhookBody', () => {
+ it('should return defaults for a minimal request', () => {
+ const body = requestToWebhookBody({});
+ expect(body).toEqual({
+ master: false,
+ ip: '',
+ headers: {},
+ installationId: undefined,
+ });
+ });
+
+ it('should pass through master, ip, headers, installationId', () => {
+ const body = requestToWebhookBody({
+ master: true,
+ ip: '127.0.0.1',
+ headers: { 'x-custom': 'value' },
+ installationId: 'abc-123',
+ });
+ expect(body.master).toBe(true);
+ expect(body.ip).toBe('127.0.0.1');
+ expect(body.headers).toEqual({ 'x-custom': 'value' });
+ expect(body.installationId).toBe('abc-123');
+ });
+
+ it('should serialise user via toJSON when available', () => {
+ const user = { toJSON: () => ({ objectId: 'u1', username: 'alice' }) };
+ const body = requestToWebhookBody({ user });
+ expect(body.user).toEqual({ objectId: 'u1', username: 'alice' });
+ });
+
+ it('should use plain user object when toJSON is absent', () => {
+ const user = { objectId: 'u2', username: 'bob' };
+ const body = requestToWebhookBody({ user });
+ expect(body.user).toEqual({ objectId: 'u2', username: 'bob' });
+ });
+
+ it('should not include user when it is undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'user')).toBe(false);
+ });
+
+ it('should include params when defined', () => {
+ const body = requestToWebhookBody({ params: { key: 'val' } });
+ expect(body.params).toEqual({ key: 'val' });
+ });
+
+ it('should not include params when undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'params')).toBe(false);
+ });
+
+ it('should include jobId when defined', () => {
+ const body = requestToWebhookBody({ jobId: 'job-42' });
+ expect(body.jobId).toBe('job-42');
+ });
+
+ it('should serialise object via toJSON when available', () => {
+ const obj = { toJSON: () => ({ className: 'Item', objectId: 'o1' }) };
+ const body = requestToWebhookBody({ object: obj });
+ expect(body.object).toEqual({ className: 'Item', objectId: 'o1' });
+ });
+
+ it('should use plain object when toJSON is absent', () => {
+ const obj = { className: 'Item', objectId: 'o1' };
+ const body = requestToWebhookBody({ object: obj });
+ expect(body.object).toEqual({ className: 'Item', objectId: 'o1' });
+ });
+
+ it('should not include object when it is falsy', () => {
+ const body = requestToWebhookBody({ object: null });
+ expect(Object.hasOwn(body, 'object')).toBe(false);
+ });
+
+ it('should serialise original via toJSON when available', () => {
+ const original = { toJSON: () => ({ className: 'Item', objectId: 'o0' }) };
+ const body = requestToWebhookBody({ original });
+ expect(body.original).toEqual({ className: 'Item', objectId: 'o0' });
+ });
+
+ it('should use plain original when toJSON is absent', () => {
+ const original = { className: 'Item', objectId: 'o0' };
+ const body = requestToWebhookBody({ original });
+ expect(body.original).toEqual({ className: 'Item', objectId: 'o0' });
+ });
+
+ it('should include context when defined', () => {
+ const body = requestToWebhookBody({ context: { source: 'test' } });
+ expect(body.context).toEqual({ source: 'test' });
+ });
+
+ it('should not include context when undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'context')).toBe(false);
+ });
+
+ it('should map query fields correctly', () => {
+ const query = {
+ className: 'Item',
+ _where: { score: { $gt: 10 } },
+ _limit: 25,
+ _skip: 5,
+ _include: ['author', 'comments'],
+ _keys: ['title', 'score'],
+ _order: 'score,-createdAt',
+ };
+ const body = requestToWebhookBody({ query });
+ expect(body.query).toEqual({
+ className: 'Item',
+ where: { score: { $gt: 10 } },
+ limit: 25,
+ skip: 5,
+ include: 'author,comments',
+ keys: 'title,score',
+ order: 'score,-createdAt',
+ });
+ });
+
+ it('should handle query with undefined _include and _keys', () => {
+ const query = {
+ className: 'Item',
+ _where: {},
+ _limit: 10,
+ _skip: 0,
+ };
+ const body = requestToWebhookBody({ query });
+ expect(body.query.include).toBeUndefined();
+ expect(body.query.keys).toBeUndefined();
+ });
+
+ it('should not include query when undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'query')).toBe(false);
+ });
+
+ it('should include count when defined', () => {
+ const body = requestToWebhookBody({ count: true });
+ expect(body.count).toBe(true);
+ });
+
+ it('should include isGet when defined', () => {
+ const body = requestToWebhookBody({ isGet: true });
+ expect(body.isGet).toBe(true);
+ });
+
+ it('should include file when defined', () => {
+ const file = { name: 'photo.png', data: 'base64...' };
+ const body = requestToWebhookBody({ file });
+ expect(body.file).toEqual(file);
+ });
+
+ it('should not include file when undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'file')).toBe(false);
+ });
+
+ it('should include fileSize when defined', () => {
+ const body = requestToWebhookBody({ fileSize: 1024 });
+ expect(body.fileSize).toBe(1024);
+ });
+
+ it('should include event when defined', () => {
+ const body = requestToWebhookBody({ event: 'create' });
+ expect(body.event).toBe('create');
+ });
+
+ it('should not include event when undefined', () => {
+ const body = requestToWebhookBody({});
+ expect(Object.hasOwn(body, 'event')).toBe(false);
+ });
+
+ it('should include requestId when defined', () => {
+ const body = requestToWebhookBody({ requestId: 'req-99' });
+ expect(body.requestId).toBe('req-99');
+ });
+
+ it('should include clients when defined', () => {
+ const body = requestToWebhookBody({ clients: 5 });
+ expect(body.clients).toBe(5);
+ });
+
+ it('should include subscriptions when defined', () => {
+ const body = requestToWebhookBody({ subscriptions: 12 });
+ expect(body.subscriptions).toBe(12);
+ });
+ });
+
+ // ── webhookResponseToResult ───────────────────────────────────────────
+
+ describe('webhookResponseToResult', () => {
+ it('should return success value when response is successful', () => {
+ const result = webhookResponseToResult({ success: { name: 'test' } });
+ expect(result).toEqual({ name: 'test' });
+ });
+
+ it('should return success value when it is a primitive', () => {
+ expect(webhookResponseToResult({ success: 42 })).toBe(42);
+ });
+
+ it('should return undefined when success is undefined', () => {
+ expect(webhookResponseToResult({ success: undefined })).toBeUndefined();
+ });
+
+ it('should throw Parse.Error when response contains error', () => {
+ const response = { error: { code: 141, message: 'Cloud function failed' } };
+ expect(() => webhookResponseToResult(response)).toThrowError(Parse.Error);
+ try {
+ webhookResponseToResult(response);
+ } catch (e) {
+ expect(e.code).toBe(141);
+ expect(e.message).toBe('Cloud function failed');
+ }
+ });
+ });
+
+ // ── applyBeforeSaveResponse ───────────────────────────────────────────
+
+ describe('applyBeforeSaveResponse', () => {
+ let request;
+
+ beforeEach(() => {
+ request = {
+ object: {
+ set: jasmine.createSpy('set'),
+ },
+ };
+ });
+
+ it('should throw Parse.Error when response contains error', () => {
+ const response = { error: { code: 101, message: 'Object not found' } };
+ expect(() => applyBeforeSaveResponse(request, response)).toThrowError(Parse.Error);
+ try {
+ applyBeforeSaveResponse(request, response);
+ } catch (e) {
+ expect(e.code).toBe(101);
+ expect(e.message).toBe('Object not found');
+ }
+ });
+
+ it('should be a no-op when success is an empty object', () => {
+ applyBeforeSaveResponse(request, { success: {} });
+ expect(request.object.set).not.toHaveBeenCalled();
+ });
+
+ it('should set fields from the success object on request.object', () => {
+ applyBeforeSaveResponse(request, { success: { title: 'Hello', score: 10 } });
+ expect(request.object.set).toHaveBeenCalledWith('title', 'Hello');
+ expect(request.object.set).toHaveBeenCalledWith('score', 10);
+ expect(request.object.set).toHaveBeenCalledTimes(2);
+ });
+
+ it('should skip objectId field', () => {
+ applyBeforeSaveResponse(request, { success: { objectId: 'skip-me', title: 'keep' } });
+ expect(request.object.set).not.toHaveBeenCalledWith('objectId', jasmine.anything());
+ expect(request.object.set).toHaveBeenCalledWith('title', 'keep');
+ });
+
+ it('should skip createdAt field', () => {
+ applyBeforeSaveResponse(request, { success: { createdAt: '2025-01-01', name: 'ok' } });
+ expect(request.object.set).not.toHaveBeenCalledWith('createdAt', jasmine.anything());
+ expect(request.object.set).toHaveBeenCalledWith('name', 'ok');
+ });
+
+ it('should skip updatedAt field', () => {
+ applyBeforeSaveResponse(request, { success: { updatedAt: '2025-01-02', name: 'ok' } });
+ expect(request.object.set).not.toHaveBeenCalledWith('updatedAt', jasmine.anything());
+ expect(request.object.set).toHaveBeenCalledWith('name', 'ok');
+ });
+
+ it('should skip className field', () => {
+ applyBeforeSaveResponse(request, { success: { className: 'Item', name: 'ok' } });
+ expect(request.object.set).not.toHaveBeenCalledWith('className', jasmine.anything());
+ expect(request.object.set).toHaveBeenCalledWith('name', 'ok');
+ });
+
+ it('should skip all skip fields at once', () => {
+ applyBeforeSaveResponse(request, {
+ success: {
+ objectId: 'x',
+ createdAt: 'a',
+ updatedAt: 'b',
+ className: 'C',
+ realField: 'yes',
+ },
+ });
+ expect(request.object.set).toHaveBeenCalledTimes(1);
+ expect(request.object.set).toHaveBeenCalledWith('realField', 'yes');
+ });
+
+ it('should not call set when success is null', () => {
+ applyBeforeSaveResponse(request, { success: null });
+ expect(request.object.set).not.toHaveBeenCalled();
+ });
+
+ it('should not call set when success is a primitive', () => {
+ applyBeforeSaveResponse(request, { success: 'string-value' });
+ expect(request.object.set).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts
index a83b8e1a62..ef019d50a3 100644
--- a/src/LiveQuery/ParseLiveQueryServer.ts
+++ b/src/LiveQuery/ParseLiveQueryServer.ts
@@ -232,7 +232,7 @@ class ParseLiveQueryServer {
installationId: client.installationId,
sendEvent: true,
};
- const trigger = getTrigger(className, 'afterEvent', Parse.applicationId);
+ const trigger = getTrigger(className, 'afterEvent', this.config.appId);
if (trigger) {
const auth = await this.getAuthFromClient(client, requestId);
if (auth && auth.user) {
@@ -241,7 +241,7 @@ class ParseLiveQueryServer {
if (res.object) {
res.object = Parse.Object.fromJSON(res.object);
}
- await runTrigger(trigger, `afterEvent.${className}`, res, auth);
+ await runTrigger(trigger, `afterEvent.${className}`, res, auth, this.config.appId);
}
if (!res.sendEvent) {
return;
@@ -388,7 +388,7 @@ class ParseLiveQueryServer {
installationId: client.installationId,
sendEvent: true,
};
- const trigger = getTrigger(className, 'afterEvent', Parse.applicationId);
+ const trigger = getTrigger(className, 'afterEvent', this.config.appId);
if (trigger) {
if (res.object) {
res.object = Parse.Object.fromJSON(res.object);
@@ -400,7 +400,7 @@ class ParseLiveQueryServer {
if (auth && auth.user) {
res.user = auth.user;
}
- await runTrigger(trigger, `afterEvent.${className}`, res, auth);
+ await runTrigger(trigger, `afterEvent.${className}`, res, auth, this.config.appId);
}
if (!res.sendEvent) {
return;
@@ -845,13 +845,13 @@ class ParseLiveQueryServer {
installationId: request.installationId,
user: undefined,
};
- const trigger = getTrigger('@Connect', 'beforeConnect', Parse.applicationId);
+ const trigger = getTrigger('@Connect', 'beforeConnect', this.config.appId);
if (trigger) {
const auth = await this.getAuthFromClient(client, request.requestId, req.sessionToken);
if (auth && auth.user) {
req.user = auth.user;
}
- await runTrigger(trigger, `beforeConnect.@Connect`, req, auth);
+ await runTrigger(trigger, `beforeConnect.@Connect`, req, auth, this.config.appId);
}
parseWebsocket.clientId = clientId;
this.clients.set(parseWebsocket.clientId, client);
@@ -908,7 +908,7 @@ class ParseLiveQueryServer {
const className = request.query.className;
let authCalled = false;
try {
- const trigger = getTrigger(className, 'beforeSubscribe', Parse.applicationId);
+ const trigger = getTrigger(className, 'beforeSubscribe', this.config.appId);
if (trigger) {
const auth = await this.getAuthFromClient(client, request.requestId, request.sessionToken);
authCalled = true;
@@ -919,7 +919,7 @@ class ParseLiveQueryServer {
const parseQuery = new Parse.Query(className);
parseQuery.withJSON(request.query);
request.query = parseQuery;
- await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth);
+ await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth, this.config.appId);
const query = request.query.toJSON();
request.query = query;
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index e9f8227317..d52f1b52dc 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -128,7 +128,21 @@ module.exports.ParseServerOptions = {
},
cloud: {
env: 'PARSE_SERVER_CLOUD',
- help: 'Full path to your cloud code main.js',
+ help: 'Full path to your cloud code main.js, a cloud code function, or an object implementing getRouter() for in-process cloud code',
+ },
+ cloudCodeAdapters: {
+ env: 'PARSE_SERVER_CLOUD_CODE_ADAPTERS',
+ help: 'Array of CloudCodeAdapter instances for BYO cloud code integration',
+ action: parsers.arrayParser,
+ },
+ cloudCodeCommand: {
+ env: 'PARSE_SERVER_CLOUD_CODE_COMMAND',
+ help: 'Shell command to spawn an external cloud code process (ParseCloud/1.0 protocol)',
+ },
+ cloudCodeOptions: {
+ env: 'PARSE_SERVER_CLOUD_CODE_OPTIONS',
+ help: 'Options for the external cloud code process adapter: startupTimeout, healthCheckInterval, shutdownTimeout, maxRestartDelay',
+ action: parsers.objectParser,
},
cluster: {
env: 'PARSE_SERVER_CLUSTER',
@@ -622,7 +636,7 @@ module.exports.ParseServerOptions = {
},
webhookKey: {
env: 'PARSE_SERVER_WEBHOOK_KEY',
- help: 'Key sent with outgoing webhook calls',
+ help: 'Key for authenticating external cloud code process requests. Required when cloudCodeCommand is set.',
},
};
module.exports.RateLimitOptions = {
diff --git a/src/Options/docs.js b/src/Options/docs.js
index 5845629022..7ad923309a 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -26,7 +26,10 @@
* @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000
* @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)
* @property {String} clientKey Key for iOS, MacOS, tvOS clients
- * @property {String} cloud Full path to your cloud code main.js
+ * @property {Union} cloud Full path to your cloud code main.js, a cloud code function, or an object implementing getRouter() for in-process cloud code
+ * @property {CloudCodeAdapter[]} cloudCodeAdapters Array of CloudCodeAdapter instances for BYO cloud code integration
+ * @property {String} cloudCodeCommand Shell command to spawn an external cloud code process (ParseCloud/1.0 protocol)
+ * @property {CloudCodeOptions} cloudCodeOptions Options for the external cloud code process adapter: startupTimeout, healthCheckInterval, shutdownTimeout, maxRestartDelay
* @property {Number|Boolean} cluster Run with cluster, optionally set the number of processes default to os.cpus().length
* @property {String} collectionPrefix A collection prefix for the classes
* @property {Boolean} convertEmailToLowercase Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`.
@@ -113,7 +116,7 @@
* @property {Boolean} verbose Set the logging to verbose
* @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.
⚠️ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Default is `true`.
* @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.
The `createdWith` values per scenario: