From 0101a32f9d4242658043f263ad37f35f1241cf17 Mon Sep 17 00:00:00 2001 From: "a.cerutti" Date: Thu, 30 Oct 2025 21:33:23 +0100 Subject: [PATCH 1/5] Added implementation of Scheduled functions inside V2 --- src/v2.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/src/v2.ts b/src/v2.ts index e3fef18..951f421 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -25,7 +25,15 @@ import { CallableFunction, CallableRequest } from 'firebase-functions/v2/https'; import { generateCombinedCloudEvent } from './cloudevent/generate'; import { DeepPartial } from './cloudevent/types'; -import * as express from 'express'; +import { + ScheduledEvent, + ScheduleFunction, +} from 'firebase-functions/v2/scheduler'; + +type V2WrappableFunctions = + | CloudFunction + | CallableFunction + | ScheduleFunction; /** A function that can be called with test data and optional override values for {@link CloudEvent} * It will subsequently invoke the cloud function it wraps with the provided {@link CloudEvent} @@ -38,14 +46,24 @@ export type WrappedV2CallableFunction = ( data: CallableRequest ) => T | Promise; -function isCallableV2Function>( - cf: CloudFunction | CallableFunction +export type WrappedV2ScheduledFunction = ( + data: ScheduledEvent +) => void | Promise; + +function isCallableV2Function( + cf: V2WrappableFunctions ): cf is CallableFunction { - return !!cf?.__endpoint?.callableTrigger; + return !!cf.__endpoint?.callableTrigger; +} + +function isScheduledV2Function( + cf: V2WrappableFunctions +): cf is ScheduleFunction { + return !!cf.__endpoint?.scheduleTrigger; } function assertIsCloudFunction>( - cf: CloudFunction | CallableFunction + cf: V2WrappableFunctions ): asserts cf is CloudFunction { if (!('run' in cf) || !cf.run) { throw new Error( @@ -54,6 +72,14 @@ function assertIsCloudFunction>( } } +function assertIsCloudFunctionV2>( + cf: CloudFunction | CallableFunction +): asserts cf is CloudFunction { + if (cf?.__endpoint?.platform !== 'gcfv2') { + throw new Error('This function can only wrap V2 CloudFunctions.'); + } +} + /** * Takes a v2 cloud function to be tested, and returns a {@link WrappedV2Function} * which can be called in test code. @@ -70,19 +96,34 @@ export function wrapV2( cloudFunction: CallableFunction ): WrappedV2CallableFunction; +export function wrapV2( + cloudFunction: ScheduleFunction +): WrappedV2ScheduledFunction; + export function wrapV2>( - cloudFunction: CloudFunction | CallableFunction -): WrappedV2Function | WrappedV2CallableFunction { + cloudFunction: + | CloudFunction + | CallableFunction + | ScheduleFunction +): + | WrappedV2Function + | WrappedV2CallableFunction + | WrappedV2ScheduledFunction { + if (!cloudFunction) { + throw new Error('Cannot wrap: undefined cloud function'); + } + + assertIsCloudFunction(cloudFunction); + assertIsCloudFunctionV2(cloudFunction); + if (isCallableV2Function(cloudFunction)) { return (req: CallableRequest) => { return cloudFunction.run(req); }; } - assertIsCloudFunction(cloudFunction); - - if (cloudFunction?.__endpoint?.platform !== 'gcfv2') { - throw new Error('This function can only wrap V2 CloudFunctions.'); + if (isScheduledV2Function(cloudFunction)) { + return createScheduledWrapper(cloudFunction); } return (cloudEventPartial?: DeepPartial) => { @@ -93,3 +134,25 @@ export function wrapV2>( return cloudFunction.run(cloudEvent); }; } + +function createScheduledWrapper( + cloudFunction: ScheduleFunction +): WrappedV2ScheduledFunction { + return function(options: ScheduledEvent) { + _checkOptionValidity(['jobName', 'scheduleTime'], options); + return cloudFunction.run(options); + }; +} + +function _checkOptionValidity( + validFields: string[], + options: Record +) { + Object.keys(options).forEach((key) => { + if (validFields.indexOf(key) === -1) { + throw new Error( + `Options object ${JSON.stringify(options)} has invalid key "${key}"` + ); + } + }); +} \ No newline at end of file From 22066dee486c522fb9e742907a38fd9075306453 Mon Sep 17 00:00:00 2001 From: "a.cerutti" Date: Thu, 30 Oct 2025 21:33:55 +0100 Subject: [PATCH 2/5] Changed import orders and added type --- src/v2.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/v2.ts b/src/v2.ts index 951f421..9feff30 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -20,16 +20,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { CloudFunction, CloudEvent } from 'firebase-functions/v2'; -import { CallableFunction, CallableRequest } from 'firebase-functions/v2/https'; - -import { generateCombinedCloudEvent } from './cloudevent/generate'; -import { DeepPartial } from './cloudevent/types'; -import { +import type { CloudFunction, CloudEvent } from 'firebase-functions/v2'; +import type { CallableFunction, CallableRequest } from 'firebase-functions/v2/https'; +import type { ScheduledEvent, ScheduleFunction, } from 'firebase-functions/v2/scheduler'; +import { generateCombinedCloudEvent } from './cloudevent/generate'; +import { DeepPartial } from './cloudevent/types'; + type V2WrappableFunctions = | CloudFunction | CallableFunction From b1c07fa647d84b209182047b54b344431f40b216 Mon Sep 17 00:00:00 2001 From: "a.cerutti" Date: Thu, 30 Oct 2025 22:01:00 +0100 Subject: [PATCH 3/5] Improved check inside isV2CloudFunction to allow check on ScheduleFunction --- src/main.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index 13a23bb..d4c009a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -80,9 +80,14 @@ export function wrap>( | WrappedFunction | WrappedV2Function | WrappedV2CallableFunction { + if (!cloudFunction) { + throw new Error('Cannot wrap: undefined cloud function'); + } + if (isV2CloudFunction(cloudFunction)) { - return wrapV2(cloudFunction as CloudFunctionV2); + return wrapV2(cloudFunction); } + return wrapV1( cloudFunction as HttpsFunctionOrCloudFunctionV1 ); @@ -93,12 +98,23 @@ export function wrap>( *
    *
  • V1 CloudFunction is sometimes a binary function *
  • V2 CloudFunction is always a unary function + *
  • V2 ScheduleFunction is a binary function (HttpsFunctionV2) + * *
  • V1 CloudFunction.run is always a binary function *
  • V2 CloudFunction.run is always a unary function + *
  • V2 ScheduleFunction.run is always a unary function + *
+ * * @return True iff the CloudFunction is a V2 function. */ function isV2CloudFunction>( - cloudFunction: any + cloudFunction: CloudFunctionV1 | CloudFunctionV2 | HttpsFunctionV2 ): cloudFunction is CloudFunctionV2 { - return cloudFunction.length === 1 && cloudFunction?.run?.length === 1; + if (!cloudFunction) { + return false; + } + + type CloudFunctionType = CloudFunctionV1 | CloudFunctionV2; + + return (cloudFunction as CloudFunctionType).run.length === 1; } From 6e4c2f4b1cffeb2919ea506a915e3b1a8e741171 Mon Sep 17 00:00:00 2001 From: "a.cerutti" Date: Fri, 31 Oct 2025 10:37:33 +0100 Subject: [PATCH 4/5] lint --- src/v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v2.ts b/src/v2.ts index 9feff30..5880347 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -138,7 +138,7 @@ export function wrapV2>( function createScheduledWrapper( cloudFunction: ScheduleFunction ): WrappedV2ScheduledFunction { - return function(options: ScheduledEvent) { + return (options: ScheduledEvent) => { _checkOptionValidity(['jobName', 'scheduleTime'], options); return cloudFunction.run(options); }; From cac21b22c17d785acd0995d530fbbfdc999b8f91 Mon Sep 17 00:00:00 2001 From: "a.cerutti" Date: Fri, 31 Oct 2025 10:53:32 +0100 Subject: [PATCH 5/5] Added tests for scheduled v2 --- spec/v2.spec.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/spec/v2.spec.ts b/spec/v2.spec.ts index 1101aa7..d9f13da 100644 --- a/spec/v2.spec.ts +++ b/spec/v2.spec.ts @@ -23,6 +23,7 @@ import { expect } from 'chai'; import { wrapV2 } from '../src/v2'; +import type { WrappedV2ScheduledFunction } from '../src/v2'; import { CloudFunction, @@ -35,6 +36,7 @@ import { eventarc, https, firestore, + scheduler, } from 'firebase-functions/v2'; import { defineString } from 'firebase-functions/params'; import { makeDataSnapshot } from '../src/providers/database'; @@ -1422,6 +1424,41 @@ describe('v2', () => { }); }); + describe('scheduler', () => { + describe('onSchedule()', () => { + it('should return correct data', async () => { + const scheduledFunction = scheduler.onSchedule( + 'every 5 minutes', + (_e) => {} + ); + + scheduledFunction.__endpoint = { + platform: 'gcfv2', + labels: {}, + scheduleTrigger: { + schedule: 'every 5 minutes', + }, + concurrency: 20, + minInstances: 3, + region: ['us-west1', 'us-central1'], + }; + + let wrappedFunction: WrappedV2ScheduledFunction; + + expect( + () => (wrappedFunction = wrapV2(scheduledFunction)) + ).not.to.throw(); + + const result = await wrappedFunction({ + scheduleTime: '2024-01-01T00:00:00Z', + jobName: 'test-job', + }); + + expect(result).to.be.undefined; + }); + }); + }); + describe('callable functions', () => { it('should return correct data', async () => { const cloudFunction = https.onCall(() => 'hello');