From 2b439c78f99d1379411433c7db43ad294b313ad3 Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:48:28 -0700 Subject: [PATCH 1/7] feat: add Function (FunctionRegistry) client Adds PreviewFunctionClient for managing Momento Functions (putFunction, deleteFunction, listFunctions, listFunctionVersions), mirroring PreviewLeaderboardClient (a preview, data-plane client on the cache endpoint). Requires @gomomento/generated-types 0.133.0, the first release that includes the FunctionRegistry service. nodejs only for now (no web parity yet). --- packages/client-sdk-nodejs/package-lock.json | 6 +- packages/client-sdk-nodejs/package.json | 3 +- .../src/config/function-configuration.ts | 160 +++++++ .../src/config/function-configurations.ts | 71 +++ .../src/function-client-props.ts | 13 + packages/client-sdk-nodejs/src/index.ts | 25 + .../src/internal/function-client-all-props.ts | 8 + .../src/internal/function-client.ts | 432 ++++++++++++++++++ .../src/preview-function-client.ts | 113 +++++ .../function/function-client.test.ts | 81 ++++ .../integration/function/test-function.wasm | Bin 0 -> 57755 bytes .../test/unit/function-client.test.ts | 85 ++++ packages/core/src/index.ts | 12 + .../clients/function/IFunctionClient.ts | 34 ++ .../src/internal/clients/function/index.ts | 1 + packages/core/src/internal/clients/index.ts | 1 + packages/core/src/messages/function-info.ts | 75 +++ .../responses/enums/function/index.ts | 19 + .../src/messages/responses/enums/index.ts | 1 + .../responses/function/delete-function.ts | 36 ++ .../src/messages/responses/function/index.ts | 4 + .../function/list-function-versions.ts | 55 +++ .../responses/function/list-functions.ts | 53 +++ .../responses/function/put-function.ts | 65 +++ 24 files changed, 1350 insertions(+), 3 deletions(-) create mode 100644 packages/client-sdk-nodejs/src/config/function-configuration.ts create mode 100644 packages/client-sdk-nodejs/src/config/function-configurations.ts create mode 100644 packages/client-sdk-nodejs/src/function-client-props.ts create mode 100644 packages/client-sdk-nodejs/src/internal/function-client-all-props.ts create mode 100644 packages/client-sdk-nodejs/src/internal/function-client.ts create mode 100644 packages/client-sdk-nodejs/src/preview-function-client.ts create mode 100644 packages/client-sdk-nodejs/test/integration/function/function-client.test.ts create mode 100644 packages/client-sdk-nodejs/test/integration/function/test-function.wasm create mode 100644 packages/client-sdk-nodejs/test/unit/function-client.test.ts create mode 100644 packages/core/src/internal/clients/function/IFunctionClient.ts create mode 100644 packages/core/src/internal/clients/function/index.ts create mode 100644 packages/core/src/messages/function-info.ts create mode 100644 packages/core/src/messages/responses/enums/function/index.ts create mode 100644 packages/core/src/messages/responses/function/delete-function.ts create mode 100644 packages/core/src/messages/responses/function/index.ts create mode 100644 packages/core/src/messages/responses/function/list-function-versions.ts create mode 100644 packages/core/src/messages/responses/function/list-functions.ts create mode 100644 packages/core/src/messages/responses/function/put-function.ts diff --git a/packages/client-sdk-nodejs/package-lock.json b/packages/client-sdk-nodejs/package-lock.json index b03161818..6c0eae0dc 100644 --- a/packages/client-sdk-nodejs/package-lock.json +++ b/packages/client-sdk-nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@gomomento/generated-types": "0.126.0", + "@gomomento/generated-types": "0.133.0", "@gomomento/sdk-core": "file:../core", "@grpc/grpc-js": "1.13.1", "@types/google-protobuf": "3.15.10", @@ -13141,7 +13141,9 @@ "link": true }, "node_modules/@gomomento/generated-types": { - "version": "0.126.0", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@gomomento/generated-types/-/generated-types-0.133.0.tgz", + "integrity": "sha512-kVTbNDgZ1z11aKZk/rrCH9HvC0ngqFfU5gx2kxCytV+IA7TRtnImzl80DdKiM2/m2SLG8P8qP82x7b7O7qFy3w==", "license": "Apache-2.0", "peerDependencies": { "@grpc/grpc-js": "1.13.1", diff --git a/packages/client-sdk-nodejs/package.json b/packages/client-sdk-nodejs/package.json index 299402833..1346d22f7 100644 --- a/packages/client-sdk-nodejs/package.json +++ b/packages/client-sdk-nodejs/package.json @@ -20,6 +20,7 @@ "integration-test-cache": "jest cache/ --maxWorkers 1 --testPathIgnorePatterns=\"abort-signal\" -- useConsistentReads", "integration-test-control-cache-topics": "npm run integration-test-cache && npm run integration-test-topics", "integration-test-leaderboard": "jest leaderboard/ --maxWorkers 1 -- useConsistentReads", + "integration-test-function": "jest function/ --maxWorkers 1", "integration-test-topics": "jest topics/ --maxWorkers 1 -- useConsistentReads", "integration-test-retry": "jest retry/ --maxWorkers 1 -- useConsistentReads", "integration-test-consistent-reads": "jest integration --testPathIgnorePatterns=\"retry/\" --maxWorkers 1 -- useConsistentReads", @@ -58,7 +59,7 @@ "uuid": "8.3.2" }, "dependencies": { - "@gomomento/generated-types": "0.126.0", + "@gomomento/generated-types": "0.133.0", "@gomomento/sdk-core": "file:../core", "@grpc/grpc-js": "1.13.1", "@types/google-protobuf": "3.15.10", diff --git a/packages/client-sdk-nodejs/src/config/function-configuration.ts b/packages/client-sdk-nodejs/src/config/function-configuration.ts new file mode 100644 index 000000000..a73044bfc --- /dev/null +++ b/packages/client-sdk-nodejs/src/config/function-configuration.ts @@ -0,0 +1,160 @@ +import {MomentoLoggerFactory, TransportStrategy} from '../'; +import {Middleware} from './middleware/middleware'; + +/** + * Configuration options for Momento FunctionClient + * + * @export + * @interface FunctionConfiguration + */ +export interface FunctionConfiguration { + /** + * @returns {MomentoLoggerFactory} the current configuration options for logging verbosity and format + */ + getLoggerFactory(): MomentoLoggerFactory; + + /** + * @returns {TransportStrategy} the current configuration options for wire interactions with the Momento service + */ + getTransportStrategy(): TransportStrategy; + + /** + * Convenience copy constructor that updates the client-side timeout setting in the TransportStrategy + * @param {number} clientTimeoutMillis + * @returns {FunctionConfiguration} a new Configuration object with its TransportStrategy updated to use the specified client timeout + */ + withClientTimeoutMillis(clientTimeoutMillis: number): FunctionConfiguration; + + /** + * Copy constructor for overriding TransportStrategy + * @param {TransportStrategy} transportStrategy + * @returns {Configuration} a new Configuration object with the specified TransportStrategy + */ + withTransportStrategy( + transportStrategy: TransportStrategy + ): FunctionConfiguration; + + /** + * @returns {boolean} Configures whether the client should return a Momento Error object or throw an exception when an + * error occurs. By default, this is set to false, and the client will return a Momento Error object on errors. Set it + * to true if you prefer for exceptions to be thrown. + */ + getThrowOnErrors(): boolean; + + /** + * Copy constructor for configuring whether the client should return a Momento Error object or throw an exception when an + * error occurs. By default, this is set to false, and the client will return a Momento Error object on errors. Set it + * to true if you prefer for exceptions to be thrown. + * @param {boolean} throwOnErrors + * @returns {Configuration} a new Configuration object with the specified throwOnErrors setting + */ + withThrowOnErrors(throwOnErrors: boolean): FunctionConfiguration; + + /** + * @returns {Middleware[]} the middleware functions that will wrap each request + */ + getMiddlewares(): Middleware[]; + + /** + * Copy constructor for overriding Middlewares + * @param {Middleware[]} middlewares + * @returns {Configuration} a new Configuration object with the specified Middlewares + */ + withMiddlewares(middlewares: Middleware[]): FunctionConfiguration; + + /** + * Copy constructor that adds a single middleware to the existing middlewares + * @param {Middleware} middleware + * @returns {Configuration} a new Configuration object with the specified Middleware appended to the list of existing Middlewares + */ + addMiddleware(middleware: Middleware): FunctionConfiguration; +} + +export interface FunctionConfigurationProps { + /** + * Configures logging verbosity and format + */ + loggerFactory: MomentoLoggerFactory; + /** + * Configures low-level options for network interactions with the Momento service + */ + transportStrategy: TransportStrategy; + + /** + * Configures whether the client should return a Momento Error object or throw an exception when an error occurs. + */ + throwOnErrors: boolean; + + /** + * Configures middleware functions that will wrap each request + */ + middlewares: Middleware[]; +} + +export class FunctionClientConfiguration implements FunctionConfiguration { + private readonly loggerFactory: MomentoLoggerFactory; + private readonly transportStrategy: TransportStrategy; + private readonly throwOnErrors: boolean; + private readonly middlewares: Middleware[]; + + constructor(props: FunctionConfigurationProps) { + this.loggerFactory = props.loggerFactory; + this.transportStrategy = props.transportStrategy; + this.throwOnErrors = props.throwOnErrors; + this.middlewares = props.middlewares; + } + + getLoggerFactory(): MomentoLoggerFactory { + return this.loggerFactory; + } + + getTransportStrategy(): TransportStrategy { + return this.transportStrategy; + } + + withClientTimeoutMillis(clientTimeoutMillis: number): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + transportStrategy: + this.transportStrategy.withClientTimeoutMillis(clientTimeoutMillis), + }); + } + + withTransportStrategy( + transportStrategy: TransportStrategy + ): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + transportStrategy, + }); + } + + getThrowOnErrors(): boolean { + return this.throwOnErrors; + } + + withThrowOnErrors(throwOnErrors: boolean): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + throwOnErrors, + }); + } + + getMiddlewares(): Middleware[] { + return this.middlewares; + } + + withMiddlewares(middlewares: Middleware[]): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + middlewares, + }); + } + + addMiddleware(middleware: Middleware): FunctionConfiguration { + return new FunctionClientConfiguration({ + ...this, + middlewares: [middleware, ...this.middlewares], + }); + } +} diff --git a/packages/client-sdk-nodejs/src/config/function-configurations.ts b/packages/client-sdk-nodejs/src/config/function-configurations.ts new file mode 100644 index 000000000..c0d860ef9 --- /dev/null +++ b/packages/client-sdk-nodejs/src/config/function-configurations.ts @@ -0,0 +1,71 @@ +import { + FunctionClientConfiguration, + FunctionConfiguration, +} from './function-configuration'; +import {MomentoLoggerFactory} from '@gomomento/sdk-core'; + +import { + GrpcConfiguration, + StaticGrpcConfiguration, + StaticTransportStrategy, + TransportStrategy, +} from '../'; +import {DefaultMomentoLoggerFactory} from './logging/default-momento-logger'; +import {Middleware} from './middleware/middleware'; + +// 4 minutes. We want to remain comfortably underneath the idle timeout for AWS NLB, which is 350s. +const defaultMaxIdleMillis = 4 * 60 * 1_000; +const defaultMaxSessionMemoryMb = 256; +const defaultLoggerFactory: MomentoLoggerFactory = + new DefaultMomentoLoggerFactory(); +const defaultMiddlewares: Middleware[] = []; + +/** + * Laptop config provides defaults suitable for a medium-to-high-latency dev environment. + * @export + * @class Laptop + */ +export class Laptop extends FunctionClientConfiguration { + /** + * Provides the latest recommended configuration for a laptop development environment. NOTE: this configuration may + * change in future releases to take advantage of improvements we identify for default configurations. + * @param {MomentoLoggerFactory} [loggerFactory=defaultLoggerFactory] + * @returns {FunctionConfiguration} + */ + static latest( + loggerFactory: MomentoLoggerFactory = defaultLoggerFactory + ): FunctionConfiguration { + return Laptop.v1(loggerFactory); + } + + /** + * Provides v1 recommended configuration for a laptop development environment. This configuration is guaranteed not + * to change in future releases of the Momento SDK. + * @param {MomentoLoggerFactory} [loggerFactory=defaultLoggerFactory] + * @returns {FunctionConfiguration} + */ + static v1( + loggerFactory: MomentoLoggerFactory = defaultLoggerFactory + ): FunctionConfiguration { + // Deploying a function uploads the wasm artifact inline, so the deadline is more generous than the + // cache-data defaults. + const deadlineMillis = 60000; + const grpcConfig: GrpcConfiguration = new StaticGrpcConfiguration({ + deadlineMillis: deadlineMillis, + maxSessionMemoryMb: defaultMaxSessionMemoryMb, + keepAlivePermitWithoutCalls: 1, + keepAliveTimeMs: 5000, + keepAliveTimeoutMs: 1000, + }); + const transportStrategy: TransportStrategy = new StaticTransportStrategy({ + grpcConfiguration: grpcConfig, + maxIdleMillis: defaultMaxIdleMillis, + }); + return new Laptop({ + loggerFactory: loggerFactory, + transportStrategy: transportStrategy, + throwOnErrors: false, + middlewares: defaultMiddlewares, + }); + } +} diff --git a/packages/client-sdk-nodejs/src/function-client-props.ts b/packages/client-sdk-nodejs/src/function-client-props.ts new file mode 100644 index 000000000..eb10192a5 --- /dev/null +++ b/packages/client-sdk-nodejs/src/function-client-props.ts @@ -0,0 +1,13 @@ +import {CredentialProvider} from '@gomomento/sdk-core'; +import {FunctionConfiguration} from './config/function-configuration'; + +export interface FunctionClientProps { + /** + * Configuration settings for the function client + */ + configuration?: FunctionConfiguration; + /** + * controls how the client will get authentication information for connecting to the Momento service + */ + credentialProvider?: CredentialProvider; +} diff --git a/packages/client-sdk-nodejs/src/index.ts b/packages/client-sdk-nodejs/src/index.ts index b1e025d8c..ae6814980 100644 --- a/packages/client-sdk-nodejs/src/index.ts +++ b/packages/client-sdk-nodejs/src/index.ts @@ -4,6 +4,7 @@ import * as Configurations from './config/configurations'; import * as AuthClientConfigurations from './config/auth-client-configurations'; import * as TopicConfigurations from './config/topic-configurations'; import * as LeaderboardConfigurations from './config/leaderboard-configurations'; +import * as FunctionConfigurations from './config/function-configurations'; import * as BatchUtils from './batchutils/batch-functions'; import {TopicClientProps} from './topic-client-props'; @@ -96,6 +97,10 @@ import * as GenerateDisposableToken from '@gomomento/sdk-core/dist/src/messages/ export {leaderboard} from '@gomomento/sdk-core'; export * from '@gomomento/sdk-core/dist/src/messages/responses/leaderboard'; +// FunctionClient Response Types +export {functions} from '@gomomento/sdk-core'; +export * from '@gomomento/sdk-core/dist/src/messages/responses/function'; + // Enums representing the different types available for each response export * from '@gomomento/sdk-core/dist/src/messages/responses/enums'; @@ -116,6 +121,10 @@ import { SetIfAbsentOrHashEqualOptions, SetIfAbsentOrHashNotEqualOptions, CacheInfo, + FunctionInfo, + FunctionVersionInfo, + IFunctionClient, + PutFunctionOptions, CollectionTtl, ItemType, SortedSetOrder, @@ -204,6 +213,12 @@ import { LeaderboardClientConfiguration, } from './config/leaderboard-configuration'; import {PreviewLeaderboardClient} from './preview-leaderboard-client'; +import { + FunctionConfiguration, + FunctionClientConfiguration, +} from './config/function-configuration'; +import {PreviewFunctionClient} from './preview-function-client'; +import {FunctionClientProps} from './function-client-props'; export { DefaultMomentoLoggerFactory, @@ -462,6 +477,16 @@ export { PreviewLeaderboardClient, LeaderboardOrder, ILeaderboard, + // FunctionClient + FunctionConfigurations, + FunctionConfiguration, + FunctionClientConfiguration, + PreviewFunctionClient, + FunctionClientProps, + IFunctionClient, + PutFunctionOptions, + FunctionInfo, + FunctionVersionInfo, // Errors MomentoErrorCode, SdkError, diff --git a/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts b/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts new file mode 100644 index 000000000..0e0b6e657 --- /dev/null +++ b/packages/client-sdk-nodejs/src/internal/function-client-all-props.ts @@ -0,0 +1,8 @@ +import {FunctionClientProps} from '../function-client-props'; +import {FunctionConfiguration} from '../config/function-configuration'; +import {CredentialProvider} from '@gomomento/sdk-core'; + +export interface FunctionClientAllProps extends FunctionClientProps { + configuration: FunctionConfiguration; + credentialProvider: CredentialProvider; +} diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts new file mode 100644 index 000000000..9dac7c539 --- /dev/null +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -0,0 +1,432 @@ +import { + CredentialProvider, + DeleteFunction, + FunctionInfo, + FunctionVersionInfo, + InvalidArgumentError, + ListFunctions, + ListFunctionVersions, + MomentoLogger, + MomentoLoggerFactory, + PutFunction, + UnknownError, +} from '@gomomento/sdk-core'; +import { + IFunctionClient, + PutFunctionOptions, +} from '@gomomento/sdk-core/dist/src/internal/clients/function/IFunctionClient'; +import {validateCacheName} from '@gomomento/sdk-core/dist/src/internal/utils'; +import {FunctionConfiguration} from '../config/function-configuration'; +import {function_client} from '@gomomento/generated-types/dist/function'; +import {function_types} from '@gomomento/generated-types/dist/function_types'; +import {IdleGrpcClientWrapper} from './grpc/idle-grpc-client-wrapper'; +import {GrpcClientWrapper} from './grpc/grpc-client-wrapper'; +import {Header, HeaderInterceptor} from './grpc/headers-interceptor'; +import {CacheServiceErrorMapper} from '../errors/cache-service-error-mapper'; +import { + ChannelCredentials, + ClientReadableStream, + Interceptor, + Metadata, + ServiceError, +} from '@grpc/grpc-js'; +import {version} from '../../package.json'; +import {FunctionClientAllProps} from './function-client-all-props'; +import {middlewaresInterceptor} from './grpc/middlewares-interceptor'; +import { + Middleware, + MiddlewareRequestHandlerContext, +} from '../config/middleware/middleware'; +import {grpcChannelOptionsFromGrpcConfig} from './grpc/grpc-channel-options'; +import {RetryInterceptor} from './grpc/retry-interceptor'; + +export const CONNECTION_ID_KEY = Symbol('connectionID'); + +// Wasm artifacts ship inline in the PutFunction request and can be large, so raise the gRPC message-size +// caps above the data-plane defaults (which are tuned for small cache items). +const MAX_MESSAGE_SIZE_BYTES = 32 * 1024 * 1024; + +export class FunctionClient implements IFunctionClient { + private readonly configuration: FunctionConfiguration; + private readonly credentialProvider: CredentialProvider; + private readonly logger: MomentoLogger; + private readonly cacheServiceErrorMapper: CacheServiceErrorMapper; + private readonly requestTimeoutMs: number; + private readonly clientWrapper: GrpcClientWrapper; + private readonly interceptors: Interceptor[]; + // Server-streaming calls (list*) use interceptors WITHOUT the retry interceptor — a stream cannot be + // naively retried. + private readonly streamingInterceptors: Interceptor[]; + + constructor(props: FunctionClientAllProps, functionClientId: string) { + this.configuration = props.configuration; + this.cacheServiceErrorMapper = new CacheServiceErrorMapper( + props.configuration.getThrowOnErrors() + ); + this.credentialProvider = props.credentialProvider; + this.logger = this.configuration.getLoggerFactory().getLogger(this); + const grpcConfig = this.configuration + .getTransportStrategy() + .getGrpcConfig(); + + this.requestTimeoutMs = grpcConfig.getDeadlineMillis(); + this.validateRequestTimeout(this.requestTimeoutMs); + this.logger.debug( + `Creating function client using endpoint: '${this.credentialProvider.getCacheEndpoint()}'` + ); + + const channelOptions = { + ...grpcChannelOptionsFromGrpcConfig(grpcConfig), + 'grpc.max_send_message_length': MAX_MESSAGE_SIZE_BYTES, + 'grpc.max_receive_message_length': MAX_MESSAGE_SIZE_BYTES, + }; + + this.clientWrapper = new IdleGrpcClientWrapper({ + clientFactoryFn: () => + new function_client.FunctionRegistryClient( + this.credentialProvider.getCacheEndpoint(), + this.credentialProvider.isEndpointSecure() + ? ChannelCredentials.createSsl() + : ChannelCredentials.createInsecure(), + channelOptions + ), + loggerFactory: this.configuration.getLoggerFactory(), + clientTimeoutMillis: this.requestTimeoutMs, + maxIdleMillis: this.configuration + .getTransportStrategy() + .getMaxIdleMillis(), + }); + + const context: MiddlewareRequestHandlerContext = {}; + context[CONNECTION_ID_KEY] = functionClientId; + this.interceptors = this.initializeInterceptors( + this.configuration.getLoggerFactory(), + this.configuration.getMiddlewares(), + context + ); + this.streamingInterceptors = this.initializeStreamingInterceptors( + this.configuration.getLoggerFactory(), + this.configuration.getMiddlewares(), + context + ); + } + + close() { + this.logger.debug('Closing function client'); + this.clientWrapper.getClient().close(); + } + + private validateRequestTimeout(timeout?: number) { + this.logger.debug(`Request timeout ms: ${String(timeout)}`); + if (timeout !== undefined && timeout <= 0) { + throw new InvalidArgumentError( + 'request timeout must be greater than zero.' + ); + } + } + + private buildHeaders(): Header[] { + return [ + new Header('Authorization', this.credentialProvider.getAuthToken()), + new Header('agent', `nodejs:function:${version}`), + new Header('runtime-version', `nodejs:${process.versions.node}`), + ]; + } + + private initializeInterceptors( + _loggerFactory: MomentoLoggerFactory, + middlewares: Middleware[], + middlewareRequestContext: MiddlewareRequestHandlerContext + ): Interceptor[] { + return [ + middlewaresInterceptor( + _loggerFactory, + middlewares, + middlewareRequestContext + ), + HeaderInterceptor.createHeadersInterceptor(this.buildHeaders()), + RetryInterceptor.createRetryInterceptor({ + clientName: 'FunctionClient', + loggerFactory: _loggerFactory, + overallRequestTimeoutMs: this.requestTimeoutMs, + }), + ]; + } + + private initializeStreamingInterceptors( + _loggerFactory: MomentoLoggerFactory, + middlewares: Middleware[], + middlewareRequestContext: MiddlewareRequestHandlerContext + ): Interceptor[] { + return [ + middlewaresInterceptor( + _loggerFactory, + middlewares, + middlewareRequestContext + ), + HeaderInterceptor.createHeadersInterceptor(this.buildHeaders()), + ]; + } + + private createMetadata(cacheName: string): Metadata { + const metadata = new Metadata(); + metadata.set('cache', cacheName); + return metadata; + } + + public async putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + try { + validateCacheName(cacheName); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new PutFunction.Error(err) + ); + } + this.logger.trace( + `Issuing 'putFunction' request; cache: ${cacheName}, name: ${functionName}, wasm bytes: ${wasmBytes.length}` + ); + return await this.sendPutFunction( + cacheName, + functionName, + wasmBytes, + options + ); + } + + private async sendPutFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + const environment = new Map(); + for (const [key, value] of Object.entries( + options?.environmentVariables ?? {} + )) { + environment.set( + key, + new function_types._EnvironmentValue({literal: value}) + ); + } + const request = new function_client._PutFunctionRequest({ + cache_name: cacheName, + name: functionName, + description: options?.description ?? '', + environment: environment, + inline: wasmBytes, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + this.clientWrapper + .getClient() + .PutFunction( + request, + metadata, + {interceptors: this.interceptors}, + (err: ServiceError | null, resp: unknown) => { + const fn = ( + resp as function_client._PutFunctionResponse | undefined + )?.function; + if (fn) { + resolve(new PutFunction.Success(fn.function_id, functionName)); + } else { + // resp present but no function is an anomalous response; convertError(null) yields a generic + // SdkError, so this never throws/hangs. + this.cacheServiceErrorMapper.resolveOrRejectError({ + err: err, + errorResponseFactoryFn: e => new PutFunction.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + } + } + ); + }); + } + + public async deleteFunction( + cacheName: string, + functionName: string + ): Promise { + try { + validateCacheName(cacheName); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new DeleteFunction.Error(err) + ); + } + this.logger.trace( + `Issuing 'deleteFunction' request; cache: ${cacheName}, name: ${functionName}` + ); + return await this.sendDeleteFunction(cacheName, functionName); + } + + private async sendDeleteFunction( + cacheName: string, + functionName: string + ): Promise { + const request = new function_client._DeleteFunctionRequest({ + cache_name: cacheName, + name: functionName, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + this.clientWrapper + .getClient() + .DeleteFunction( + request, + metadata, + {interceptors: this.interceptors}, + (err: ServiceError | null, resp: unknown) => { + if (resp) { + resolve(new DeleteFunction.Success()); + } else { + this.cacheServiceErrorMapper.resolveOrRejectError({ + err: err, + errorResponseFactoryFn: e => new DeleteFunction.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + } + } + ); + }); + } + + public async listFunctions( + cacheName: string + ): Promise { + try { + validateCacheName(cacheName); + } catch (err) { + return this.cacheServiceErrorMapper.returnOrThrowError( + err as Error, + err => new ListFunctions.Error(err) + ); + } + this.logger.trace(`Issuing 'listFunctions' request; cache: ${cacheName}`); + return await this.sendListFunctions(cacheName); + } + + private async sendListFunctions( + cacheName: string + ): Promise { + const request = new function_client._ListFunctionsRequest({ + cache_name: cacheName, + }); + const metadata = this.createMetadata(cacheName); + return await new Promise((resolve, reject) => { + const functions: FunctionInfo[] = []; + const call: ClientReadableStream = + this.clientWrapper.getClient().ListFunctions(request, metadata, { + interceptors: this.streamingInterceptors, + deadline: Date.now() + this.requestTimeoutMs, + }); + call.on('data', (resp: function_types._Function) => { + try { + functions.push( + new FunctionInfo( + resp.function_id, + resp.name, + resp.description, + resp.latest_version + ) + ); + } catch (e) { + call.cancel(); + resolve( + new ListFunctions.Error( + new UnknownError(e instanceof Error ? e.message : String(e)) + ) + ); + } + }); + call.on('error', (err: ServiceError) => { + this.cacheServiceErrorMapper.resolveOrRejectError({ + err: err, + errorResponseFactoryFn: e => new ListFunctions.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + }); + call.on('end', () => { + resolve(new ListFunctions.Success(functions)); + }); + }); + } + + public async listFunctionVersions( + functionId: string + ): Promise { + if (!functionId || functionId.trim().length === 0) { + return this.cacheServiceErrorMapper.returnOrThrowError( + new InvalidArgumentError('functionId must not be empty'), + err => new ListFunctionVersions.Error(err) + ); + } + this.logger.trace( + `Issuing 'listFunctionVersions' request; functionId: ${functionId}` + ); + return await this.sendListFunctionVersions(functionId); + } + + private async sendListFunctionVersions( + functionId: string + ): Promise { + const request = new function_client._ListFunctionVersionsRequest({ + function_id: functionId, + }); + // listFunctionVersions is keyed by function id, not by cache, so no cache metadata header is set. + const metadata = new Metadata(); + return await new Promise((resolve, reject) => { + const versions: FunctionVersionInfo[] = []; + const call: ClientReadableStream = + this.clientWrapper.getClient().ListFunctionVersions(request, metadata, { + interceptors: this.streamingInterceptors, + deadline: Date.now() + this.requestTimeoutMs, + }); + call.on('data', (resp: function_types._FunctionVersion) => { + try { + // id and wasm_id are optional proto submessages: their getters return undefined when absent, so + // guard the nested access (otherwise a sparse row would throw inside this listener — an uncaught + // exception that never settles the promise). + const id = resp.id; + const wasmId = resp.wasm_id; + versions.push( + new FunctionVersionInfo( + id?.id ?? '', + id?.version ?? 0, + resp.description, + wasmId?.id ?? '' + ) + ); + } catch (e) { + call.cancel(); + resolve( + new ListFunctionVersions.Error( + new UnknownError(e instanceof Error ? e.message : String(e)) + ) + ); + } + }); + call.on('error', (err: ServiceError) => { + this.cacheServiceErrorMapper.resolveOrRejectError({ + err: err, + errorResponseFactoryFn: e => new ListFunctionVersions.Error(e), + resolveFn: resolve, + rejectFn: reject, + }); + }); + call.on('end', () => { + resolve(new ListFunctionVersions.Success(versions)); + }); + }); + } +} diff --git a/packages/client-sdk-nodejs/src/preview-function-client.ts b/packages/client-sdk-nodejs/src/preview-function-client.ts new file mode 100644 index 000000000..e7e0f854a --- /dev/null +++ b/packages/client-sdk-nodejs/src/preview-function-client.ts @@ -0,0 +1,113 @@ +import { + DeleteFunction, + getDefaultCredentialProvider, + ListFunctions, + ListFunctionVersions, + MomentoLogger, + PutFunction, +} from '@gomomento/sdk-core'; +import { + IFunctionClient, + PutFunctionOptions, +} from '@gomomento/sdk-core/dist/src/internal/clients/function/IFunctionClient'; +import {FunctionClient} from './internal/function-client'; +import {FunctionClientProps} from './function-client-props'; +import {FunctionConfiguration, FunctionConfigurations} from './index'; +import {FunctionClientAllProps} from './internal/function-client-all-props'; + +/** + * PREVIEW Momento Function Client + * WARNING: the API for this client is not yet stable and may change without notice. + * Please contact Momento if you would like to try this preview. + * + * Deploys and removes Momento Functions (wasm) in a cache. Methods return a response object unique to each + * request, resolved to a type-safe success/error sub-type — see each response type for details. + */ +export class PreviewFunctionClient implements IFunctionClient { + protected readonly logger: MomentoLogger; + private readonly dataClient: IFunctionClient; + private readonly configuration: FunctionConfiguration; + + constructor(props?: FunctionClientProps) { + const configuration = + props?.configuration ?? getDefaultFunctionConfiguration(); + const allProps: FunctionClientAllProps = { + configuration: configuration, + credentialProvider: + props?.credentialProvider ?? getDefaultCredentialProvider(), + }; + this.configuration = configuration; + + this.logger = configuration.getLoggerFactory().getLogger(this); + this.logger.debug('Creating Momento FunctionClient'); + this.dataClient = new FunctionClient(allProps, '0'); + + this.configuration.getMiddlewares().forEach(m => { + if (m.init) { + m.init(); + } + }); + } + + public close() { + this.dataClient.close(); + this.configuration.getMiddlewares().forEach(m => { + if (m.close) { + m.close(); + } + }); + } + + /** + * Deploys a Momento Function in the given cache: creates it, or updates it if one already exists with the + * same name (each update creates a new version). + */ + public putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise { + return this.dataClient.putFunction( + cacheName, + functionName, + wasmBytes, + options + ); + } + + /** + * Deletes a Momento Function from the given cache. + */ + public deleteFunction( + cacheName: string, + functionName: string + ): Promise { + return this.dataClient.deleteFunction(cacheName, functionName); + } + + /** + * Lists the Momento Functions in the given cache. + */ + public listFunctions(cacheName: string): Promise { + return this.dataClient.listFunctions(cacheName); + } + + /** + * Lists the versions of a Momento Function, by function id. + */ + public listFunctionVersions( + functionId: string + ): Promise { + return this.dataClient.listFunctionVersions(functionId); + } +} + +function getDefaultFunctionConfiguration(): FunctionConfiguration { + const config = FunctionConfigurations.Laptop.latest(); + const logger = config.getLoggerFactory().getLogger('FunctionClient'); + logger.info( + 'No configuration provided to FunctionClient. Using default "Laptop" configuration, suitable for development. For production use, consider specifying an explicit configuration.' + ); + return config; +} diff --git a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts new file mode 100644 index 000000000..eb774bbd0 --- /dev/null +++ b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts @@ -0,0 +1,81 @@ +import { + CacheClient, + Configurations, + CreateCache, + CredentialProvider, + DeleteFunction, + ListFunctions, + ListFunctionVersions, + PreviewFunctionClient, + PutFunction, +} from '../../../src'; +import * as fs from 'fs'; +import * as path from 'path'; +import {v4} from 'uuid'; + +// Runs whenever MOMENTO_API_KEY is set (the normal integration sweep), using the same MOMENTO_API_KEY + +// MOMENTO_ENDPOINT env vars as the rest of the suite via CredentialProvider.fromEnvVarV2(). Functions are +// enabled in all cells, so no special gating is needed. +const describeIfLive = process.env.MOMENTO_API_KEY ? describe : describe.skip; + +describeIfLive('PreviewFunctionClient (integration)', () => { + const cacheName = `js-fn-it-${v4().slice(0, 8)}`; + const wasm = fs.readFileSync(path.join(__dirname, 'test-function.wasm')); + let cacheClient: CacheClient; + let functionClient: PreviewFunctionClient; + + beforeAll(async () => { + const credentialProvider = CredentialProvider.fromEnvVarV2(); + cacheClient = new CacheClient({ + configuration: Configurations.Laptop.latest(), + credentialProvider, + defaultTtlSeconds: 60, + }); + const create = await cacheClient.createCache(cacheName); + if (create instanceof CreateCache.Error) { + throw create.innerException(); + } + functionClient = new PreviewFunctionClient({credentialProvider}); + }); + + afterAll(async () => { + functionClient?.close(); + if (cacheClient) { + await cacheClient.deleteCache(cacheName); + } + }); + + it('puts, lists, lists versions of, and deletes a function', async () => { + const name = `it-fn-${v4().slice(0, 8)}`; + + const put = await functionClient.putFunction(cacheName, name, wasm, { + description: 'integration test function', + environmentVariables: {E2E_GREETING: 'hello'}, + }); + expect(put).toBeInstanceOf(PutFunction.Success); + const functionId = (put as PutFunction.Success).functionId(); + expect(functionId).toBeTruthy(); + + const list = await functionClient.listFunctions(cacheName); + expect(list).toBeInstanceOf(ListFunctions.Success); + const listedNames = (list as ListFunctions.Success) + .getFunctions() + .map(f => f.getName()); + expect(listedNames).toContain(name); + + const versions = await functionClient.listFunctionVersions(functionId); + expect(versions).toBeInstanceOf(ListFunctionVersions.Success); + expect( + (versions as ListFunctionVersions.Success).getVersions().length + ).toBeGreaterThanOrEqual(1); + + const del = await functionClient.deleteFunction(cacheName, name); + expect(del).toBeInstanceOf(DeleteFunction.Success); + + const after = await functionClient.listFunctions(cacheName); + expect(after).toBeInstanceOf(ListFunctions.Success); + expect( + (after as ListFunctions.Success).getFunctions().map(f => f.getName()) + ).not.toContain(name); + }, 30000); +}); diff --git a/packages/client-sdk-nodejs/test/integration/function/test-function.wasm b/packages/client-sdk-nodejs/test/integration/function/test-function.wasm new file mode 100644 index 0000000000000000000000000000000000000000..88aa9c3c7678dceefd323cd73059769adbb8ba3a GIT binary patch literal 57755 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 QJ^x){lmVPCcunO02OaHYYybcN literal 0 HcmV?d00001 diff --git a/packages/client-sdk-nodejs/test/unit/function-client.test.ts b/packages/client-sdk-nodejs/test/unit/function-client.test.ts new file mode 100644 index 000000000..9fb342db6 --- /dev/null +++ b/packages/client-sdk-nodejs/test/unit/function-client.test.ts @@ -0,0 +1,85 @@ +import { + DeleteFunction, + FunctionConfigurations, + InvalidArgumentError, + ListFunctions, + ListFunctionVersions, + PreviewFunctionClient, + PutFunction, + StringMomentoTokenProvider, +} from '../../src'; + +// Syntactically correct but not a real token; used only for unit testing constructors + the validation path. +const fakeAuthTokenForTesting = + 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzcXVpcnJlbCIsImNwIjoiY29udHJvbCBwbGFuZSBlbmRwb2ludCIsImMiOiJkYXRhIHBsYW5lIGVuZHBvaW50In0.zsTsEXFawetTCZI'; +const credentialProvider = new StringMomentoTokenProvider({ + authToken: fakeAuthTokenForTesting, +}); +const configuration = FunctionConfigurations.Laptop.latest(); + +describe('PreviewFunctionClient', () => { + it('can construct a PreviewFunctionClient', () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + client.close(); + }); + + it('returns an error on putFunction with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + ' ', + 'my-function', + new Uint8Array([0, 1, 2]), + {environmentVariables: {GREETING: 'hello'}} + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on deleteFunction with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.deleteFunction(' ', 'my-function'); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on listFunctions with an invalid cache name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.listFunctions(' '); + expect(response).toBeInstanceOf(ListFunctions.Error); + if (response instanceof ListFunctions.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on listFunctionVersions with an empty function id', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.listFunctionVersions(' '); + expect(response).toBeInstanceOf(ListFunctionVersions.Error); + if (response instanceof ListFunctionVersions.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15139b148..a51a3807d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,11 +88,16 @@ import * as GenerateDisposableToken from './messages/responses/generate-disposab export * as leaderboard from './messages/responses/leaderboard'; export * from './messages/responses/leaderboard'; +// Function Response Types +export * as functions from './messages/responses/function'; +export * from './messages/responses/function'; + export * as webhook from './messages/responses/webhook'; export * from './messages/responses/webhook'; export {Webhook, WebhookId} from './messages/webhook'; import {CacheInfo} from './messages/cache-info'; +import {FunctionInfo, FunctionVersionInfo} from './messages/function-info'; import { SubscribeCallOptions, CollectionTtl, @@ -184,6 +189,11 @@ export {IMomentoCache} from './clients/IMomentoCache'; export {ILeaderboardClient} from './clients/ILeaderboardClient'; export {ILeaderboard} from './clients/ILeaderboard'; +export { + IFunctionClient, + PutFunctionOptions, +} from './internal/clients/function/IFunctionClient'; + export { CacheRole, CachePermission, @@ -329,6 +339,8 @@ export { CacheIncreaseTtl, CacheDecreaseTtl, CacheInfo, + FunctionInfo, + FunctionVersionInfo, CacheSetBatch, CacheGetBatch, CacheSetWithHash, diff --git a/packages/core/src/internal/clients/function/IFunctionClient.ts b/packages/core/src/internal/clients/function/IFunctionClient.ts new file mode 100644 index 000000000..4e2b89453 --- /dev/null +++ b/packages/core/src/internal/clients/function/IFunctionClient.ts @@ -0,0 +1,34 @@ +import { + DeleteFunction, + ListFunctions, + ListFunctionVersions, + PutFunction, +} from '../../../messages/responses/function'; + +/** + * Options for a {@link IFunctionClient.putFunction} request. + */ +export interface PutFunctionOptions { + /** A human-readable description stored alongside the function. */ + description?: string; + /** Non-secret environment variables made available to the function at runtime. */ + environmentVariables?: Record; +} + +export interface IFunctionClient { + putFunction( + cacheName: string, + functionName: string, + wasmBytes: Uint8Array, + options?: PutFunctionOptions + ): Promise; + deleteFunction( + cacheName: string, + functionName: string + ): Promise; + listFunctions(cacheName: string): Promise; + listFunctionVersions( + functionId: string + ): Promise; + close(): void; +} diff --git a/packages/core/src/internal/clients/function/index.ts b/packages/core/src/internal/clients/function/index.ts new file mode 100644 index 000000000..adc60d0a6 --- /dev/null +++ b/packages/core/src/internal/clients/function/index.ts @@ -0,0 +1 @@ +export * from './IFunctionClient'; diff --git a/packages/core/src/internal/clients/index.ts b/packages/core/src/internal/clients/index.ts index 852117b86..e6b928f8a 100644 --- a/packages/core/src/internal/clients/index.ts +++ b/packages/core/src/internal/clients/index.ts @@ -2,3 +2,4 @@ export * from './cache'; export * from './auth'; export * from './pubsub'; export * from './leaderboard'; +export * from './function'; diff --git a/packages/core/src/messages/function-info.ts b/packages/core/src/messages/function-info.ts new file mode 100644 index 000000000..fc5c4eef0 --- /dev/null +++ b/packages/core/src/messages/function-info.ts @@ -0,0 +1,75 @@ +/** + * Metadata about a Momento Function, returned by `listFunctions`. + */ +export class FunctionInfo { + private readonly _functionId: string; + private readonly _name: string; + private readonly _description: string; + private readonly _latestVersion: number; + + constructor( + functionId: string, + name: string, + description: string, + latestVersion: number + ) { + this._functionId = functionId; + this._name = name; + this._description = description; + this._latestVersion = latestVersion; + } + + public getFunctionId(): string { + return this._functionId; + } + + public getName(): string { + return this._name; + } + + public getDescription(): string { + return this._description; + } + + public getLatestVersion(): number { + return this._latestVersion; + } +} + +/** + * Metadata about a single version of a Momento Function, returned by `listFunctionVersions`. + */ +export class FunctionVersionInfo { + private readonly _functionId: string; + private readonly _version: number; + private readonly _description: string; + private readonly _wasmId: string; + + constructor( + functionId: string, + version: number, + description: string, + wasmId: string + ) { + this._functionId = functionId; + this._version = version; + this._description = description; + this._wasmId = wasmId; + } + + public getFunctionId(): string { + return this._functionId; + } + + public getVersion(): number { + return this._version; + } + + public getDescription(): string { + return this._description; + } + + public getWasmId(): string { + return this._wasmId; + } +} diff --git a/packages/core/src/messages/responses/enums/function/index.ts b/packages/core/src/messages/responses/enums/function/index.ts new file mode 100644 index 000000000..5e5d7b234 --- /dev/null +++ b/packages/core/src/messages/responses/enums/function/index.ts @@ -0,0 +1,19 @@ +export enum PutFunctionResponse { + Success = 'Success', + Error = 'Error', +} + +export enum DeleteFunctionResponse { + Success = 'Success', + Error = 'Error', +} + +export enum ListFunctionsResponse { + Success = 'Success', + Error = 'Error', +} + +export enum ListFunctionVersionsResponse { + Success = 'Success', + Error = 'Error', +} diff --git a/packages/core/src/messages/responses/enums/index.ts b/packages/core/src/messages/responses/enums/index.ts index bdcd6688f..2ebed9a45 100644 --- a/packages/core/src/messages/responses/enums/index.ts +++ b/packages/core/src/messages/responses/enums/index.ts @@ -2,5 +2,6 @@ export * from './auth'; export * from './cache'; export * from './topics'; export * from './leaderboard'; +export * from './function'; export * from './store'; export * from './webhook'; diff --git a/packages/core/src/messages/responses/function/delete-function.ts b/packages/core/src/messages/responses/function/delete-function.ts new file mode 100644 index 000000000..5cc1510ab --- /dev/null +++ b/packages/core/src/messages/responses/function/delete-function.ts @@ -0,0 +1,36 @@ +import {SdkError} from '../../../errors'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {DeleteFunctionResponse} from '../enums'; + +interface IResponse { + readonly type: DeleteFunctionResponse; +} + +/** + * Indicates a successful delete-function request. Deleting a function that does not exist returns an Error + * with code NOT_FOUND (deletes are not idempotent). + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: DeleteFunctionResponse.Success = + DeleteFunctionResponse.Success; +} + +/** + * Indicates that an error occurred during the delete-function request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: DeleteFunctionResponse.Error = DeleteFunctionResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/index.ts b/packages/core/src/messages/responses/function/index.ts new file mode 100644 index 000000000..6436d9387 --- /dev/null +++ b/packages/core/src/messages/responses/function/index.ts @@ -0,0 +1,4 @@ +export * as PutFunction from './put-function'; +export * as DeleteFunction from './delete-function'; +export * as ListFunctions from './list-functions'; +export * as ListFunctionVersions from './list-function-versions'; diff --git a/packages/core/src/messages/responses/function/list-function-versions.ts b/packages/core/src/messages/responses/function/list-function-versions.ts new file mode 100644 index 000000000..cd70aefd8 --- /dev/null +++ b/packages/core/src/messages/responses/function/list-function-versions.ts @@ -0,0 +1,55 @@ +import {SdkError} from '../../../errors'; +import {FunctionVersionInfo} from '../../function-info'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {ListFunctionVersionsResponse} from '../enums'; + +interface IResponse { + readonly type: ListFunctionVersionsResponse; +} + +/** + * Indicates a successful list-function-versions request, carrying the versions of the function. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: ListFunctionVersionsResponse.Success = + ListFunctionVersionsResponse.Success; + private readonly _versions: FunctionVersionInfo[]; + + constructor(versions: FunctionVersionInfo[]) { + super(); + this._versions = versions; + } + + /** + * The versions of the function. + * @returns {FunctionVersionInfo[]} + */ + public getVersions(): FunctionVersionInfo[] { + return [...this._versions]; + } + + public override toString(): string { + return `${super.toString()}: ${this._versions.length} versions`; + } +} + +/** + * Indicates that an error occurred during the list-function-versions request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: ListFunctionVersionsResponse.Error = + ListFunctionVersionsResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/list-functions.ts b/packages/core/src/messages/responses/function/list-functions.ts new file mode 100644 index 000000000..235d6f1e8 --- /dev/null +++ b/packages/core/src/messages/responses/function/list-functions.ts @@ -0,0 +1,53 @@ +import {SdkError} from '../../../errors'; +import {FunctionInfo} from '../../function-info'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {ListFunctionsResponse} from '../enums'; + +interface IResponse { + readonly type: ListFunctionsResponse; +} + +/** + * Indicates a successful list-functions request, carrying the functions in the cache. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: ListFunctionsResponse.Success = ListFunctionsResponse.Success; + private readonly _functions: FunctionInfo[]; + + constructor(functions: FunctionInfo[]) { + super(); + this._functions = functions; + } + + /** + * The functions in the cache. + * @returns {FunctionInfo[]} + */ + public getFunctions(): FunctionInfo[] { + return [...this._functions]; + } + + public override toString(): string { + return `${super.toString()}: ${this._functions.length} functions`; + } +} + +/** + * Indicates that an error occurred during the list-functions request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: ListFunctionsResponse.Error = ListFunctionsResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; diff --git a/packages/core/src/messages/responses/function/put-function.ts b/packages/core/src/messages/responses/function/put-function.ts new file mode 100644 index 000000000..bc1d4b6e1 --- /dev/null +++ b/packages/core/src/messages/responses/function/put-function.ts @@ -0,0 +1,65 @@ +import {SdkError} from '../../../errors'; +import {BaseResponseError, BaseResponseSuccess} from '../response-base'; +import {PutFunctionResponse} from '../enums'; + +interface IResponse { + readonly type: PutFunctionResponse; +} + +/** + * Indicates a successful put-function request. The function is created, or updated if one already exists with + * the same cache + name (each update creates a new version); the returned id identifies the deployed function. + */ +export class Success extends BaseResponseSuccess implements IResponse { + readonly type: PutFunctionResponse.Success = PutFunctionResponse.Success; + private readonly _functionId: string; + private readonly _name: string; + + constructor(functionId: string, name: string) { + super(); + this._functionId = functionId; + this._name = name; + } + + /** + * The id of the deployed function. + * @returns {string} + */ + public functionId(): string { + return this._functionId; + } + + /** + * The name of the deployed function. + * @returns {string} + */ + public name(): string { + return this._name; + } + + public override toString(): string { + return `${super.toString()}: functionId: ${this._functionId}, name: ${ + this._name + }`; + } +} + +/** + * Indicates that an error occurred during the put-function request. + * + * This response object includes the following fields that you can use to determine + * how you would like to handle the error: + * + * - `errorCode()` - a unique Momento error code indicating the type of error that occurred. + * - `message()` - a human-readable description of the error + * - `innerException()` - the original error that caused the failure; can be re-thrown. + */ +export class Error extends BaseResponseError implements IResponse { + readonly type: PutFunctionResponse.Error = PutFunctionResponse.Error; + + constructor(_innerException: SdkError) { + super(_innerException); + } +} + +export type Response = Success | Error; From 40c547184c90e15dfdc8a6bb859c494e52ab7868 Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:00:16 -0700 Subject: [PATCH 2/7] fix: validate function name/id + wasm, fail on malformed version rows Addresses code review feedback: putFunction/deleteFunction now validate the function name (and putFunction rejects empty wasm) like other resource APIs; listFunctionVersions validates the function id; and a ListFunctionVersions row missing id/wasm_id is now surfaced as an error instead of a degraded result. Adds validateFunctionName/validateFunctionId to core. --- .../src/internal/function-client.ts | 40 ++++++++++++---- .../test/unit/function-client.test.ts | 47 +++++++++++++++++++ .../core/src/internal/utils/validators.ts | 12 +++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts index 9dac7c539..6fd6e4837 100644 --- a/packages/client-sdk-nodejs/src/internal/function-client.ts +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -15,7 +15,11 @@ import { IFunctionClient, PutFunctionOptions, } from '@gomomento/sdk-core/dist/src/internal/clients/function/IFunctionClient'; -import {validateCacheName} from '@gomomento/sdk-core/dist/src/internal/utils'; +import { + validateCacheName, + validateFunctionId, + validateFunctionName, +} from '@gomomento/sdk-core/dist/src/internal/utils'; import {FunctionConfiguration} from '../config/function-configuration'; import {function_client} from '@gomomento/generated-types/dist/function'; import {function_types} from '@gomomento/generated-types/dist/function_types'; @@ -182,6 +186,10 @@ export class FunctionClient implements IFunctionClient { ): Promise { try { validateCacheName(cacheName); + validateFunctionName(functionName); + if (wasmBytes.length === 0) { + throw new InvalidArgumentError('wasm bytes must not be empty'); + } } catch (err) { return this.cacheServiceErrorMapper.returnOrThrowError( err as Error, @@ -256,6 +264,7 @@ export class FunctionClient implements IFunctionClient { ): Promise { try { validateCacheName(cacheName); + validateFunctionName(functionName); } catch (err) { return this.cacheServiceErrorMapper.returnOrThrowError( err as Error, @@ -365,9 +374,11 @@ export class FunctionClient implements IFunctionClient { public async listFunctionVersions( functionId: string ): Promise { - if (!functionId || functionId.trim().length === 0) { + try { + validateFunctionId(functionId); + } catch (err) { return this.cacheServiceErrorMapper.returnOrThrowError( - new InvalidArgumentError('functionId must not be empty'), + err as Error, err => new ListFunctionVersions.Error(err) ); } @@ -394,17 +405,28 @@ export class FunctionClient implements IFunctionClient { }); call.on('data', (resp: function_types._FunctionVersion) => { try { - // id and wasm_id are optional proto submessages: their getters return undefined when absent, so - // guard the nested access (otherwise a sparse row would throw inside this listener — an uncaught - // exception that never settles the promise). + // id and wasm_id are proto submessages whose getters return undefined when absent. A row missing + // them is a malformed server/proto response, so fail the call rather than emit a degraded version + // (and never let the access throw uncaught inside this listener — that would hang the promise). const id = resp.id; const wasmId = resp.wasm_id; + if (id === undefined || wasmId === undefined) { + call.cancel(); + resolve( + new ListFunctionVersions.Error( + new UnknownError( + 'ListFunctionVersions returned a version row missing id or wasm_id' + ) + ) + ); + return; + } versions.push( new FunctionVersionInfo( - id?.id ?? '', - id?.version ?? 0, + id.id, + id.version, resp.description, - wasmId?.id ?? '' + wasmId.id ) ); } catch (e) { diff --git a/packages/client-sdk-nodejs/test/unit/function-client.test.ts b/packages/client-sdk-nodejs/test/unit/function-client.test.ts index 9fb342db6..62b131276 100644 --- a/packages/client-sdk-nodejs/test/unit/function-client.test.ts +++ b/packages/client-sdk-nodejs/test/unit/function-client.test.ts @@ -82,4 +82,51 @@ describe('PreviewFunctionClient', () => { } client.close(); }); + + it('returns an error on putFunction with an empty function name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + 'a-cache', + ' ', + new Uint8Array([0, 1, 2]) + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on putFunction with empty wasm bytes', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.putFunction( + 'a-cache', + 'my-function', + new Uint8Array([]) + ); + expect(response).toBeInstanceOf(PutFunction.Error); + if (response instanceof PutFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); + + it('returns an error on deleteFunction with an empty function name', async () => { + const client = new PreviewFunctionClient({ + configuration, + credentialProvider, + }); + const response = await client.deleteFunction('a-cache', ' '); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(InvalidArgumentError); + } + client.close(); + }); }); diff --git a/packages/core/src/internal/utils/validators.ts b/packages/core/src/internal/utils/validators.ts index 849e51054..60d7571c4 100644 --- a/packages/core/src/internal/utils/validators.ts +++ b/packages/core/src/internal/utils/validators.ts @@ -118,6 +118,18 @@ export function validateWebhookName(name: string) { } } +export function validateFunctionName(name: string) { + if (isEmpty(name)) { + throw new InvalidArgumentError('function name must not be empty'); + } +} + +export function validateFunctionId(functionId: string) { + if (isEmpty(functionId)) { + throw new InvalidArgumentError('function id must not be empty'); + } +} + export function validateIndexName(name: string) { if (isEmpty(name)) { throw new InvalidArgumentError('index name must not be empty'); From 3e407cb51bcc95f14799a30853ff4694533daf3c Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:12:45 -0700 Subject: [PATCH 3/7] fix: import public sdk-core types + correct addMiddleware doc Code review round 2: import IFunctionClient/PutFunctionOptions from the public @gomomento/sdk-core entrypoint instead of the internal dist path; fix the addMiddleware JSDoc (it prepends, not appends). --- .../client-sdk-nodejs/src/config/function-configuration.ts | 2 +- packages/client-sdk-nodejs/src/internal/function-client.ts | 6 ++---- packages/client-sdk-nodejs/src/preview-function-client.ts | 6 ++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/client-sdk-nodejs/src/config/function-configuration.ts b/packages/client-sdk-nodejs/src/config/function-configuration.ts index a73044bfc..ee4503ce0 100644 --- a/packages/client-sdk-nodejs/src/config/function-configuration.ts +++ b/packages/client-sdk-nodejs/src/config/function-configuration.ts @@ -65,7 +65,7 @@ export interface FunctionConfiguration { /** * Copy constructor that adds a single middleware to the existing middlewares * @param {Middleware} middleware - * @returns {Configuration} a new Configuration object with the specified Middleware appended to the list of existing Middlewares + * @returns {Configuration} a new Configuration object with the specified Middleware prepended to the list of existing Middlewares */ addMiddleware(middleware: Middleware): FunctionConfiguration; } diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts index 6fd6e4837..cf129f84b 100644 --- a/packages/client-sdk-nodejs/src/internal/function-client.ts +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -3,18 +3,16 @@ import { DeleteFunction, FunctionInfo, FunctionVersionInfo, + IFunctionClient, InvalidArgumentError, ListFunctions, ListFunctionVersions, MomentoLogger, MomentoLoggerFactory, PutFunction, + PutFunctionOptions, UnknownError, } from '@gomomento/sdk-core'; -import { - IFunctionClient, - PutFunctionOptions, -} from '@gomomento/sdk-core/dist/src/internal/clients/function/IFunctionClient'; import { validateCacheName, validateFunctionId, diff --git a/packages/client-sdk-nodejs/src/preview-function-client.ts b/packages/client-sdk-nodejs/src/preview-function-client.ts index e7e0f854a..0187d97fa 100644 --- a/packages/client-sdk-nodejs/src/preview-function-client.ts +++ b/packages/client-sdk-nodejs/src/preview-function-client.ts @@ -1,15 +1,13 @@ import { DeleteFunction, getDefaultCredentialProvider, + IFunctionClient, ListFunctions, ListFunctionVersions, MomentoLogger, PutFunction, -} from '@gomomento/sdk-core'; -import { - IFunctionClient, PutFunctionOptions, -} from '@gomomento/sdk-core/dist/src/internal/clients/function/IFunctionClient'; +} from '@gomomento/sdk-core'; import {FunctionClient} from './internal/function-client'; import {FunctionClientProps} from './function-client-props'; import {FunctionConfiguration, FunctionConfigurations} from './index'; From 4ba118d136585cf50fe4ce0b2449e9a27f82d887 Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:23:48 -0700 Subject: [PATCH 4/7] feat: surface current_version + last_updated_at; return full Function from putFunction FunctionInfo now exposes getCurrentVersion() (resolves the pinned/latest oneof, as the Rust SDK does) and getLastUpdatedAt(). PutFunction.Success now carries the full FunctionInfo the server already returns (getFunction()) instead of only id+name. --- .../src/internal/function-client.ts | 26 +++++++++++------- .../function/function-client.test.ts | 6 ++++- packages/core/src/messages/function-info.ts | 19 ++++++++++++- .../responses/function/put-function.ts | 27 +++++++++++-------- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts index cf129f84b..1cf9eeb34 100644 --- a/packages/client-sdk-nodejs/src/internal/function-client.ts +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -44,6 +44,21 @@ import {RetryInterceptor} from './grpc/retry-interceptor'; export const CONNECTION_ID_KEY = Symbol('connectionID'); +/** Map the generated `_Function` proto into the public `FunctionInfo`, resolving the current version. */ +function toFunctionInfo(fn: function_types._Function): FunctionInfo { + // current_version is a oneof: a pinned version resolves to its number, otherwise the latest version is active. + const currentVersion = + fn.current_version?.pinned?.pinned_version ?? fn.latest_version; + return new FunctionInfo( + fn.function_id, + fn.name, + fn.description, + fn.latest_version, + currentVersion, + fn.last_updated_at + ); +} + // Wasm artifacts ship inline in the PutFunction request and can be large, so raise the gRPC message-size // caps above the data-plane defaults (which are tuned for small cache items). const MAX_MESSAGE_SIZE_BYTES = 32 * 1024 * 1024; @@ -240,7 +255,7 @@ export class FunctionClient implements IFunctionClient { resp as function_client._PutFunctionResponse | undefined )?.function; if (fn) { - resolve(new PutFunction.Success(fn.function_id, functionName)); + resolve(new PutFunction.Success(toFunctionInfo(fn))); } else { // resp present but no function is an anomalous response; convertError(null) yields a generic // SdkError, so this never throws/hangs. @@ -338,14 +353,7 @@ export class FunctionClient implements IFunctionClient { }); call.on('data', (resp: function_types._Function) => { try { - functions.push( - new FunctionInfo( - resp.function_id, - resp.name, - resp.description, - resp.latest_version - ) - ); + functions.push(toFunctionInfo(resp)); } catch (e) { call.cancel(); resolve( diff --git a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts index eb774bbd0..3675c65ff 100644 --- a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts +++ b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts @@ -53,8 +53,12 @@ describeIfLive('PreviewFunctionClient (integration)', () => { environmentVariables: {E2E_GREETING: 'hello'}, }); expect(put).toBeInstanceOf(PutFunction.Success); - const functionId = (put as PutFunction.Success).functionId(); + const deployed = (put as PutFunction.Success).getFunction(); + const functionId = deployed.getFunctionId(); expect(functionId).toBeTruthy(); + expect(deployed.getName()).toEqual(name); + expect(deployed.getCurrentVersion()).toBeGreaterThanOrEqual(0); + expect(deployed.getLastUpdatedAt()).toBeTruthy(); const list = await functionClient.listFunctions(cacheName); expect(list).toBeInstanceOf(ListFunctions.Success); diff --git a/packages/core/src/messages/function-info.ts b/packages/core/src/messages/function-info.ts index fc5c4eef0..ad0432ce8 100644 --- a/packages/core/src/messages/function-info.ts +++ b/packages/core/src/messages/function-info.ts @@ -6,17 +6,23 @@ export class FunctionInfo { private readonly _name: string; private readonly _description: string; private readonly _latestVersion: number; + private readonly _currentVersion: number; + private readonly _lastUpdatedAt: string; constructor( functionId: string, name: string, description: string, - latestVersion: number + latestVersion: number, + currentVersion: number, + lastUpdatedAt: string ) { this._functionId = functionId; this._name = name; this._description = description; this._latestVersion = latestVersion; + this._currentVersion = currentVersion; + this._lastUpdatedAt = lastUpdatedAt; } public getFunctionId(): string { @@ -31,9 +37,20 @@ export class FunctionInfo { return this._description; } + /** The latest version of the function. */ public getLatestVersion(): number { return this._latestVersion; } + + /** The version currently serving traffic (the pinned version, or the latest if not pinned). */ + public getCurrentVersion(): number { + return this._currentVersion; + } + + /** When the function was last updated (ISO 8601). */ + public getLastUpdatedAt(): string { + return this._lastUpdatedAt; + } } /** diff --git a/packages/core/src/messages/responses/function/put-function.ts b/packages/core/src/messages/responses/function/put-function.ts index bc1d4b6e1..27971f779 100644 --- a/packages/core/src/messages/responses/function/put-function.ts +++ b/packages/core/src/messages/responses/function/put-function.ts @@ -1,4 +1,5 @@ import {SdkError} from '../../../errors'; +import {FunctionInfo} from '../../function-info'; import {BaseResponseError, BaseResponseSuccess} from '../response-base'; import {PutFunctionResponse} from '../enums'; @@ -8,17 +9,23 @@ interface IResponse { /** * Indicates a successful put-function request. The function is created, or updated if one already exists with - * the same cache + name (each update creates a new version); the returned id identifies the deployed function. + * the same cache + name (each update creates a new version); the returned function carries its full metadata. */ export class Success extends BaseResponseSuccess implements IResponse { readonly type: PutFunctionResponse.Success = PutFunctionResponse.Success; - private readonly _functionId: string; - private readonly _name: string; + private readonly _function: FunctionInfo; - constructor(functionId: string, name: string) { + constructor(deployedFunction: FunctionInfo) { super(); - this._functionId = functionId; - this._name = name; + this._function = deployedFunction; + } + + /** + * The deployed function's metadata. + * @returns {FunctionInfo} + */ + public getFunction(): FunctionInfo { + return this._function; } /** @@ -26,7 +33,7 @@ export class Success extends BaseResponseSuccess implements IResponse { * @returns {string} */ public functionId(): string { - return this._functionId; + return this._function.getFunctionId(); } /** @@ -34,13 +41,11 @@ export class Success extends BaseResponseSuccess implements IResponse { * @returns {string} */ public name(): string { - return this._name; + return this._function.getName(); } public override toString(): string { - return `${super.toString()}: functionId: ${this._functionId}, name: ${ - this._name - }`; + return `${super.toString()}: functionId: ${this.functionId()}, name: ${this.name()}`; } } From dc178064f188ba0a12569f07b70a008cc67099cf Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:23:48 -0700 Subject: [PATCH 5/7] fix: move IFunctionClient to public clients dir + correct config JSDoc Code review round 3: public client interfaces belong under core/src/clients (like ILeaderboardClient), so move IFunctionClient + PutFunctionOptions out of internal/clients/function. Correct the function-configuration JSDoc @returns to FunctionConfiguration. --- .../src/config/function-configuration.ts | 8 ++++---- .../clients/function => clients}/IFunctionClient.ts | 2 +- packages/core/src/index.ts | 5 +---- packages/core/src/internal/clients/function/index.ts | 1 - packages/core/src/internal/clients/index.ts | 1 - 5 files changed, 6 insertions(+), 11 deletions(-) rename packages/core/src/{internal/clients/function => clients}/IFunctionClient.ts (95%) delete mode 100644 packages/core/src/internal/clients/function/index.ts diff --git a/packages/client-sdk-nodejs/src/config/function-configuration.ts b/packages/client-sdk-nodejs/src/config/function-configuration.ts index ee4503ce0..fa685f4a7 100644 --- a/packages/client-sdk-nodejs/src/config/function-configuration.ts +++ b/packages/client-sdk-nodejs/src/config/function-configuration.ts @@ -28,7 +28,7 @@ export interface FunctionConfiguration { /** * Copy constructor for overriding TransportStrategy * @param {TransportStrategy} transportStrategy - * @returns {Configuration} a new Configuration object with the specified TransportStrategy + * @returns {FunctionConfiguration} a new Configuration object with the specified TransportStrategy */ withTransportStrategy( transportStrategy: TransportStrategy @@ -46,7 +46,7 @@ export interface FunctionConfiguration { * error occurs. By default, this is set to false, and the client will return a Momento Error object on errors. Set it * to true if you prefer for exceptions to be thrown. * @param {boolean} throwOnErrors - * @returns {Configuration} a new Configuration object with the specified throwOnErrors setting + * @returns {FunctionConfiguration} a new Configuration object with the specified throwOnErrors setting */ withThrowOnErrors(throwOnErrors: boolean): FunctionConfiguration; @@ -58,14 +58,14 @@ export interface FunctionConfiguration { /** * Copy constructor for overriding Middlewares * @param {Middleware[]} middlewares - * @returns {Configuration} a new Configuration object with the specified Middlewares + * @returns {FunctionConfiguration} a new Configuration object with the specified Middlewares */ withMiddlewares(middlewares: Middleware[]): FunctionConfiguration; /** * Copy constructor that adds a single middleware to the existing middlewares * @param {Middleware} middleware - * @returns {Configuration} a new Configuration object with the specified Middleware prepended to the list of existing Middlewares + * @returns {FunctionConfiguration} a new Configuration object with the specified Middleware prepended to the list of existing Middlewares */ addMiddleware(middleware: Middleware): FunctionConfiguration; } diff --git a/packages/core/src/internal/clients/function/IFunctionClient.ts b/packages/core/src/clients/IFunctionClient.ts similarity index 95% rename from packages/core/src/internal/clients/function/IFunctionClient.ts rename to packages/core/src/clients/IFunctionClient.ts index 4e2b89453..275f5b37d 100644 --- a/packages/core/src/internal/clients/function/IFunctionClient.ts +++ b/packages/core/src/clients/IFunctionClient.ts @@ -3,7 +3,7 @@ import { ListFunctions, ListFunctionVersions, PutFunction, -} from '../../../messages/responses/function'; +} from '../messages/responses/function'; /** * Options for a {@link IFunctionClient.putFunction} request. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a51a3807d..3adc6ac8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,10 +189,7 @@ export {IMomentoCache} from './clients/IMomentoCache'; export {ILeaderboardClient} from './clients/ILeaderboardClient'; export {ILeaderboard} from './clients/ILeaderboard'; -export { - IFunctionClient, - PutFunctionOptions, -} from './internal/clients/function/IFunctionClient'; +export {IFunctionClient, PutFunctionOptions} from './clients/IFunctionClient'; export { CacheRole, diff --git a/packages/core/src/internal/clients/function/index.ts b/packages/core/src/internal/clients/function/index.ts deleted file mode 100644 index adc60d0a6..000000000 --- a/packages/core/src/internal/clients/function/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './IFunctionClient'; diff --git a/packages/core/src/internal/clients/index.ts b/packages/core/src/internal/clients/index.ts index e6b928f8a..852117b86 100644 --- a/packages/core/src/internal/clients/index.ts +++ b/packages/core/src/internal/clients/index.ts @@ -2,4 +2,3 @@ export * from './cache'; export * from './auth'; export * from './pubsub'; export * from './leaderboard'; -export * from './function'; From 5af56e57545c153baeadc65c8ee67d2fb4143502 Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:41:33 -0700 Subject: [PATCH 6/7] feat: surface FunctionNotFoundError for missing functions Code review round 4: the shared CacheServiceErrorMapper renders a NOT_FOUND as a cache-flavored CacheNotFoundError. Add FunctionNotFoundError (+ a FUNCTION_NOT_FOUND_ERROR code) and remap NOT_FOUND in the function client so a missing function surfaces the correct error. Verified live. --- packages/client-sdk-nodejs/src/index.ts | 2 + .../src/internal/function-client.ts | 42 ++++++++++++++++--- .../function/function-client.test.ts | 16 +++++++ packages/core/src/errors/errors.ts | 12 ++++++ packages/core/src/index.ts | 2 + 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/packages/client-sdk-nodejs/src/index.ts b/packages/client-sdk-nodejs/src/index.ts index ae6814980..62c46028c 100644 --- a/packages/client-sdk-nodejs/src/index.ts +++ b/packages/client-sdk-nodejs/src/index.ts @@ -147,6 +147,7 @@ import { BadRequestError, PermissionError, CacheNotFoundError, + FunctionNotFoundError, UnknownError, MomentoLogger, MomentoLoggerFactory, @@ -503,6 +504,7 @@ export { BadRequestError, PermissionError, CacheNotFoundError, + FunctionNotFoundError, UnknownError, // Logging MomentoLogger, diff --git a/packages/client-sdk-nodejs/src/internal/function-client.ts b/packages/client-sdk-nodejs/src/internal/function-client.ts index 1cf9eeb34..4728c6483 100644 --- a/packages/client-sdk-nodejs/src/internal/function-client.ts +++ b/packages/client-sdk-nodejs/src/internal/function-client.ts @@ -2,6 +2,7 @@ import { CredentialProvider, DeleteFunction, FunctionInfo, + FunctionNotFoundError, FunctionVersionInfo, IFunctionClient, InvalidArgumentError, @@ -11,6 +12,7 @@ import { MomentoLoggerFactory, PutFunction, PutFunctionOptions, + SdkError, UnknownError, } from '@gomomento/sdk-core'; import { @@ -31,6 +33,7 @@ import { Interceptor, Metadata, ServiceError, + status, } from '@grpc/grpc-js'; import {version} from '../../package.json'; import {FunctionClientAllProps} from './function-client-all-props'; @@ -68,6 +71,7 @@ export class FunctionClient implements IFunctionClient { private readonly credentialProvider: CredentialProvider; private readonly logger: MomentoLogger; private readonly cacheServiceErrorMapper: CacheServiceErrorMapper; + private readonly throwOnErrors: boolean; private readonly requestTimeoutMs: number; private readonly clientWrapper: GrpcClientWrapper; private readonly interceptors: Interceptor[]; @@ -77,8 +81,9 @@ export class FunctionClient implements IFunctionClient { constructor(props: FunctionClientAllProps, functionClientId: string) { this.configuration = props.configuration; + this.throwOnErrors = props.configuration.getThrowOnErrors(); this.cacheServiceErrorMapper = new CacheServiceErrorMapper( - props.configuration.getThrowOnErrors() + this.throwOnErrors ); this.credentialProvider = props.credentialProvider; this.logger = this.configuration.getLoggerFactory().getLogger(this); @@ -191,6 +196,33 @@ export class FunctionClient implements IFunctionClient { return metadata; } + /** + * Like the shared cache error mapper, but surfaces a gRPC NOT_FOUND as a {@link FunctionNotFoundError} — + * the shared mapper would otherwise render a missing function as a cache-flavored CacheNotFoundError. + */ + private resolveOrRejectFunctionError(opts: { + err: ServiceError | null; + errorResponseFactoryFn: (e: SdkError) => T; + resolveFn: (response: T) => void; + rejectFn: (err: SdkError) => void; + }): void { + if (opts.err?.code === status.NOT_FOUND) { + const notFound = new FunctionNotFoundError( + opts.err.message, + opts.err.code, + opts.err.metadata, + opts.err.stack + ); + if (this.throwOnErrors) { + opts.rejectFn(notFound); + } else { + opts.resolveFn(opts.errorResponseFactoryFn(notFound)); + } + return; + } + this.cacheServiceErrorMapper.resolveOrRejectError(opts); + } + public async putFunction( cacheName: string, functionName: string, @@ -259,7 +291,7 @@ export class FunctionClient implements IFunctionClient { } else { // resp present but no function is an anomalous response; convertError(null) yields a generic // SdkError, so this never throws/hangs. - this.cacheServiceErrorMapper.resolveOrRejectError({ + this.resolveOrRejectFunctionError({ err: err, errorResponseFactoryFn: e => new PutFunction.Error(e), resolveFn: resolve, @@ -310,7 +342,7 @@ export class FunctionClient implements IFunctionClient { if (resp) { resolve(new DeleteFunction.Success()); } else { - this.cacheServiceErrorMapper.resolveOrRejectError({ + this.resolveOrRejectFunctionError({ err: err, errorResponseFactoryFn: e => new DeleteFunction.Error(e), resolveFn: resolve, @@ -364,7 +396,7 @@ export class FunctionClient implements IFunctionClient { } }); call.on('error', (err: ServiceError) => { - this.cacheServiceErrorMapper.resolveOrRejectError({ + this.resolveOrRejectFunctionError({ err: err, errorResponseFactoryFn: e => new ListFunctions.Error(e), resolveFn: resolve, @@ -445,7 +477,7 @@ export class FunctionClient implements IFunctionClient { } }); call.on('error', (err: ServiceError) => { - this.cacheServiceErrorMapper.resolveOrRejectError({ + this.resolveOrRejectFunctionError({ err: err, errorResponseFactoryFn: e => new ListFunctionVersions.Error(e), resolveFn: resolve, diff --git a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts index 3675c65ff..2c1aa1e71 100644 --- a/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts +++ b/packages/client-sdk-nodejs/test/integration/function/function-client.test.ts @@ -4,8 +4,10 @@ import { CreateCache, CredentialProvider, DeleteFunction, + FunctionNotFoundError, ListFunctions, ListFunctionVersions, + MomentoErrorCode, PreviewFunctionClient, PutFunction, } from '../../../src'; @@ -82,4 +84,18 @@ describeIfLive('PreviewFunctionClient (integration)', () => { (after as ListFunctions.Success).getFunctions().map(f => f.getName()) ).not.toContain(name); }, 30000); + + it('returns a FunctionNotFoundError when deleting a missing function', async () => { + const response = await functionClient.deleteFunction( + cacheName, + `missing-${v4().slice(0, 8)}` + ); + expect(response).toBeInstanceOf(DeleteFunction.Error); + if (response instanceof DeleteFunction.Error) { + expect(response.innerException()).toBeInstanceOf(FunctionNotFoundError); + expect(response.errorCode()).toEqual( + MomentoErrorCode.FUNCTION_NOT_FOUND_ERROR + ); + } + }, 30000); }); diff --git a/packages/core/src/errors/errors.ts b/packages/core/src/errors/errors.ts index 20e1fe635..2195fbbf3 100644 --- a/packages/core/src/errors/errors.ts +++ b/packages/core/src/errors/errors.ts @@ -17,6 +17,8 @@ export enum MomentoErrorCode { STORE_NOT_FOUND_ERROR = 'STORE_NOT_FOUND_ERROR', // Item with specified key doesn't exist STORE_ITEM_NOT_FOUND_ERROR = 'STORE_ITEM_NOT_FOUND_ERROR', + // Function with specified name doesn't exist + FUNCTION_NOT_FOUND_ERROR = 'FUNCTION_NOT_FOUND_ERROR', // An unexpected error occurred while trying to fulfill the request INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', // Insufficient permissions to perform operation @@ -278,6 +280,16 @@ export class StoreItemNotFoundError extends SdkError { override _messageWrapper = 'An item with the specified key does not exist'; } +/** + * Error that occurs when trying to operate on a function that doesn't exist. To resolve, make sure that the + * function you are trying to use exists; if it doesn't, create it first and then try again. + */ +export class FunctionNotFoundError extends SdkError { + override _errorCode = MomentoErrorCode.FUNCTION_NOT_FOUND_ERROR; + override _messageWrapper = + 'A function with the specified name does not exist. To resolve this error, make sure you have created the function before attempting to use it'; +} + /** * Insufficient permissions to perform an operation on Cache Service */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3adc6ac8b..f71424143 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,6 +144,7 @@ import { CacheNotFoundError, StoreItemNotFoundError, StoreNotFoundError, + FunctionNotFoundError, UnknownError, } from './errors'; @@ -384,5 +385,6 @@ export { CacheNotFoundError, StoreItemNotFoundError, StoreNotFoundError, + FunctionNotFoundError, UnknownError, }; From 0aca283c2f542195c84b58bbea54d9898723e1d1 Mon Sep 17 00:00:00 2001 From: eaddingtonwhite <5491827+ellery44@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:48:18 -0700 Subject: [PATCH 7/7] docs: FunctionInfo is returned by putFunction too Code review round 5: FunctionInfo is now returned by putFunction (PutFunction.Success) as well as listFunctions; update the class docstring. --- packages/core/src/messages/function-info.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/messages/function-info.ts b/packages/core/src/messages/function-info.ts index ad0432ce8..fc654da66 100644 --- a/packages/core/src/messages/function-info.ts +++ b/packages/core/src/messages/function-info.ts @@ -1,5 +1,5 @@ /** - * Metadata about a Momento Function, returned by `listFunctions`. + * Metadata about a Momento Function, returned by `putFunction` and `listFunctions`. */ export class FunctionInfo { private readonly _functionId: string;