From 29f2484c748cd9186898149af42e55009eb4d434 Mon Sep 17 00:00:00 2001 From: igalshilman Date: Tue, 5 Mar 2024 11:54:00 +0100 Subject: [PATCH] Introduces virtual objects/service handler abstraction --- README.md | 8 +- buf.lock | 6 - buf.yaml | 4 +- examples/embedded_example.ts | 38 - examples/example.ts | 32 +- examples/grpc_example.ts | 54 - examples/handler_example.ts | 52 - package.json | 5 +- proto/discovery.proto | 48 - proto/dynrpc.proto | 49 - proto/protocol.proto | 20 + proto/services.proto | 174 ---- proto/test.proto | 36 - src/connection/embedded_connection.ts | 62 -- src/context.ts | 159 +-- src/context_impl.ts | 98 +- src/embedded/api.ts | 57 -- src/embedded/handler.ts | 36 - src/embedded/http2_remote.ts | 103 -- src/embedded/invocation.ts | 126 --- src/endpoint.ts | 55 +- src/endpoint/endpoint_impl.ts | 586 +---------- src/endpoint/http2_handler.ts | 88 +- src/endpoint/lambda_handler.ts | 113 +-- src/invocation.ts | 32 +- src/journal.ts | 4 +- src/public_api.ts | 33 +- src/state_machine.ts | 13 +- src/types/components.ts | 201 ++++ src/types/discovery.ts | 41 + src/types/grpc.ts | 97 -- src/types/router.ts | 118 --- src/types/rpc.ts | 92 ++ src/types/types.ts | 16 - src/utils/assumptions.ts | 131 --- src/utils/serde.ts | 20 + src/utils/utils.ts | 24 - src/workflows/workflow.ts | 12 +- src/workflows/workflow_state_service.ts | 48 +- src/workflows/workflow_wrapper_service.ts | 97 +- test/awakeable.test.ts | 37 +- test/complete_awakeable.test.ts | 31 +- test/eager_state.test.ts | 115 ++- test/get_and_set_state.test.ts | 49 +- test/get_state.test.ts | 61 +- test/lambda.test.ts | 136 +-- test/promise_combinators.test.ts | 19 +- test/protoutils.ts | 38 +- test/send_request.test.ts | 842 ---------------- test/service_bind.test.ts | 12 +- test/side_effect.test.ts | 1086 --------------------- test/sleep.test.ts | 26 +- test/state_keys.test.ts | 33 +- test/state_machine.test.ts | 12 +- test/testdriver.ts | 77 +- test/utils.test.ts | 73 -- 56 files changed, 1081 insertions(+), 4554 deletions(-) delete mode 100644 examples/embedded_example.ts delete mode 100644 examples/grpc_example.ts delete mode 100644 examples/handler_example.ts delete mode 100644 proto/discovery.proto delete mode 100644 proto/dynrpc.proto delete mode 100644 proto/services.proto delete mode 100644 proto/test.proto delete mode 100644 src/connection/embedded_connection.ts delete mode 100644 src/embedded/api.ts delete mode 100644 src/embedded/handler.ts delete mode 100644 src/embedded/http2_remote.ts delete mode 100644 src/embedded/invocation.ts create mode 100644 src/types/components.ts create mode 100644 src/types/discovery.ts delete mode 100644 src/types/grpc.ts delete mode 100644 src/types/router.ts create mode 100644 src/types/rpc.ts delete mode 100644 src/utils/assumptions.ts create mode 100644 src/utils/serde.ts delete mode 100644 test/send_request.test.ts delete mode 100644 test/side_effect.test.ts diff --git a/README.md b/README.md index 9556ff74..c7e54f49 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ as part of long-running processes, or as FaaS (AWS Lambda). ```typescript // note that there is no failure handling in this example, because the combination of durable execution, // communication, and state storage makes this unnecessary here. -const addToCart = async (ctx: restate.RpcContext, cartId: string /* the key */, ticketId: string) => { +const addToCart = async (ctx: restateObjectContext, ticketId: string) => { // RPC participates in durable execution, so guaranteed to eventually happen and // will never get duplicated. would suspend if the other takes too long - const success = await ctx.rpc({ path: "tickets" }).reserve(ticketId); + const success = await ctx.service(ticketApi).reserve(ticketId); if (success) { const cart = (await ctx.get("cart")) || []; // gets state 'cart' bound to current cartId @@ -24,7 +24,7 @@ const addToCart = async (ctx: restate.RpcContext, cartId: string /* the key */, ctx.set("cart", cart); // writes state bound to current cartId // reliable delayed call sent from Restate, which also participaes in durable execution - ctx.sendDelayed({path: "cart"}, minutes(15)).expireTicket(ticketId); + ctx.objectSendDelayed(cartApi, minutes(15)).expireTicket(ticketId); } return success; } @@ -33,7 +33,7 @@ const addToCart = async (ctx: restate.RpcContext, cartId: string /* the key */, restate .createServer() - .bindKeyedRouter("cart", restate.keyedRouter({ addToCart, expireTicket })) + .object("cart", restate.object({ addToCart, expireTicket })) .listen(9080); ``` diff --git a/buf.lock b/buf.lock index 84e00285..c91b5810 100644 --- a/buf.lock +++ b/buf.lock @@ -1,8 +1,2 @@ # Generated by buf. DO NOT EDIT. version: v1 -deps: - - remote: buf.build - owner: restatedev - repository: proto - commit: 6ea2d15aed8f408590a1465844df5a8e - digest: shake256:e6599809ff13490a631f87d1a4b13ef1886d1bd1c0aa001ccb92806c0acc373d047a6ead761f8a21dfbd57a4fd9acd5915a52e47bd5b4e4a02dd1766f78511b3 diff --git a/buf.yaml b/buf.yaml index 8e7714ae..9d1844be 100644 --- a/buf.yaml +++ b/buf.yaml @@ -1,6 +1,6 @@ version: v1 -deps: - - buf.build/restatedev/proto +#deps: +# - buf.build/restatedev/proto build: excludes: - node_modules diff --git a/examples/embedded_example.ts b/examples/embedded_example.ts deleted file mode 100644 index 3f3038bc..00000000 --- a/examples/embedded_example.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -/* eslint-disable no-console */ - -import express, { Request, Response } from "express"; -import * as restate from "../src/public_api"; - -const rs = restate.connection("http://127.0.0.1:8080"); - -const app = express(); -app.use(express.json()); - -app.post("/workflow", async (req: Request, res: Response) => { - const { id } = req.body; - - const response = await rs.invoke(id, {}, async (ctx) => { - // You can use all RestateContext features here (except sleep) - - const response = await ctx.sideEffect(async () => { - return (await fetch("https://dummyjson.com/products/1")).json(); - }); - - return response.title; - }); - - res.send(response); -}); - -app.listen(3000); diff --git a/examples/example.ts b/examples/example.ts index 1002de7d..6d775097 100644 --- a/examples/example.ts +++ b/examples/example.ts @@ -9,30 +9,18 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ -/* - * A simple example program using the Restate dynamic RPC-based API. - * - * This example primarily exists to make it simple to test the code against - * a running Restate instance. - */ - import * as restate from "../src/public_api"; -// -// The main entry point for the service, receiving the greeting request and name. -// -const greeter = restate.router({ +const greeter = restate.service({ greet: async (ctx: restate.Context, name: string) => { // blocking RPC call to a keyed service (here supplying type and path separately) - const countSoFar = await ctx - .rpc({ path: "counter" }) - .count(name); + const countSoFar = await ctx.object(counterApi, name).count(); const message = `Hello ${name}, for the ${countSoFar + 1}th time!`; // sending messages to ourselves, immediately and delayed - ctx.send(greeterApi).logger(message); - ctx.sendDelayed(greeterApi, 100).logger("delayed " + message); + ctx.serviceSend(greeterApi).logger(message); + ctx.serviceSendDelayed(greeterApi, 100).logger("delayed " + message); return message; }, @@ -47,21 +35,21 @@ const greeter = restate.router({ // This could in principle be the same service as the greet service, we just separate // them here to have this multi-service setup for testing. // -const counter = restate.keyedRouter({ - count: async (ctx: restate.KeyedContext): Promise => { +const counter = restate.object({ + count: async (ctx: restate.ObjectContext): Promise => { const seen = (await ctx.get("seen")) ?? 0; ctx.set("seen", seen + 1); return seen; }, }); -const greeterApi: restate.ServiceApi = { path: "greeter" }; -type counterApiType = typeof counter; +const greeterApi = restate.serviceApi("greeter", greeter); +const counterApi = restate.objectApi("counter", counter); // restate server restate .endpoint() - .bindRouter("greeter", greeter) - .bindKeyedRouter("counter", counter) + .service(greeterApi.path, greeter) + .object(counterApi.path, counter) .listen(9080); diff --git a/examples/grpc_example.ts b/examples/grpc_example.ts deleted file mode 100644 index 47049ae5..00000000 --- a/examples/grpc_example.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -/* - * A simple example program using the Restate gRPC-based API. - * - * This example primarily exists to make it simple to test the code against - * a running Restate instance. - */ - -import * as restate from "../src/public_api"; -import { - TestRequest, - TestResponse, - TestGreeter, - protoMetadata, -} from "../src/generated/proto/test"; - -/** - * Example of a service implemented with the Restate Typescript SDK - * This is a long-running service with two gRPC methods: Greet and MultiWord - */ -export class GreeterService implements TestGreeter { - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - - // state - let seen = (await ctx.get("seen")) ?? 0; - seen += 1; - ctx.set("seen", seen); - - // return the final response - return TestResponse.create({ - greeting: `Hello ${request.name} no.${seen}!`, - }); - } -} - -restate - .endpoint() - .bindService({ - descriptor: protoMetadata, - service: "TestGreeter", - instance: new GreeterService(), - }) - .listen(9080); diff --git a/examples/handler_example.ts b/examples/handler_example.ts deleted file mode 100644 index dc13f95e..00000000 --- a/examples/handler_example.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -/* - * A simple example program showing how to let services listen to events produced - * by systems like Kafka. - */ - -import * as restate from "../src/public_api"; - -type UserProfile = { - id: string; - name: string; - email: string; -}; - -const profileService = restate.keyedRouter({ - registration: restate.keyedEventHandler( - async (ctx: restate.KeyedContext, event: restate.Event) => { - // store in state the user's information as coming from the registeration event - const { name } = event.json<{ name: string }>(); - ctx.set("name", name); - } - ), - - email: restate.keyedEventHandler( - async (ctx: restate.KeyedContext, event: restate.Event) => { - // store in state the user's information as coming from the email event - const { email } = event.json<{ email: string }>(); - ctx.set("email", email); - } - ), - - get: async (ctx: restate.KeyedContext, id: string): Promise => { - return { - id, - name: (await ctx.get("name")) ?? "", - email: (await ctx.get("email")) ?? "", - }; - }, -}); - -// restate server -restate.endpoint().bindKeyedRouter("profile", profileService).listen(9080); diff --git a/package.json b/package.json index c7b3e46f..18af998e 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,7 @@ "verify": "npm run format-check && npm run lint && npm run test && npm run build", "release": "release-it", "example": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/example.ts", - "grpcexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/grpc_example.ts", - "workflowexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/workflow_example.ts", - "handlerexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/handler_example.ts", - "expressexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/embedded_example.ts" + "workflowexample": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only ./examples/workflow_example.ts" }, "dependencies": { "protobufjs": "^7.2.2", diff --git a/proto/discovery.proto b/proto/discovery.proto deleted file mode 100644 index 9bd04314..00000000 --- a/proto/discovery.proto +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -syntax = "proto3"; - -package dev.restate.service.discovery; - -import "google/protobuf/descriptor.proto"; - -option java_package = "dev.restate.generated.service.discovery"; -option go_package = "restate.dev/sdk-go/pb/service/discovery"; - - -// --- Service discovery endpoint --- -// Request: POST /discover with application/proto containing ServiceDiscoveryRequest -// Response: application/proto containing ServiceDiscoveryResponse - -message ServiceDiscoveryRequest { -} - -enum ProtocolMode { - // protolint:disable:next ENUM_FIELD_NAMES_ZERO_VALUE_END_WITH - BIDI_STREAM = 0; - REQUEST_RESPONSE = 1; -} - -message ServiceDiscoveryResponse { - // List of all proto files used to define the services, including the dependencies. - google.protobuf.FileDescriptorSet files = 1; - - // List of services to register. This might be a subset of services defined in files. - repeated string services = 2; - - // Service-protocol version negotiation - uint32 min_protocol_version = 3; - uint32 max_protocol_version = 4; - - // Protocol mode negotiation - ProtocolMode protocol_mode = 5; -} diff --git a/proto/dynrpc.proto b/proto/dynrpc.proto deleted file mode 100644 index c6101067..00000000 --- a/proto/dynrpc.proto +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -syntax = "proto3"; - -import "dev/restate/ext.proto"; -import "dev/restate/events.proto"; -import "google/protobuf/struct.proto"; -import "google/protobuf/empty.proto"; - -service RpcEndpoint { - option (dev.restate.ext.service_type) = KEYED; - - rpc call(RpcRequest) returns (RpcResponse) {}; - - rpc handle(KeyedEvent) returns (google.protobuf.Empty) {}; -} - -message KeyedEvent { - string key = 1 [(dev.restate.ext.field) = KEY]; - bytes payload = 3 [(dev.restate.ext.field) = EVENT_PAYLOAD]; - map attributes = 15 [(dev.restate.ext.field) = EVENT_METADATA]; -} - -service UnkeyedRpcEndpoint { - option (dev.restate.ext.service_type) = UNKEYED; - - rpc call(RpcRequest) returns (RpcResponse) {}; -} - -message RpcRequest { - string key = 1 [(dev.restate.ext.field) = KEY]; - google.protobuf.Value request = 2; - - // internal: see src/utils/assumptions.ts - int32 sender_assumes = 101; -} - -message RpcResponse { - google.protobuf.Value response = 1; -} diff --git a/proto/protocol.proto b/proto/protocol.proto index a2ce02ea..8131bfb8 100644 --- a/proto/protocol.proto +++ b/proto/protocol.proto @@ -39,6 +39,11 @@ message StartMessage { // protolint:disable:next REPEATED_FIELD_NAMES_PLURALIZED repeated StateEntry state_map = 4; bool partial_state = 5; + + repeated Header headers = 6; + + // If this invocation has a key associated (e.g. for objects and workflows), then this key is filled in. Empty otherwise. + string key = 7; } // Type: 0x0000 + 1 @@ -196,6 +201,11 @@ message InvokeEntryMessage { bytes parameter = 3; + repeated Header headers = 4; + + // If this invocation has a key associated (e.g. for objects and workflows), then this key is filled in. Empty otherwise. + string key = 5; + oneof result { bytes value = 14; Failure failure = 15; @@ -216,6 +226,11 @@ message BackgroundInvokeEntryMessage { // If this value is not set, equal to 0, or past in time, // the runtime will execute this BackgroundInvoke as soon as possible. uint64 invoke_time = 4; + + repeated Header headers = 5; + + // If this invocation has a key associated (e.g. for objects and workflows), then this key is filled in. Empty otherwise. + string key = 6; } // Completable: Yes @@ -255,3 +270,8 @@ message Failure { // Contains a concise error message, e.g. Throwable#getMessage() in Java. string message = 2; } + +message Header { + string key = 1; + string value = 2; +} diff --git a/proto/services.proto b/proto/services.proto deleted file mode 100644 index d697fb38..00000000 --- a/proto/services.proto +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH -// -// This file is part of the Restate service protocol, which is -// released under the MIT license. -// -// You can find a copy of the license in file LICENSE in the root -// directory of this repository or package, or at -// https://github.com/restatedev/proto/blob/main/LICENSE - -syntax = "proto3"; - -/* - This package contains internal service interfaces - */ -package dev.restate.internal; - -import "dev/restate/ext.proto"; -import "google/protobuf/empty.proto"; -import "google/protobuf/struct.proto"; - -// RemoteContext service to implement the embedded handler API -service RemoteContext { - option (dev.restate.ext.service_type) = KEYED; - - // Start a new invocation, or resume a previously existing one. - // - // If another client is already executing this invocation, it will be fenced off and this client will take precedence. - // - // This method is not idempotent. - rpc Start(StartRequest) returns (StartResponse); - - // Send new messages to append to the message stream. - // - // This method is not idempotent, and a request can fail for several reasons, - // including errors in sent messages, or some other transient error. - // The client should consider the stream in an unrecoverable error state and it can retry - // by creating a new stream through Start() with a different stream_id. - // - // Once the invocation is completed, subsequent Send fail. - rpc Send(SendRequest) returns (SendResponse); - - // Receive new messages from the message stream. - // - // This method is not idempotent, and a request can fail for several reasons, - // including errors in sent messages, or some other transient error. - // The client should consider the stream in an unrecoverable error state and it can retry - // by creating a new stream through Start() with a different stream_id. - // - // If the invocation is completed, Recv returns a response with messages field empty. - rpc Recv(RecvRequest) returns (RecvResponse); - - // Get the result of the invocation. - // - // In case another client is executing the invocation (through a sequence of Start/Send/Recv), this method will block - // until a response is computed. - // In case the response is already available, it will return immediately with the response. - // In case no client is executing the invocation, that is no client ever invoked Start() for the given operation_id, - // this method will return response.none. - // - // This method can be safely invoked by multiple clients and it's idempotent. - rpc GetResult(GetResultRequest) returns (GetResultResponse); - - // Cleanup all the state of the invocation, excluding the user state. - // - // This is automatically executed when retention_period_sec is past, but it can be manually invoked before the expiry time elapsed. - rpc Cleanup(CleanupRequest) returns (google.protobuf.Empty); -} - -message StartRequest { - // User provided operation id, this is used as idempotency key. - string operation_id = 1 [(dev.restate.ext.field) = KEY]; - - // Stream id to uniquely identify a open stream between client and Restate. - // There can be at most one open stream at the same time. - string stream_id = 2; - - // Retention period for the response in seconds. - // After the invocation completes, the response will be persisted for the given duration. - // Afterwards, the system will cleanup the response and treats any subsequent invocation with same operation_id as new. - // - // If not set, 30 minutes will be used as retention period. - uint32 retention_period_sec = 3; - - // Argument of the invocation - bytes argument = 4; -} - -message StartResponse { - oneof invocation_status { - // Contains the concatenated first messages of the stream, encoded using the same framing used by service-protocol - bytes executing = 1; - - // Contains the result of the invocation - GetResultResponse completed = 2; - } -} - -message SendRequest { - // User provided operation id, this is used as idempotency key. - string operation_id = 1 [(dev.restate.ext.field) = KEY]; - - // Stream id to uniquely identify a open stream between client and Restate. - // There can be at most one open stream at the same time. - string stream_id = 2; - - // Contains the concatenated messages of the stream, encoded using the same framing used by service-protocol - bytes messages = 3; -} - -message SendResponse { - oneof response { - google.protobuf.Empty ok = 1; - - // This means the provided stream_id is invalid, and it should not be reused, - // nor the client should create a new stream using Start(). - // The client can instead read the invocation result using GetResult(). - google.protobuf.Empty invalid_stream = 2; - - // This means the invocation is completed, and the result should be collected using GetResult - google.protobuf.Empty invocation_completed = 3; - } -} - -message RecvRequest { - // User provided operation id, this is used as idempotency key. - string operation_id = 1 [(dev.restate.ext.field) = KEY]; - - // Stream id to uniquely identify a open stream between client and Restate. - // There can be at most one open stream at the same time. - string stream_id = 2; -} - -message RecvResponse { - oneof response { - // Contains the concatenated messages of the stream, encoded using the same framing used by service-protocol - bytes messages = 1; - - // This means the provided stream_id is invalid, and it should not be reused, - // nor the client should create a new stream using Start(). - // The client can instead read the invocation result using GetResult(). - google.protobuf.Empty invalid_stream = 2; - - // This means the invocation is completed, and the result should be collected using GetResult - google.protobuf.Empty invocation_completed = 3; - } -} - -message GetResultRequest { - // User provided operation id, this is used as idempotency key. - string operation_id = 1 [(dev.restate.ext.field) = KEY]; -} - -message GetResultResponse { - message InvocationFailure { - uint32 code = 1; - string message = 2; - } - - oneof response { - // See GetResult documentation - google.protobuf.Empty none = 1; - bytes success = 2; - InvocationFailure failure = 3; - } - - // Timestamp of the response expiry time in RFC3339. - // Empty if response = none - string expiry_time = 15; -} - -message CleanupRequest { - // User provided operation id, this is used as idempotency key. - string operation_id = 1 [(dev.restate.ext.field) = KEY]; -} diff --git a/proto/test.proto b/proto/test.proto deleted file mode 100644 index 424c7caa..00000000 --- a/proto/test.proto +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -syntax = "proto3"; - -package test; - -import "dev/restate/ext.proto"; -import "google/protobuf/empty.proto"; - -service TestGreeter { - option (dev.restate.ext.service_type) = KEYED; - - rpc Greet(TestRequest) returns (TestResponse) {}; -} - -message TestRequest { - string name = 1 [(dev.restate.ext.field) = KEY]; -} - -message TestResponse { - string greeting = 1; -} - -message TestEmpty { - google.protobuf.Empty greeting = 1; -} - diff --git a/src/connection/embedded_connection.ts b/src/connection/embedded_connection.ts deleted file mode 100644 index 632fe55f..00000000 --- a/src/connection/embedded_connection.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { RemoteContext } from "../generated/proto/services"; -import { Message } from "../types/types"; -import { BufferedConnection } from "./buffered_connection"; -import { Connection } from "./connection"; - -export class FencedOffError extends Error { - constructor() { - super("FencedOff"); - } -} - -export class InvocationAlreadyCompletedError extends Error { - constructor() { - super("Completed"); - } -} - -export class EmbeddedConnection implements Connection { - private buffered: BufferedConnection; - - constructor( - private readonly operationId: string, - private readonly streamId: string, - private readonly remote: RemoteContext - ) { - this.buffered = new BufferedConnection((buffer) => this.sendBuffer(buffer)); - } - - send(msg: Message): Promise { - return this.buffered.send(msg); - } - - end(): Promise { - return this.buffered.end(); - } - - private async sendBuffer(buffer: Buffer): Promise { - const res = await this.remote.send({ - operationId: this.operationId, - streamId: this.streamId, - messages: buffer, - }); - - if (res.invalidStream !== undefined) { - throw new FencedOffError(); - } - if (res.invocationCompleted !== undefined) { - throw new InvocationAlreadyCompletedError(); - } - } -} diff --git a/src/context.ts b/src/context.ts index e135d47d..f5f84761 100644 --- a/src/context.ts +++ b/src/context.ts @@ -10,7 +10,7 @@ */ import { RetrySettings } from "./utils/public_utils"; -import { Client, SendClient } from "./types/router"; +import { Client, SendClient } from "./types/rpc"; import { ContextImpl } from "./context_impl"; /** @@ -75,9 +75,8 @@ export interface KeyValueStore { * - awakeables * - ... * - * Keyed services can also access their key-value store using the {@link KeyedContext}. + * Keyed services can also access their key-value store using the {@link ObjectContext}. * - * In gRPC-based API, to access this context, use {@link useContext}. */ export interface Context { /** @@ -250,7 +249,9 @@ export interface Context { * const result2 = await ctx.rpc(myApi).anotherAction(1337); * ``` */ - rpc(opts: ServiceApi): Client; + service(opts: ServiceApi): Client; + + object(opts: ObjectApi, key: string): Client; /** * Makes a type-safe one-way RPC to the specified target service. This method effectively behaves @@ -291,7 +292,8 @@ export interface Context { * ctx.send(myApi).anotherAction(1337); * ``` */ - send(opts: ServiceApi): SendClient; + objectSend(opts: ObjectApi, key: string): SendClient; + serviceSend(opts: ServiceApi): SendClient; /** * Makes a type-safe one-way RPC to the specified target service, after a delay specified by the @@ -338,12 +340,12 @@ export interface Context { * ctx.sendDelayed(myApi, 60_000).anotherAction(1337); * ``` */ - sendDelayed(opts: ServiceApi, delay: number): SendClient; - - /** - * Get the {@link RestateGrpcChannel} to invoke gRPC based services. - */ - grpcChannel(): RestateGrpcChannel; + objectSendDelayed( + opts: ObjectApi, + delay: number, + key: string + ): SendClient; + serviceSendDelayed(opts: ServiceApi, delay: number): SendClient; } /** @@ -357,9 +359,10 @@ export interface Context { * * This context can be used only within keyed services/routers. * - * In gRPC-based API, to access this context, use {@link useKeyedContext}. */ -export interface KeyedContext extends Context, KeyValueStore {} +export interface ObjectContext extends Context, KeyValueStore { + key(): string; +} export interface Rand { /** @@ -488,118 +491,10 @@ export const CombineablePromise = { }, }; -// ---------------------------------------------------------------------------- -// types and functions for the gRPC-based API -// ---------------------------------------------------------------------------- - -/** - * Interface to interact with **gRPC** based services. You can use this interface to instantiate a gRPC generated client. - */ -export interface RestateGrpcChannel { - /** - * Unidirectional call to other Restate services ( = in background / async / not waiting on response). - * To do this, wrap the call via the proto-ts client with oneWayCall, as shown in the example. - * - * NOTE: this returns a Promise because we override the gRPC clients provided by proto-ts. - * So we are required to return a Promise. - * - * @param call Invoke another service by using the generated proto-ts client. - * @example - * const ctx = restate.useContext(this); - * const client = new GreeterClientImpl(ctx); - * await ctx.oneWayCall(() => - * client.greet(Request.create({ name: "Peter" })) - * ) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - oneWayCall(call: () => Promise): Promise; - - /** - * Delayed unidirectional call to other Restate services ( = in background / async / not waiting on response). - * To do this, wrap the call via the proto-ts client with delayedCall, as shown in the example. - * Add the delay in millis as the second parameter. - * - * NOTE: this returns a Promise because we override the gRPC clients provided by proto-ts. - * So we are required to return a Promise. - * - * @param call Invoke another service by using the generated proto-ts client. - * @param delayMillis millisecond delay duration to delay the execution of the call - * @example - * const ctx = restate.useContext(this); - * const client = new GreeterClientImpl(ctx); - * await ctx.delayedCall(() => - * client.greet(Request.create({ name: "Peter" })), - * 5000 - * ) - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delayedCall(call: () => Promise, delayMillis?: number): Promise; - - /** - * Call another Restate service and await the response. - * - * This function is not recommended to be called directly. Instead, use the generated gRPC client - * that was generated based on the Protobuf service definitions (which internally use this method): - * - * @example - * ``` - * const ctx = restate.useContext(this); - * const client = new GreeterClientImpl(ctx); - * client.greet(Request.create({ name: "Peter" })) - * ``` - * - * @param service name of the service to call - * @param method name of the method to call - * @param data payload as Uint8Array - * @returns a Promise that is resolved with the response of the called service - */ - request( - service: string, - method: string, - data: Uint8Array - ): Promise; -} - -/** - * @deprecated use {@link KeyedContext}. - */ -export type RestateContext = KeyedContext; - -/** - * Returns the {@link Context} which is the entrypoint for all interaction with Restate. - * Use this from within a method to retrieve the {@link Context}. - * The context is bounded to a single invocation. - * - * @example - * const ctx = restate.useContext(this); - * - */ -export function useContext(instance: T): Context { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrapper = instance as any; - if (wrapper.$$restate === undefined || wrapper.$$restate === null) { - throw new Error(`not running within a Restate call.`); - } - return wrapper.$$restate; -} - /** - * Returns the {@link KeyedContext} which is the entrypoint for all interaction with Restate. - * Use this from within a method of a keyed service to retrieve the {@link KeyedContext}. - * The context is bounded to a single invocation. - * - * @example - * const ctx = restate.useKeyedContext(this); - * + * @deprecated use {@link ObjectContext}. */ -export function useKeyedContext(instance: T): KeyedContext { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrapper = instance as any; - if (wrapper.$$restate === undefined || wrapper.$$restate === null) { - throw new Error(`not running within a Restate call.`); - } - return wrapper.$$restate; -} +export type RestateContext = ObjectContext; // ---------------------------------------------------------------------------- // types for the rpc-handler-based API @@ -631,3 +526,21 @@ export function useKeyedContext(instance: T): KeyedContext { export type ServiceApi<_M = unknown> = { path: string; }; + +export const serviceApi = <_M = unknown>( + path: string, + _m?: _M +): ServiceApi<_M> => { + return { path }; +}; + +export const objectApi = <_M = unknown>( + path: string, + _m?: _M +): ObjectApi<_M> => { + return { path }; +}; + +export type ObjectApi<_M = unknown> = { + path: string; +}; diff --git a/src/context_impl.ts b/src/context_impl.ts index e15ea0c7..5a021808 100644 --- a/src/context_impl.ts +++ b/src/context_impl.ts @@ -9,13 +9,7 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ -import { - CombineablePromise, - KeyedContext, - Rand, - RestateGrpcChannel, - ServiceApi, -} from "./context"; +import { CombineablePromise, ObjectContext, Rand, ServiceApi } from "./context"; import { StateMachine } from "./state_machine"; import { AwakeableEntryMessage, @@ -62,12 +56,12 @@ import { EXPONENTIAL_BACKOFF, RetrySettings, } from "./utils/public_utils"; -import { Client, SendClient } from "./types/router"; -import { RpcRequest, RpcResponse } from "./generated/proto/dynrpc"; -import { requestFromArgs } from "./utils/assumptions"; +import { Client, SendClient } from "./types/rpc"; import { RandImpl } from "./utils/rand"; import { newJournalEntryPromiseId } from "./promise_combinator_tracker"; import { WrappedPromise } from "./utils/promises"; +import { Buffer } from "node:buffer"; +import { deserializeJson, serializeJson } from "./utils/serde"; export enum CallContexType { None, @@ -85,7 +79,7 @@ export type InternalCombineablePromise = CombineablePromise & journalIndex: number; }; -export class ContextImpl implements KeyedContext, RestateGrpcChannel { +export class ContextImpl implements ObjectContext { // here, we capture the context information for actions on the Restate context that // are executed within other actions, such as // ctx.oneWayCall( () => client.foo(bar) ); @@ -103,11 +97,19 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { public readonly serviceName: string, public readonly console: Console, public readonly keyedContext: boolean, + public readonly keyedContextKey: string | undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly stateMachine: StateMachine, + private readonly stateMachine: StateMachine, public readonly rand: Rand = new RandImpl(id) ) {} + public key(): string { + if (!this.keyedContextKey) { + throw new TerminalError("unexpected missing key"); + } + return this.keyedContextKey; + } + // DON'T make this function async!!! see sideEffect comment for details. public get(name: string): Promise { // Check if this is a valid action @@ -220,7 +222,8 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { private invoke( service: string, method: string, - data: Uint8Array + data: Uint8Array, + key?: string ): InternalCombineablePromise { this.checkState("invoke"); @@ -228,6 +231,7 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { serviceName: service, methodName: method, parameter: Buffer.from(data), + key, }); return this.markCombineablePromise( this.stateMachine @@ -240,7 +244,8 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { service: string, method: string, data: Uint8Array, - delay?: number + delay?: number, + key?: string ): Promise { const actualDelay = delay || 0; const invokeTime = actualDelay > 0 ? Date.now() + actualDelay : undefined; @@ -249,6 +254,7 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { methodName: method, parameter: Buffer.from(data), invokeTime: invokeTime, + key, }); await this.stateMachine.handleUserCodeMessage( @@ -286,17 +292,16 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { ); } - rpc({ path }: ServiceApi): Client { + service({ path }: ServiceApi): Client { const clientProxy = new Proxy( {}, { get: (_target, prop) => { const route = prop as string; - return async (...args: unknown[]) => { - const request = requestFromArgs(args); - const requestBytes = RpcRequest.encode(request).finish(); + return (...args: unknown[]) => { + const requestBytes = serializeJson(args.shift()); return this.invoke(path, route, requestBytes).transform( - (responseBytes) => RpcResponse.decode(responseBytes).response + (responseBytes) => deserializeJson(responseBytes) ); }; }, @@ -306,11 +311,30 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { return clientProxy as Client; } - public send(options: ServiceApi): SendClient { - return this.sendDelayed(options, 0); + object({ path }: ServiceApi, key: string): Client { + const clientProxy = new Proxy( + {}, + { + get: (_target, prop) => { + const route = prop as string; + return (...args: unknown[]) => { + const requestBytes = serializeJson(args.shift()); + return this.invoke(path, route, requestBytes, key).transform( + (responseBytes) => deserializeJson(responseBytes) + ); + }; + }, + } + ); + + return clientProxy as Client; + } + + public serviceSend(options: ServiceApi): SendClient { + return this.serviceSendDelayed(options, 0); } - public sendDelayed( + public serviceSendDelayed( { path }: ServiceApi, delayMillis: number ): SendClient { @@ -320,8 +344,7 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { get: (_target, prop) => { const route = prop as string; return (...args: unknown[]) => { - const request = requestFromArgs(args); - const requestBytes = RpcRequest.encode(request).finish(); + const requestBytes = serializeJson(args.shift()); this.invokeOneWay(path, route, requestBytes, delayMillis); }; }, @@ -331,10 +354,29 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { return clientProxy as SendClient; } - // --- Methods exposed by respective interfaces to interact with other APIs + public objectSend(options: ServiceApi, key: string): SendClient { + return this.objectSendDelayed(options, 0, key); + } + + public objectSendDelayed( + { path }: ServiceApi, + delayMillis: number, + key: string + ): SendClient { + const clientProxy = new Proxy( + {}, + { + get: (_target, prop) => { + const route = prop as string; + return (...args: unknown[]) => { + const requestBytes = serializeJson(args.shift()); + this.invokeOneWay(path, route, requestBytes, delayMillis, key); + }; + }, + } + ); - grpcChannel(): RestateGrpcChannel { - return this; + return clientProxy as SendClient; } // DON'T make this function async!!! @@ -602,7 +644,7 @@ export class ContextImpl implements KeyedContext, RestateGrpcChannel { private checkStateOperation(callType: string): void { if (!this.keyedContext) { throw new TerminalError( - `You can do ${callType} calls only from keyed services/routers.`, + `You can do ${callType} calls only from a virtual object`, { errorCode: ErrorCodes.INTERNAL } ); } diff --git a/src/embedded/api.ts b/src/embedded/api.ts deleted file mode 100644 index 217a420e..00000000 --- a/src/embedded/api.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { Context } from "../context"; -import { doInvoke } from "./invocation"; -import { wrapHandler } from "./handler"; -import crypto from "crypto"; -import { RemoteContext } from "../generated/proto/services"; -import { bufConnectRemoteContext } from "./http2_remote"; -import { OutgoingHttpHeaders } from "http"; - -export type RestateConnectionOptions = { - /** - * Additional headers attached to the requests sent to Restate. - */ - headers: OutgoingHttpHeaders; -}; - -export type RestateInvocationOptions = { - /** - * Retention period for the response in seconds. - * After the invocation completes, the response will be persisted for the given duration. - * Afterward, the system will clean up the response and treats any subsequent invocation with same operation_id as new. - * - * If not set, 30 minutes will be used as retention period. - */ - retain?: number; -}; - -export const connection = ( - address: string, - opt?: RestateConnectionOptions -): RestateConnection => - new RestateConnection(bufConnectRemoteContext(address, opt)); - -export class RestateConnection { - constructor(private readonly remote: RemoteContext) {} - - public invoke( - id: string, - input: I, - handler: (ctx: Context, input: I) => Promise, - opt?: RestateInvocationOptions - ): Promise { - const method = wrapHandler(handler); - const streamId = crypto.randomUUID(); - return doInvoke(this.remote, id, streamId, input, method, opt); - } -} diff --git a/src/embedded/handler.ts b/src/embedded/handler.ts deleted file mode 100644 index 91a5ee95..00000000 --- a/src/embedded/handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { Context, useContext } from "../context"; -import { GrpcServiceMethod, HostedGrpcServiceMethod } from "../types/grpc"; - -export function wrapHandler( - handler: (ctx: Context, input: I) => Promise -): HostedGrpcServiceMethod { - const localMethod = (instance: unknown, input: I): Promise => { - return handler(useContext(instance), input); - }; - - const encoder = (output: O): Uint8Array => - Buffer.from(JSON.stringify(output)); - const decoder = (buf: Uint8Array): I => JSON.parse(buf.toString()); - - const method = new GrpcServiceMethod( - "", - "", - false, - localMethod, - decoder, - encoder - ); - - return new HostedGrpcServiceMethod({}, "", "", method); -} diff --git a/src/embedded/http2_remote.ts b/src/embedded/http2_remote.ts deleted file mode 100644 index 44e48120..00000000 --- a/src/embedded/http2_remote.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import http2 from "node:http2"; -import { once } from "events"; -import { - RemoteContext, - RemoteContextClientImpl, -} from "../generated/proto/services"; -import { RestateConnectionOptions } from "./api"; -import { OutgoingHttpHeaders } from "http"; - -export class RequestError extends Error { - constructor( - public readonly url: string, - public readonly status: number, - public readonly statusText?: string - ) { - super(`${status} ${statusText ?? ""}`); - } - - precondtionFailed(): boolean { - return this.status === http2.constants.HTTP_STATUS_PRECONDITION_FAILED; - } -} - -export const bufConnectRemoteContext = ( - url: string, - opt?: RestateConnectionOptions -): RemoteContext => { - const httpClient = new ProtobufHttp2Client(url, opt); - - return new RemoteContextClientImpl({ - request: (service: string, method: string, data: Uint8Array) => - httpClient.post(`/${service}/${method}`, data), - }); -}; - -class ProtobufHttp2Client { - private session?: http2.ClientHttp2Session; - private additionalHeaders: OutgoingHttpHeaders; - - public constructor( - private readonly ingress: string, - opt?: RestateConnectionOptions - ) { - this.additionalHeaders = opt?.headers == undefined ? {} : opt?.headers; - } - - private async client(): Promise { - if (this.session !== undefined) { - return this.session; - } - const client = http2.connect(this.ingress); - client.unref(); - - client.once("goaway", () => { - this.session = undefined; - }); - client.once("close", () => { - this.session = undefined; - }); - this.session = client; - return client; - } - - public async post(path: string, body: Uint8Array): Promise { - const client = await this.client(); - - const req = client.request({ - ...{ - [http2.constants.HTTP2_HEADER_SCHEME]: "http", - [http2.constants.HTTP2_HEADER_METHOD]: - http2.constants.HTTP2_METHOD_POST, - [http2.constants.HTTP2_HEADER_PATH]: path, - [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/proto", - [http2.constants.HTTP2_HEADER_ACCEPT]: "application/proto", - [http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: body.length, - }, - ...this.additionalHeaders, - }); - req.end(body); - - const [headers] = await once(req, "response"); - const status = headers[http2.constants.HTTP2_HEADER_STATUS] ?? 0; - if (status !== 200) { - throw new RequestError(path, status); - } - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - return Buffer.concat(chunks); - } -} diff --git a/src/embedded/invocation.ts b/src/embedded/invocation.ts deleted file mode 100644 index 963aa03e..00000000 --- a/src/embedded/invocation.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { decodeMessagesBuffer } from "../io/decoder"; -import { Message } from "../types/types"; -import { - GetResultResponse, - RemoteContext, - StartRequest, -} from "../generated/proto/services"; -import { InvocationBuilder } from "../invocation"; -import { HostedGrpcServiceMethod } from "../types/grpc"; -import { StateMachine } from "../state_machine"; -import { ProtocolMode } from "../generated/proto/discovery"; -import { - EmbeddedConnection, - FencedOffError, -} from "../connection/embedded_connection"; -import { RestateInvocationOptions } from "./api"; - -export const doInvoke = async ( - remote: RemoteContext, - operationId: string, - streamId: string, - input: I, - method: HostedGrpcServiceMethod, - opt?: RestateInvocationOptions -): Promise => { - // - // 1. ask to Start this execution. - // - - const startRequest = StartRequest.fromPartial({ - operationId, - streamId, - argument: Buffer.from(JSON.stringify(input)), - }); - if (opt != undefined && opt.retain != undefined) { - startRequest.retentionPeriodSec = opt.retain; - } - const res = await remote.start(startRequest); - - if (res.completed !== undefined) { - return unwrap(res.completed); - } - - // - // 2. rebuild the previous execution state - // - - const messages = decodeMessagesBuffer(res.executing ?? Buffer.alloc(0)); - const journalBuilder = new InvocationBuilder(method); - messages.forEach((e: Message) => journalBuilder.handleMessage(e)); - const journal = journalBuilder.build(); - - // - // 3. resume the execution state machine - // - const connection = new EmbeddedConnection(operationId, streamId, remote); - - const stateMachine = new StateMachine( - connection, - journal, - ProtocolMode.BIDI_STREAM, - false, - journal.inferLoggerContext(), - -1 - ); - - // - // 4. track the state machine execution result - // - let done = false; - - const invocation = stateMachine.invoke().finally(() => (done = true)); - - // - // 5. keep pulling for input and feeding this to the fsm. - // - try { - while (!done) { - const recv = await remote.recv({ - operationId, - streamId, - }); - if (recv.invalidStream !== undefined) { - throw new FencedOffError(); - } - if (recv.invocationCompleted !== undefined) { - break; - } - const buffer = recv.messages ?? Buffer.alloc(0); - const messages = decodeMessagesBuffer(buffer); - messages.forEach((m: Message) => stateMachine.handleMessage(m)); - } - } catch (e) { - stateMachine.handleStreamError(e as Error); - throw e; - } - - // - // 6. wait for the state machine to complete the invocation - // - const maybeResult = await invocation; - if (maybeResult instanceof Buffer) { - return JSON.parse(maybeResult.toString()); - } - - // TODO: no sure what to do here. The state machine has decided to be suspended? - throw new Error("suspended"); -}; - -const unwrap = (response: GetResultResponse): O => { - if (response.success === undefined) { - throw new Error(response.failure?.message ?? ""); - } - return JSON.parse(response.success.toString()) as O; -}; diff --git a/src/endpoint.ts b/src/endpoint.ts index 2bf72f67..10e04afb 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -9,8 +9,7 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ -import { KeyedRouter, UnKeyedRouter } from "./types/router"; -import { ProtoMetadata } from "./types/grpc"; +import { VirtualObject, Service } from "./types/rpc"; import { Http2ServerRequest, Http2ServerResponse } from "http2"; import { EndpointImpl } from "./endpoint/endpoint_impl"; @@ -21,16 +20,6 @@ export function endpoint(): RestateEndpoint { return new EndpointImpl(); } -/** - * The properties describing a gRPC service. Consisting of the name, the object holding the - * implementation, and the descriptor (metadata) describing the service and types. - */ -export interface ServiceOpts { - descriptor: ProtoMetadata; - service: string; - instance: unknown; -} - /** * Utility interface for a bundle of one or more services belonging together * and being registered together. @@ -78,44 +67,6 @@ export interface ServiceBundle { * ``` */ export interface RestateEndpoint { - /** - * Adds a gRPC service to be served from this endpoint. - * - * The {@link ServiceOpts} passed here need to describe the following properties: - * - * - The 'service' name: the name of the gRPC service (as in the service definition proto file). - * - The service 'instance': the implementation of the service logic (must implement the generated - * gRPC service interface). - * - The gRPC/protobuf 'descriptor': The protoMetadata descriptor that describes the service, methods, - * and parameter types. It is usually found as the value 'protoMetadata' in the generated - * file '(service-name).ts' - * - * The descriptor is generated by the protobuf compiler and needed by Restate to reflectively discover - * the service details, understand payload serialization, perform HTTP/JSON-to-gRPC transcoding, or - * to proxy the service. - * - * If you define multiple services in the same '.proto' file, you may have only one descriptor that - * describes all services together. You can pass the same descriptor to multiple calls of '.bindService()'. - * - * If you don't find the gRPC/protobuf descriptor, make your you generated the gRPC/ProtoBuf code with - * the option to generate the descriptor. For example, using the 'ts-proto' plugin, make sure you pass - * the 'outputSchema=true' option. If you are using Restate's project templates, this should all be - * pre-configured for you. - * - * @example - * ``` - * endpoint.bindService({ - * service: "MyService", - * instance: new myService.MyServiceImpl(), - * descriptor: myService.protoMetadata - * }) - * ``` - * - * @param serviceOpts The options describing the service to be bound. See above for a detailed description. - * @returns An instance of this LambdaRestateServer - */ - bindService(serviceOpts: ServiceOpts): RestateEndpoint; - /** * Binds a new durable RPC service to the given path. This method is for regular (stateless) * durably executed RPC services. @@ -124,7 +75,7 @@ export interface RestateEndpoint { * If the path is 'acme.myservice' and the router has '{ foo, bar }' as properties, the * Restate will expose the RPC paths '/acme.myservice/foo' and '/acme.myservice/bar'. */ - bindRouter(path: string, router: UnKeyedRouter): RestateEndpoint; + service(path: string, service: Service): RestateEndpoint; /** * Binds a new stateful keyed durable RPC service to the given path. @@ -135,7 +86,7 @@ export interface RestateEndpoint { * If the path is 'acme.myservice' and the router has '{ foo, bar }' as properties, the * Restate will expose the RPC paths '/acme.myservice/foo' and '/acme.myservice/bar'. */ - bindKeyedRouter(path: string, router: KeyedRouter): RestateEndpoint; + object(path: string, virtualObject: VirtualObject): RestateEndpoint; /** * Adds one or more services to this endpoint. This will call the diff --git a/src/endpoint/endpoint_impl.ts b/src/endpoint/endpoint_impl.ts index 565ee76a..77a74bf9 100644 --- a/src/endpoint/endpoint_impl.ts +++ b/src/endpoint/endpoint_impl.ts @@ -11,104 +11,40 @@ /* eslint-disable @typescript-eslint/ban-types */ -import { - GrpcService, - GrpcServiceMethod, - HostedGrpcServiceMethod, - ProtoMetadata, -} from "../types/grpc"; -import { - DeepPartial, - ServiceDiscoveryResponse, -} from "../generated/proto/discovery"; -import { Event } from "../types/types"; -import { - FileDescriptorProto, - UninterpretedOption, -} from "../generated/google/protobuf/descriptor"; -import { Empty } from "../generated/google/protobuf/empty"; -import { - FileDescriptorProto as FileDescriptorProto1, - ServiceDescriptorProto as ServiceDescriptorProto1, - MethodDescriptorProto as MethodDescriptorProto1, -} from "ts-proto-descriptors"; -import { - fieldTypeToJSON, - ServiceType, - serviceTypeToJSON, -} from "../generated/dev/restate/ext"; -import { - RpcRequest, - RpcResponse, - ProtoMetadata as RpcServiceProtoMetadata, - protoMetadata as rpcServiceProtoMetadata, - KeyedEvent, -} from "../generated/proto/dynrpc"; -import { Context, KeyedContext, useContext, useKeyedContext } from "../context"; -import { verifyAssumptions } from "../utils/assumptions"; -import { RestateEndpoint, ServiceBundle, TerminalError } from "../public_api"; -import { KeyedRouter, UnKeyedRouter, isEventHandler } from "../types/router"; -import { jsonSafeAny } from "../utils/utils"; +import { RestateEndpoint, ServiceBundle } from "../public_api"; +import { VirtualObject, Service } from "../types/rpc"; import { rlog } from "../logger"; -import { ServiceOpts } from "../endpoint"; import http2, { Http2ServerRequest, Http2ServerResponse } from "http2"; import { Http2Handler } from "./http2_handler"; import { LambdaHandler } from "./lambda_handler"; +import { + Component, + ServiceComponent, + ServiceHandlerFunction, + VirtualObjectHandlerFunction, + VritualObjectComponent as VritualObjectComponent, +} from "../types/components"; + +import * as discovery from "../types/discovery"; export class EndpointImpl implements RestateEndpoint { - protected readonly methods: Record< - string, - HostedGrpcServiceMethod - > = {}; - readonly discovery: DeepPartial; - protected readonly dynrpcDescriptor: RpcServiceProtoMetadata; + private readonly components: Map = new Map(); - public constructor() { - this.discovery = { - files: { file: [] }, - services: [], - minProtocolVersion: 0, - maxProtocolVersion: 0, - }; - this.dynrpcDescriptor = copyProtoMetadata(rpcServiceProtoMetadata); + public componentByName(componentName: string): Component | undefined { + return this.components.get(componentName); } - bindService({ descriptor, service, instance }: ServiceOpts): RestateEndpoint { - const spec = parseService(descriptor, service, instance); - this.addDescriptor(descriptor); - - const qname = - spec.packge === "" ? spec.name : `${spec.packge}.${spec.name}`; - - this.discovery.services?.push(qname); - for (const method of spec.methods) { - const url = `/invoke/${qname}/${method.name}`; - this.methods[url] = new HostedGrpcServiceMethod( - instance, - spec.packge, - service, - method - ); - // note that this log will not print all the keys. - rlog.info(`Binding: ${url} -> ${JSON.stringify(method, null, "\t")}`); - } - - return this; + public addComponent(component: Component) { + this.components.set(component.name(), component); } - public bindRouter( - path: string, - router: UnKeyedRouter - ): RestateEndpoint { - this.bindRpcService(path, router, false); + public service(path: string, router: Service): RestateEndpoint { + this.bindServiceComponent(path, router); return this; } - public bindKeyedRouter( - path: string, - router: KeyedRouter - ): RestateEndpoint { - this.bindRpcService(path, router, true); + public object(path: string, router: VirtualObject): RestateEndpoint { + this.bindVirtualObjectComponent(path, router); return this; } @@ -141,483 +77,53 @@ export class EndpointImpl implements RestateEndpoint { return new Promise(() => {}); } - // Private methods to build the endpoint - - private addDescriptor(descriptor: ProtoMetadata) { - const desc = FileDescriptorProto.fromPartial(descriptor.fileDescriptor); - - // extract out service options and put into the fileDescriptor - for (const name in descriptor.options?.services) { - if ( - descriptor.options?.services[name]?.options?.service_type !== undefined - ) { - desc.service - .find((desc) => desc.name === name) - ?.options?.uninterpretedOption.push( - UninterpretedOption.fromPartial({ - name: [ - { namePart: "dev.restate.ext.service_type", isExtension: true }, - ], - identifierValue: serviceTypeToJSON( - descriptor.options?.services[name]?.options?.service_type - ), - }) - ); - } - } - - // extract out field options and put into the fileDescriptor - for (const messageName in descriptor.options?.messages) { - for (const fieldName in descriptor.options?.messages[messageName] - ?.fields) { - const fields = descriptor.options?.messages[messageName]?.fields || {}; - if (fields[fieldName]["field"] !== undefined) { - desc.messageType - .find((desc) => desc.name === messageName) - ?.field?.find((desc) => desc.name === fieldName) - ?.options?.uninterpretedOption.push( - UninterpretedOption.fromPartial({ - name: [ - { namePart: "dev.restate.ext.field", isExtension: true }, - ], - identifierValue: fieldTypeToJSON(fields[fieldName]["field"]), - }) - ); - } - } - } - - if ( - this.discovery.files?.file?.filter( - (haveDesc) => desc.name === haveDesc.name - ).length === 0 - ) { - this.discovery.files?.file.push(desc); - } - descriptor.dependencies?.forEach((dep) => { - this.addDescriptor(dep); - }); - } - - private rpcHandler( - keyed: boolean, - route: string, - handler: Function - ): { - descriptor: MethodDescriptorProto1; - method: GrpcServiceMethod; - } { - const descriptor = createRpcMethodDescriptor(route); + computeDiscovery(): discovery.Deployment { + const components = [...this.components.values()].map((c) => c.discovery()); - const localMethod = (instance: unknown, input: RpcRequest) => { - if (keyed) { - return dispatchKeyedRpcHandler( - useKeyedContext(instance), - input, - handler - ); - } else { - return dispatchUnkeyedRpcHandler(useContext(instance), input, handler); - } + const deployment: discovery.Deployment = { + protocolMode: discovery.ProtocolMode.BIDI_STREAM, + minProtocolVersion: 1, + maxProtocolVersion: 2, + components, }; - const decoder = RpcRequest.decode; - const encoder = (message: RpcResponse) => - RpcResponse.encode({ - response: jsonSafeAny("", message.response), - }).finish(); - - const method = new GrpcServiceMethod( - route, - route, - keyed, - localMethod, - decoder, - encoder - ); - - return { - descriptor: descriptor, - method: method as GrpcServiceMethod, - }; + return deployment; } - stringKeyedEventHandler( - keyed: boolean, - route: string, - handler: Function - ): { - descriptor: MethodDescriptorProto1; - method: GrpcServiceMethod; - } { - if (!keyed) { - // TODO: support unkeyed rpc event handler - throw new TerminalError("Unkeyed Event handlers are not yet supported."); - } - const descriptor = createStringKeyedMethodDescriptor(route); - const localMethod = (instance: unknown, input: KeyedEvent) => { - return dispatchKeyedEventHandler( - useKeyedContext(instance), - input, - handler - ); - }; - - const decoder = KeyedEvent.decode; - const encoder = (message: Empty) => Empty.encode(message).finish(); - - const method = new GrpcServiceMethod( - route, - route, - keyed, - localMethod, - decoder, - encoder - ); - - return { - descriptor, - method: method as GrpcServiceMethod, - }; - } - - private bindRpcService(name: string, router: RpcRouter, keyed: boolean) { - if (name === undefined || router === undefined || keyed === undefined) { - throw new Error("incomplete arguments: (name, router, keyed)"); - } - if (!(typeof name === "string") || name.length === 0) { - throw new Error("service name must be a non-empty string"); - } + private bindServiceComponent(name: string, router: RpcRouter) { if (name.indexOf("/") !== -1) { throw new Error("service name must not contain any slash '/'"); } - - const lastDot = name.indexOf("."); - const serviceName = lastDot === -1 ? name : name.substring(lastDot + 1); - const servicePackage = name.substring( - 0, - name.length - serviceName.length - 1 - ); - - const desc = this.dynrpcDescriptor; - const serviceGrpcSpec = keyed - ? pushKeyedService(desc, name) - : pushUnKeyedService(desc, name); + const component = new ServiceComponent(name); for (const [route, handler] of Object.entries(router)) { - let registration: { - descriptor: MethodDescriptorProto1; - method: GrpcServiceMethod; - }; - - if (isEventHandler(handler)) { - const theHandler = handler.handler; - registration = this.stringKeyedEventHandler(keyed, route, theHandler); - } else { - registration = this.rpcHandler(keyed, route, handler); - } - serviceGrpcSpec.method.push(registration.descriptor); - const url = `/invoke/${name}/${route}`; - this.methods[url] = new HostedGrpcServiceMethod( - {}, // we don't actually execute on any class instance - servicePackage, - serviceName, - registration.method - ) as HostedGrpcServiceMethod; - - rlog.info( - `Binding: ${url} -> ${JSON.stringify(registration.method, null, "\t")}` - ); - } - - // since we modified this descriptor, we need to remove it in case it was added before, - // so that the modified version is processed and added again - const filteredFiles = this.discovery.files?.file?.filter( - (haveDesc) => desc.fileDescriptor.name !== haveDesc.name - ); - if (this.discovery.files !== undefined && filteredFiles !== undefined) { - this.discovery.files.file = filteredFiles; + component.add({ + name: route, + /* eslint-disable @typescript-eslint/no-explicit-any */ + fn: handler as ServiceHandlerFunction, + }); } - this.addDescriptor(desc); - this.discovery.services?.push(name); + this.addComponent(component); } - methodByUrl( - url: string | undefined | null - ): HostedGrpcServiceMethod | undefined { - if (url == undefined || url === null) { - return undefined; - } - return this.methods[url] as HostedGrpcServiceMethod; - } -} - -/* eslint-disable @typescript-eslint/no-explicit-any */ -function indexProperties(instance: any): Map { - const names = new Map(); - while ( - instance !== null && - instance !== undefined && - instance !== Object.prototype - ) { - for (const property of Object.getOwnPropertyNames(instance)) { - names.set(property.toLowerCase(), property); - } - instance = Object.getPrototypeOf(instance); - } - return names; -} - -// Given: -// * an instance of a class that implements a gRPC TypeScript interface, -// as generated by our protoc plugin, this method -// * The ProtobufFileDescriptor as generated by the protobuf plugin -// * and the gRPC service name -// -// Return a GrpcService definition, as defined above. -// -// For example (see first: example.proto and example.ts): -// -// > parse(example.protoMetaData, "Greeter", new GreeterService()) -// -// produces ~ -// -// serviceName: 'Greeter', -// instance: GreeterService {}, -// methods: { -// multiword: { -// localName: 'multiWord', -// fn: [Function: multiWord], -// inputType: [Object], -// outputType: [Object] -// }, -// greet: { -// localName: 'greet', -// fn: [Function: greet], -// inputType: [Object], -// outputType: [Object] -// } -// } -//} -// -/* eslint-disable @typescript-eslint/no-explicit-any */ -export function parseService( - meta: ProtoMetadata, - serviceName: string, - instance: any -) { - const svcMethods: Array> = []; - - const service_type = - meta.options?.services?.[serviceName].options?.["service_type"]; - const keyed = service_type !== ServiceType.UNKEYED; - - // index all the existing properties that `instance` has. - // we index them by the lower case represention. - const names = indexProperties(instance); - for (const serviceDescriptor of meta.fileDescriptor.service) { - if (serviceName !== serviceDescriptor.name) { - continue; + private bindVirtualObjectComponent(name: string, router: RpcRouter) { + if (name.indexOf("/") !== -1) { + throw new Error("service name must not contain any slash '/'"); } - for (const methodDescriptor of serviceDescriptor.method) { - const lowercaseName = methodDescriptor.name.toLowerCase(); - const localName = names.get(lowercaseName); - if (localName === undefined || localName === null) { - throw new Error(`unimplemented method ${methodDescriptor.name}`); - } - const fn = instance[localName]; - if (typeof fn !== "function") { - throw new Error( - `A property ${localName} exists, which coresponds to a gRPC service named ${methodDescriptor.name}, but that property is not a function.` - ); - } - const localMethod = async (instance: unknown, input: unknown) => { - return await fn.call(instance, input); - }; - let inputMessage = meta.references[methodDescriptor.inputType]; - // If the input message type is not defined by the proto files of the service but by a dependency (e.g. BoolValue, Empty, etc) - // then we need to look for the encoders and decoders in the dependencies. - if (inputMessage === undefined) { - meta.dependencies?.forEach((dep) => { - if (dep.references[methodDescriptor.inputType] !== undefined) { - inputMessage = dep.references[methodDescriptor.inputType]; - } - }); - } - let outputMessage = meta.references[methodDescriptor.outputType]; - // If the output message type is not defined by use but by the proto files of the service (e.g. BoolValue, Empty, etc) - // then we need to look for the encoders and decoders in the dependencies. - if (outputMessage === undefined) { - meta.dependencies?.forEach((dep) => { - if (dep.references[methodDescriptor.outputType] !== undefined) { - outputMessage = dep.references[methodDescriptor.outputType]; - } - }); - } + const component = new VritualObjectComponent(name); - const decoder = (buffer: Uint8Array) => inputMessage.decode(buffer); - const encoder = (message: unknown) => - outputMessage.encode(message).finish(); - svcMethods.push( - new GrpcServiceMethod( - methodDescriptor.name, - localName, - keyed, - localMethod, - decoder, - encoder - ) - ); + for (const [route, handler] of Object.entries(router)) { + component.add({ + name: route, + fn: handler as VirtualObjectHandlerFunction, + }); } - return new GrpcService( - serviceName, - meta.fileDescriptor.package, - instance, - svcMethods - ); + this.addComponent(component); } - throw new Error(`Unable to find a service ${serviceName}.`); } export type RpcRouter = { [key: string]: Function; }; - -async function dispatchKeyedRpcHandler( - ctx: KeyedContext, - req: RpcRequest, - handler: Function -): Promise { - const { key, request } = verifyAssumptions(true, req); - if (typeof key !== "string" || key.length === 0) { - // we throw a terminal error here, because this cannot be patched by updating code: - // if the request is wrong (missing a key), the request can never make it - throw new TerminalError( - "Keyed handlers must recieve a non null or empty string key" - ); - } - const jsResult = (await handler(ctx, key, request)) as any; - return RpcResponse.create({ response: jsResult }); -} - -async function dispatchUnkeyedRpcHandler( - ctx: Context, - req: RpcRequest, - handler: Function -): Promise { - const { request } = verifyAssumptions(false, req); - const result = await handler(ctx, request); - return RpcResponse.create({ response: result }); -} - -async function dispatchKeyedEventHandler( - ctx: KeyedContext, - req: KeyedEvent, - handler: Function -): Promise { - const key = req.key; - if (key === null || key === undefined || key.length === 0) { - // we throw a terminal error here, because this cannot be patched by updating code: - // if the request is wrong (missing a key), the request can never make it - throw new TerminalError( - "Keyed handlers must receive a non null or empty string key" - ); - } - const jsEvent = new Event(key, req.payload, req.attributes); - await handler(ctx, jsEvent); - return Empty.create({}); -} - -function copyProtoMetadata( - original: RpcServiceProtoMetadata -): RpcServiceProtoMetadata { - // duplicate the file descriptor. shallow, because we only need to - // change one top-level field: service[] - const fileDescriptorCopy = { - ...original.fileDescriptor, - } as FileDescriptorProto1; - fileDescriptorCopy.service = []; - - let options = original.options; - if (options !== undefined) { - options = { - ...original.options, - }; - options.services = {}; - } - - return { - fileDescriptor: fileDescriptorCopy, - references: original.references, - dependencies: original.dependencies, - options: options, - }; -} - -function pushKeyedService( - desc: RpcServiceProtoMetadata, - newName: string -): ServiceDescriptorProto1 { - const service = { - ...rpcServiceProtoMetadata.fileDescriptor.service[0], - } as ServiceDescriptorProto1; - service.name = newName; - service.method = []; - desc.fileDescriptor.service.push(service); - - const serviceOptions = - rpcServiceProtoMetadata.options?.services?.["RpcEndpoint"]; - if (serviceOptions === undefined) { - throw new Error( - "Missing service options in original RpcEndpoint proto descriptor" - ); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - desc.options!.services![newName] = serviceOptions; - - return service; -} - -function pushUnKeyedService( - desc: RpcServiceProtoMetadata, - newName: string -): ServiceDescriptorProto1 { - const service = { - ...rpcServiceProtoMetadata.fileDescriptor.service[1], - } as ServiceDescriptorProto1; - service.name = newName; - service.method = []; - desc.fileDescriptor.service.push(service); - - const serviceOptions = - rpcServiceProtoMetadata.options?.services?.["UnkeyedRpcEndpoint"]; - if (serviceOptions === undefined) { - throw new Error( - "Missing service options in original UnkeyedRpcEndpoint proto descriptor" - ); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - desc.options!.services![newName] = serviceOptions; - - return service; -} - -function createRpcMethodDescriptor(methodName: string): MethodDescriptorProto1 { - const desc = { - ...rpcServiceProtoMetadata.fileDescriptor.service[0].method[0], - } as MethodDescriptorProto1; - desc.name = methodName; - return desc; -} - -function createStringKeyedMethodDescriptor( - methodName: string -): MethodDescriptorProto1 { - const desc = { - ...rpcServiceProtoMetadata.fileDescriptor.service[0].method[1], - } as MethodDescriptorProto1; - desc.name = methodName; - return desc; -} diff --git a/src/endpoint/http2_handler.ts b/src/endpoint/http2_handler.ts index 0a656fe2..fcccac59 100644 --- a/src/endpoint/http2_handler.ts +++ b/src/endpoint/http2_handler.ts @@ -13,26 +13,22 @@ import stream from "stream"; import { pipeline, finished } from "stream/promises"; import http2, { Http2ServerRequest, Http2ServerResponse } from "http2"; import { parse as urlparse, Url } from "url"; -import { - ProtocolMode, - ServiceDiscoveryResponse, -} from "../generated/proto/discovery"; import { EndpointImpl } from "./endpoint_impl"; import { RestateHttp2Connection } from "../connection/http_connection"; -import { HostedGrpcServiceMethod } from "../types/grpc"; import { ensureError } from "../types/errors"; import { InvocationBuilder } from "../invocation"; import { StateMachine } from "../state_machine"; import { rlog } from "../logger"; +import { + ComponentHandler, + UrlPathComponents, + VirtualObjectHandler, + parseUrlComponents, +} from "../types/components"; +import { Deployment, ProtocolMode } from "../types/discovery"; export class Http2Handler { - private readonly discoveryResponse: ServiceDiscoveryResponse; - constructor(private readonly endpoint: EndpointImpl) { - this.discoveryResponse = ServiceDiscoveryResponse.fromPartial({ - ...this.endpoint.discovery, - protocolMode: ProtocolMode.BIDI_STREAM, - }); - } + constructor(private readonly endpoint: EndpointImpl) {} acceptConnection( request: Http2ServerRequest, @@ -52,49 +48,47 @@ export class Http2Handler { }); } - private async handleConnection( + private handleConnection( url: Url, stream: http2.ServerHttp2Stream ): Promise { - const method = this.endpoint.methodByUrl(url.path); - - if (method !== undefined) { - // valid connection, let's dispatch the invocation - stream.respond({ - "content-type": "application/restate", - ":status": 200, - }); - - const restateStream = RestateHttp2Connection.from(stream); - await handleInvocation(method, restateStream); - return; + const route = parseUrlComponents(url.path ?? undefined); + if (!route) { + return respondNotFound(stream); } - - // no method under that name. might be a discovery request - if (url.path == "/discover") { - rlog.info( - "Answering discovery request. Announcing services: " + - JSON.stringify(this.discoveryResponse.services) - ); - await respondDiscovery(this.discoveryResponse, stream); - return; + if (route === "discovery") { + return respondDiscovery(this.endpoint.computeDiscovery(), stream); } - - // no discovery, so unknown method: 404 - rlog.error(`No service and function found for URL ${url.path}`); - await respondNotFound(stream); + const urlComponents = route as UrlPathComponents; + const component = this.endpoint.componentByName( + urlComponents.componentName + ); + if (!component) { + return respondNotFound(stream); + } + const handler = component.handlerMatching(urlComponents); + if (!handler) { + return respondNotFound(stream); + } + // valid connection, let's dispatch the invocation + stream.respond({ + "content-type": "application/restate", + ":status": 200, + }); + const restateStream = RestateHttp2Connection.from(stream); + return handleInvocation(handler, restateStream); } } async function respondDiscovery( - response: ServiceDiscoveryResponse, + response: Deployment, http2Stream: http2.ServerHttp2Stream ) { - const responseData = ServiceDiscoveryResponse.encode(response).finish(); + const responseData = JSON.stringify(response); http2Stream.respond({ ":status": 200, - "content-type": "application/proto", + "content-type": "application/json", }); await pipeline(stream.Readable.from(responseData), http2Stream, { @@ -104,19 +98,19 @@ async function respondDiscovery( async function respondNotFound(stream: http2.ServerHttp2Stream) { stream.respond({ - "content-type": "application/restate", + "content-type": "application/json", ":status": 404, }); stream.end(); await finished(stream); } -async function handleInvocation( - func: HostedGrpcServiceMethod, +async function handleInvocation( + handler: ComponentHandler, connection: RestateHttp2Connection ) { // step 1: collect all journal events - const journalBuilder = new InvocationBuilder(func); + const journalBuilder = new InvocationBuilder(handler); connection.pipeToConsumer(journalBuilder); try { await journalBuilder.completion(); @@ -127,11 +121,11 @@ async function handleInvocation( // step 2: create the state machine const invocation = journalBuilder.build(); - const stateMachine = new StateMachine( + const stateMachine = new StateMachine( connection, invocation, ProtocolMode.BIDI_STREAM, - func.method.keyedContext, + handler instanceof VirtualObjectHandler, invocation.inferLoggerContext() ); connection.pipeToConsumer(stateMachine); diff --git a/src/endpoint/lambda_handler.ts b/src/endpoint/lambda_handler.ts index 21e969a1..7f2fbb14 100644 --- a/src/endpoint/lambda_handler.ts +++ b/src/endpoint/lambda_handler.ts @@ -17,10 +17,6 @@ import { APIGatewayProxyResultV2, Context, } from "aws-lambda"; -import { - ProtocolMode, - ServiceDiscoveryResponse, -} from "../generated/proto/discovery"; import { EndpointImpl } from "./endpoint_impl"; import { LambdaConnection } from "../connection/lambda_connection"; import { InvocationBuilder } from "../invocation"; @@ -29,15 +25,16 @@ import { Message } from "../types/types"; import { StateMachine } from "../state_machine"; import { ensureError } from "../types/errors"; import { OUTPUT_STREAM_ENTRY_MESSAGE_TYPE } from "../types/protocol"; +import { ProtocolMode } from "../types/discovery"; +import { + ComponentHandler, + UrlPathComponents, + VirtualObjectHandler, + parseUrlComponents, +} from "../types/components"; export class LambdaHandler { - private readonly discoveryResponse: ServiceDiscoveryResponse; - constructor(private readonly endpoint: EndpointImpl) { - this.discoveryResponse = ServiceDiscoveryResponse.fromPartial({ - ...this.endpoint.discovery, - protocolMode: ProtocolMode.REQUEST_RESPONSE, - }); - } + constructor(private readonly endpoint: EndpointImpl) {} // -------------------------------------------------------------------------- @@ -48,65 +45,44 @@ export class LambdaHandler { event: APIGatewayProxyEvent | APIGatewayProxyEventV2, context: Context ): Promise { - let path; - if ("path" in event) { - // V1 - path = event.path; - } else { - // V2 - path = event.rawPath; - } - const pathSegments = path.split("/"); - - // API Gateway can add a prefix to the path based on the name of the Lambda function and deployment stage - // (e.g. /default) - // So we only check the ending of the path on correctness. - // Logic: - // 1. Check whether there are at least three segments in the path and whether the third-last one is "invoke". - // If that is the case, treat it as an invocation. - // 2. See if the last one is "discover", answer with discovery. - // 3. Else report "invalid path". - if ( - pathSegments.length >= 3 && - pathSegments[pathSegments.length - 3] === "invoke" - ) { - const url = "/" + pathSegments.slice(-3).join("/"); - return await this.handleInvoke(url, event, context); - } else if (pathSegments[pathSegments.length - 1] === "discover") { - return this.handleDiscovery(); - } else { + const path = "path" in event ? event.path : event.rawPath; + const parsed = parseUrlComponents(path); + if (!parsed) { const msg = `Invalid path: path doesn't end in /invoke/SvcName/MethodName and also not in /discover: ${path}`; rlog.trace(msg); - return this.toErrorResponse(500, msg); + return this.toErrorResponse(404, msg); + } + if (parsed === "discovery") { + return this.handleDiscovery(); } + const parsedUrl = parsed as UrlPathComponents; + const method = this.endpoint.componentByName(parsedUrl.componentName); + if (!method) { + const msg = `No service found for URL: ${parsedUrl}`; + rlog.error(msg); + return this.toErrorResponse(404, msg); + } + const handler = method?.handlerMatching(parsedUrl); + if (!handler) { + const msg = `No service found for URL: ${parsedUrl}`; + rlog.error(msg); + return this.toErrorResponse(404, msg); + } + if (!event.body) { + throw new Error("The incoming message body was null"); + } + return this.handleInvoke(handler, event.body, context); } private async handleInvoke( - url: string, - event: APIGatewayProxyEvent | APIGatewayProxyEventV2, + handler: ComponentHandler, + body: string, context: Context ): Promise { try { - const method = this.endpoint.methodByUrl(url); - if (event.body == null) { - throw new Error("The incoming message body was null"); - } - - if (method === undefined) { - if (url.includes("?")) { - throw new Error( - `Invalid path: path URL seems to include query parameters: ${url}` - ); - } else { - const msg = `No service found for URL: ${url}`; - rlog.error(msg); - return this.toErrorResponse(404, msg); - } - } - // build the previous journal from the events - let decodedEntries: Message[] | null = decodeLambdaBody(event.body); - const journalBuilder = new InvocationBuilder(method); + let decodedEntries: Message[] | null = decodeLambdaBody(body); + const journalBuilder = new InvocationBuilder(handler); decodedEntries.forEach((e: Message) => journalBuilder.handleMessage(e)); const alreadyCompleted = decodedEntries.find( @@ -121,7 +97,7 @@ export class LambdaHandler { connection, invocation, ProtocolMode.REQUEST_RESPONSE, - method.method.keyedContext, + handler instanceof VirtualObjectHandler, invocation.inferLoggerContext({ AWSRequestId: context.awsRequestId, }) @@ -146,20 +122,17 @@ export class LambdaHandler { } private handleDiscovery(): APIGatewayProxyResult | APIGatewayProxyResultV2 { - // return discovery information - rlog.info( - "Answering discovery request. Announcing services: " + - JSON.stringify(this.discoveryResponse.services) - ); + const disocvery = this.endpoint.computeDiscovery(); + const discoveryJson = JSON.stringify(disocvery); + const body = Buffer.from(discoveryJson).toString("base64"); + return { headers: { - "content-type": "application/proto", + "content-type": "application/json", }, statusCode: 200, isBase64Encoded: true, - body: encodeResponse( - ServiceDiscoveryResponse.encode(this.discoveryResponse).finish() - ), + body, }; } diff --git a/src/invocation.ts b/src/invocation.ts index 0175fcd8..021ab537 100644 --- a/src/invocation.ts +++ b/src/invocation.ts @@ -12,7 +12,6 @@ /*eslint-disable @typescript-eslint/no-non-null-assertion*/ import { Message } from "./types/types"; -import { HostedGrpcServiceMethod } from "./types/grpc"; import { Failure, PollInputStreamEntryMessage, @@ -28,6 +27,7 @@ import { LocalStateStore } from "./local_state_store"; import { ensureError } from "./types/errors"; import { LoggerContext } from "./logger"; import { CompletablePromise } from "./utils/promises"; +import { ComponentHandler } from "./types/components"; enum State { ExpectingStart = 0, @@ -40,7 +40,7 @@ type InvocationValue = | { kind: "value"; value: Buffer } | { kind: "failure"; failure: Failure }; -export class InvocationBuilder implements RestateStreamConsumer { +export class InvocationBuilder implements RestateStreamConsumer { private readonly complete = new CompletablePromise(); private state: State = State.ExpectingStart; @@ -52,8 +52,9 @@ export class InvocationBuilder implements RestateStreamConsumer { private invocationValue?: InvocationValue = undefined; private nbEntriesToReplay?: number = undefined; private localStateStore?: LocalStateStore; + private userKey?: string; - constructor(private readonly method: HostedGrpcServiceMethod) {} + constructor(private readonly component: ComponentHandler) {} public handleMessage(m: Message): boolean { try { @@ -138,15 +139,16 @@ export class InvocationBuilder implements RestateStreamConsumer { return this.complete.promise; } - private handleStartMessage(m: StartMessage): InvocationBuilder { + private handleStartMessage(m: StartMessage): InvocationBuilder { this.nbEntriesToReplay = m.knownEntries; this.id = m.id; this.debugId = m.debugId; this.localStateStore = new LocalStateStore(m.partialState, m.stateMap); + this.userKey = m.key; return this; } - private addReplayEntry(m: Message): InvocationBuilder { + private addReplayEntry(m: Message): InvocationBuilder { // Will be retrieved when the user code reaches this point this.replayEntries.set(this.runtimeReplayIndex, m); this.incrementRuntimeReplayIndex(); @@ -161,33 +163,35 @@ export class InvocationBuilder implements RestateStreamConsumer { return this.state === State.Complete; } - public build(): Invocation { + public build(): Invocation { if (!this.isComplete()) { throw new Error( `Cannot build invocation. Not all data present: ${JSON.stringify(this)}` ); } return new Invocation( - this.method!, + this.component, this.id!, this.debugId!, this.nbEntriesToReplay!, this.replayEntries!, this.invocationValue!, - this.localStateStore! + this.localStateStore!, + this.userKey ); } } -export class Invocation { +export class Invocation { constructor( - public readonly method: HostedGrpcServiceMethod, + public readonly handler: ComponentHandler, public readonly id: Buffer, public readonly debugId: string, public readonly nbEntriesToReplay: number, public readonly replayEntries: Map, public readonly invocationValue: InvocationValue, - public readonly localStateStore: LocalStateStore + public readonly localStateStore: LocalStateStore, + public readonly userKey?: string ) {} public inferLoggerContext(additionalContext?: { @@ -195,9 +199,9 @@ export class Invocation { }): LoggerContext { return new LoggerContext( this.debugId, - this.method.pkg, - this.method.service, - this.method.method.name, + "", + this.handler.name(), + this.handler.component().name(), additionalContext ); } diff --git a/src/journal.ts b/src/journal.ts index e1c57454..12a09499 100644 --- a/src/journal.ts +++ b/src/journal.ts @@ -50,7 +50,7 @@ import { CompletablePromise } from "./utils/promises"; const RESOLVED = Promise.resolve(undefined); -export class Journal { +export class Journal { private state = NewExecutionState.REPLAYING; private userCodeJournalIndex = 0; @@ -59,7 +59,7 @@ export class Journal { // 0 = root promise of the method invocation private pendingJournalEntries = new Map(); - constructor(readonly invocation: Invocation) { + constructor(readonly invocation: Invocation) { const inputMessage = invocation.replayEntries.get(0); if ( !inputMessage || diff --git a/src/public_api.ts b/src/public_api.ts index 8a297d67..44bb9591 100644 --- a/src/public_api.ts +++ b/src/public_api.ts @@ -12,30 +12,23 @@ export { RestateContext, Context, - KeyedContext, - useContext, - useKeyedContext, + ObjectContext, ServiceApi, + ObjectApi, + serviceApi, + objectApi, CombineablePromise, Rand, - RestateGrpcChannel, } from "./context"; export { - router, - keyedRouter, - keyedEventHandler, - UnKeyedRouter, - KeyedRouter, - KeyedEventHandler, + service, + object, + Service, + VirtualObject, Client, SendClient, -} from "./types/router"; -export { - endpoint, - ServiceBundle, - ServiceOpts, - RestateEndpoint, -} from "./endpoint"; +} from "./types/rpc"; +export { endpoint, ServiceBundle, RestateEndpoint } from "./endpoint"; export * as RestateUtils from "./utils/public_utils"; export { ErrorCodes, @@ -43,11 +36,5 @@ export { TerminalError, TimeoutError, } from "./types/errors"; -export { Event } from "./types/types"; -export { - RestateConnection, - connection, - RestateConnectionOptions, -} from "./embedded/api"; export * as workflow from "./workflows/workflow"; export * as clients from "./clients/workflow_client"; diff --git a/src/state_machine.ts b/src/state_machine.ts index 8e8261e8..47247107 100644 --- a/src/state_machine.ts +++ b/src/state_machine.ts @@ -12,7 +12,6 @@ import * as p from "./types/protocol"; import { ContextImpl } from "./context_impl"; import { Connection, RestateStreamConsumer } from "./connection/connection"; -import { ProtocolMode } from "./generated/proto/discovery"; import { Message } from "./types/types"; import { createStateMachineConsole, @@ -54,9 +53,10 @@ import { PromiseType, } from "./promise_combinator_tracker"; import { CombinatorEntryMessage } from "./generated/proto/javascript"; +import { ProtocolMode } from "./types/discovery"; -export class StateMachine implements RestateStreamConsumer { - private journal: Journal; +export class StateMachine implements RestateStreamConsumer { + private journal: Journal; private restateContext: ContextImpl; private readonly invocationComplete = new CompletablePromise(); @@ -84,7 +84,7 @@ export class StateMachine implements RestateStreamConsumer { constructor( private readonly connection: Connection, - private readonly invocation: Invocation, + private readonly invocation: Invocation, private readonly protocolMode: ProtocolMode, keyedContext: boolean, loggerContext: LoggerContext, @@ -95,10 +95,11 @@ export class StateMachine implements RestateStreamConsumer { this.restateContext = new ContextImpl( this.invocation.id, - this.invocation.method.service, + this.invocation.handler.component().name(), // The console exposed by RestateContext filters logs in replay, while the internal one is based on the ENV variables. createRestateConsole(loggerContext, () => !this.journal.isReplaying()), keyedContext, + invocation.userKey, this ); this.journal = new Journal(this.invocation); @@ -339,7 +340,7 @@ export class StateMachine implements RestateStreamConsumer { switch (this.invocation.invocationValue.kind) { case "value": - resultBytes = this.invocation.method.invoke( + resultBytes = this.invocation.handler.invoke( this.restateContext, this.invocation.invocationValue.value ); diff --git a/src/types/components.ts b/src/types/components.ts new file mode 100644 index 00000000..5a6535d5 --- /dev/null +++ b/src/types/components.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH + * + * This file is part of the Restate SDK for Node.js/TypeScript, + * which is released under the MIT license. + * + * You can find a copy of the license in file LICENSE in the root + * directory of this repository or package, or at + * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Context, ObjectContext } from "../context"; +import * as d from "./discovery"; +import { ContextImpl } from "../context_impl"; +import { deserializeJson, serializeJson } from "../utils/serde"; + +// +// Interfaces +// +export interface Component { + name(): string; + handlerMatching(url: UrlPathComponents): ComponentHandler | undefined; + discovery(): d.Component; +} + +export interface ComponentHandler { + name(): string; + component(): Component; + invoke(context: ContextImpl, input: Uint8Array): Promise; +} + +// +// Service +// + +export type ServiceHandlerFunction = ( + ctx: Context, + param: I +) => Promise; + +export type ServiceHandlerOpts = { + name: string; + fn: ServiceHandlerFunction; +}; + +export class ServiceComponent implements Component { + private readonly handlers: Map = new Map(); + + constructor(private readonly componentName: string) {} + + name(): string { + return this.componentName; + } + + add(opts: ServiceHandlerOpts) { + const c = new ServiceHandler(opts, this); + this.handlers.set(opts.name, c); + } + + discovery(): d.Component { + const handlers: d.Handler[] = [...this.handlers.keys()].map((name) => { + return { + name, + }; + }); + + return { + fullyQualifiedComponentName: this.componentName, + componentType: d.ComponentType.SERVICE, + handlers, + }; + } + + handlerMatching(url: UrlPathComponents): ComponentHandler | undefined { + return this.handlers.get(url.handlerName); + } +} + +export class ServiceHandler implements ComponentHandler { + private readonly handlerName: string; + private readonly parent: ServiceComponent; + private readonly fn: ServiceHandlerFunction; + + constructor(opts: ServiceHandlerOpts, parent: ServiceComponent) { + this.handlerName = opts.name; + this.parent = parent; + this.fn = opts.fn; + } + + async invoke(context: ContextImpl, input: Uint8Array): Promise { + const req = deserializeJson(input); + const res = await this.fn(context, req); + return serializeJson(res); + } + + name(): string { + return this.handlerName; + } + component(): Component { + return this.parent; + } +} + +// +// Virtual Object +// + +export type VirtualObjectHandlerFunction = ( + ctx: ObjectContext, + param: I +) => Promise; + +export type VirtualObjectHandlerOpts = { + name: string; + fn: VirtualObjectHandlerFunction; +}; + +export class VritualObjectComponent implements Component { + private readonly opts: Map> = + new Map(); + + constructor(public readonly componentName: string) {} + + name(): string { + return this.componentName; + } + + add(opts: VirtualObjectHandlerOpts) { + this.opts.set(opts.name, opts as VirtualObjectHandlerOpts); + } + + discovery(): d.Component { + const handlers: d.Handler[] = [...this.opts.keys()].map((name) => { + return { + name, + }; + }); + + return { + fullyQualifiedComponentName: this.componentName, + componentType: d.ComponentType.VIRTUAL_OBJECT, + handlers, + }; + } + + handlerMatching(url: UrlPathComponents): ComponentHandler | undefined { + const opts = this.opts.get(url.handlerName); + if (!opts) { + return undefined; + } + return new VirtualObjectHandler(url.handlerName, this, opts); + } +} + +export class VirtualObjectHandler implements ComponentHandler { + constructor( + private readonly componentName: string, + private readonly parent: VritualObjectComponent, + private readonly opts: VirtualObjectHandlerOpts + ) {} + + name(): string { + return this.componentName; + } + component(): Component { + return this.parent; + } + + async invoke(context: ContextImpl, input: Uint8Array): Promise { + const req = deserializeJson(input); + const res = await this.opts.fn(context, req); + return serializeJson(res); + } +} + +export type UrlPathComponents = { + componentName: string; + handlerName: string; +}; + +export function parseUrlComponents( + urlPath?: string +): UrlPathComponents | "discovery" | undefined { + if (!urlPath) { + return undefined; + } + const fragments = urlPath.split("/"); + if (fragments.length >= 3 && fragments[fragments.length - 3] === "invoke") { + return { + componentName: fragments[fragments.length - 2], + handlerName: fragments[fragments.length - 1], + }; + } + if (fragments.length > 0 && fragments[fragments.length - 1] === "discover") { + return "discovery"; + } + return undefined; +} diff --git a/src/types/discovery.ts b/src/types/discovery.ts new file mode 100644 index 00000000..22a90e51 --- /dev/null +++ b/src/types/discovery.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH + * + * This file is part of the Restate SDK for Node.js/TypeScript, + * which is released under the MIT license. + * + * You can find a copy of the license in file LICENSE in the root + * directory of this repository or package, or at + * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE + */ + +export enum ProtocolMode { + BIDI_STREAM = "BIDI_STREAM", + REQUEST_RESPONSE = "REQUEST_RESPONSE", +} + +export enum ComponentType { + VIRTUAL_OBJECT = "VIRTUAL_OBJECT", + SERVICE = "SERVICE", +} + +export interface Handler { + name: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + inputSchema?: any; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + outputSchema?: any; +} + +export interface Component { + fullyQualifiedComponentName: string; + componentType: ComponentType; + handlers: Handler[]; +} + +export interface Deployment { + protocolMode: ProtocolMode; + minProtocolVersion: number; + maxProtocolVersion: number; + components: Component[]; +} diff --git a/src/types/grpc.ts b/src/types/grpc.ts deleted file mode 100644 index 33a0725c..00000000 --- a/src/types/grpc.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { Context } from "../context"; -import { FileDescriptorProto } from "ts-proto-descriptors"; - -export class GrpcServiceMethod { - constructor( - readonly name: string, // the gRPC name as defined in the .proto file - readonly localName: string, // the method name as defined in the class. - readonly keyedContext: boolean, // If the method expects a keyed context - readonly localFn: (instance: unknown, input: I) => Promise, // the actual function - readonly inputDecoder: (buf: Uint8Array) => I, // the protobuf decoder - readonly outputEncoder: (output: O) => Uint8Array // protobuf encoder - ) {} -} - -export class GrpcService { - constructor( - readonly name: string, - readonly packge: string, - readonly impl: object, - readonly methods: Array> - ) {} -} - -export class HostedGrpcServiceMethod { - constructor( - readonly instance: unknown, - readonly pkg: string, - readonly service: string, - readonly method: GrpcServiceMethod - ) {} - - // The end of an invoke is either a response (Uint8Array) or a SuspensionMessage - async invoke(context: Context, inBytes: Uint8Array): Promise { - const instanceWithContext = setContext(this.instance, context); - const input = this.method.inputDecoder(inBytes); - const result: O = await this.method.localFn(instanceWithContext, input); - return this.method.outputEncoder(result); - } -} - -function setContext(instance: T, context: Context): T { - // creates a *new*, per call object that shares all the properties that @instance has - // except '$$restate' which is a unique, per call pointer to a restate context. - // - // The following line create a new object, that its prototype is @instance. - // and that object has a $$restate property. - const wrapper = Object.create(instance as object, { - $$restate: { value: context }, - }); - return wrapper as T; -} - -// -// The following definitions are equivalent to the ones -// generated by the protoc ts plugin. -// we use them to traverse the FileDescriptor -// -/* eslint-disable @typescript-eslint/no-explicit-any */ -type ProtoMetaMessageOptions = { - options?: { [key: string]: any }; - fields?: { [key: string]: { [key: string]: any } }; - oneof?: { [key: string]: { [key: string]: any } }; - nested?: { [key: string]: ProtoMetaMessageOptions }; -}; - -export interface ProtoMetadata { - fileDescriptor: FileDescriptorProto; - references: { [key: string]: any }; - dependencies?: ProtoMetadata[]; - options?: { - options?: { [key: string]: any }; - services?: { - [key: string]: { - options?: { [key: string]: any }; - methods?: { [key: string]: { [key: string]: any } }; - }; - }; - messages?: { [key: string]: ProtoMetaMessageOptions }; - enums?: { - [key: string]: { - options?: { [key: string]: any }; - values?: { [key: string]: { [key: string]: any } }; - }; - }; - }; -} diff --git a/src/types/router.ts b/src/types/router.ts deleted file mode 100644 index 0deddf5b..00000000 --- a/src/types/router.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { CombineablePromise, Context, KeyedContext } from "../context"; -import { Event } from "./types"; - -// ----------- generics ------------------------------------------------------- - -type WithKeyArgument = F extends () => infer R ? (key: string) => R : F; - -type WithoutRpcContext = F extends ( - ctx: infer C extends Context, - ...args: infer P -) => infer R - ? (...args: P) => R - : never; - -export type Client = { - [K in keyof M as M[K] extends never ? never : K]: M[K] extends ( - ...args: infer P - ) => PromiseLike - ? (...args: P) => CombineablePromise - : never; -}; - -export type SendClient = { - [K in keyof M as M[K] extends never ? never : K]: M[K] extends ( - ...args: infer P - ) => any - ? (...args: P) => void - : never; -}; - -// ----------- unkeyed handlers ---------------------------------------------- - -export type UnKeyedHandler = F extends (ctx: Context) => Promise - ? F - : F extends (ctx: Context, input: any) => Promise - ? F - : never; - -export type UnKeyedRouterOpts = { - [K in keyof U]: U[K] extends UnKeyedHandler ? U[K] : never; -}; - -export type UnKeyedRouter = { - [K in keyof U]: U[K] extends UnKeyedHandler - ? WithoutRpcContext - : never; -}; - -export const router = (opts: UnKeyedRouterOpts): UnKeyedRouter => { - if (opts === undefined || opts === null) { - throw new Error("router must be defined"); - } - return opts as UnKeyedRouter; -}; - -// ----------- keyed handlers ---------------------------------------------- - -export type KeyedHandler = F extends (ctx: KeyedContext) => Promise - ? F - : F extends (ctx: KeyedContext, key: string, value: any) => Promise - ? F - : never; - -export type KeyedRouterOpts = { - [K in keyof U]: U[K] extends KeyedHandler | KeyedEventHandler - ? U[K] - : never; -}; - -export type KeyedRouter = { - [K in keyof U]: U[K] extends KeyedEventHandler - ? never - : U[K] extends KeyedHandler - ? WithKeyArgument> - : never; -}; - -export const keyedRouter = (opts: KeyedRouterOpts): KeyedRouter => { - if (opts === undefined || opts === null) { - throw new Error("router must be defined"); - } - return opts as KeyedRouter; -}; - -// ----------- event handlers ---------------------------------------------- - -export type KeyedEventHandler = U extends () => Promise - ? never - : U extends (ctx: KeyedContext) => Promise - ? never - : U extends (ctx: KeyedContext, event: Event) => Promise - ? U - : never; - -export const keyedEventHandler = (handler: KeyedEventHandler): H => { - return { eventHandler: true, handler: handler } as H; -}; - -export const isEventHandler = ( - handler: any -): handler is { - handler: (ctx: KeyedContext, event: Event) => Promise; -} => { - return typeof handler === "object" && handler["eventHandler"]; -}; diff --git a/src/types/rpc.ts b/src/types/rpc.ts new file mode 100644 index 00000000..d3ba485a --- /dev/null +++ b/src/types/rpc.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH + * + * This file is part of the Restate SDK for Node.js/TypeScript, + * which is released under the MIT license. + * + * You can find a copy of the license in file LICENSE in the root + * directory of this repository or package, or at + * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { CombineablePromise, Context, ObjectContext } from "../context"; + +// ----------- generics ------------------------------------------------------- + +type WithoutRpcContext = F extends ( + ctx: infer C extends Context, + ...args: infer P +) => infer R + ? (...args: P) => R + : never; + +export type Client = { + [K in keyof M as M[K] extends never ? never : K]: M[K] extends ( + ...args: infer P + ) => PromiseLike + ? (...args: P) => CombineablePromise + : never; +}; + +export type SendClient = { + [K in keyof M as M[K] extends never ? never : K]: M[K] extends ( + ...args: infer P + ) => any + ? (...args: P) => void + : never; +}; + +// ----------- unkeyed handlers ---------------------------------------------- + +export type ServiceHandler = F extends (ctx: Context) => Promise + ? F + : F extends (ctx: Context, input: any) => Promise + ? F + : never; + +export type ServiceOpts = { + [K in keyof U]: U[K] extends ServiceHandler ? U[K] : never; +}; + +export type Service = { + [K in keyof U]: U[K] extends ServiceHandler + ? WithoutRpcContext + : never; +}; + +export const service = (opts: ServiceOpts): Service => { + if (opts === undefined || opts === null) { + throw new Error("service must be defined"); + } + return opts as Service; +}; + +// ----------- keyed handlers ---------------------------------------------- + +export type ObjectHandler = F extends ( + ctx: ObjectContext, + param: any +) => Promise + ? F + : F extends (ctx: ObjectContext) => Promise + ? F + : never; + +export type ObjectOpts = { + [K in keyof U]: U[K] extends ObjectHandler ? U[K] : never; +}; + +export type VirtualObject = { + [K in keyof U]: U[K] extends ObjectHandler + ? WithoutRpcContext + : never; +}; + +export const object = (opts: ObjectOpts): VirtualObject => { + if (opts === undefined || opts === null) { + throw new Error("object options must be defined"); + } + return opts as VirtualObject; +}; diff --git a/src/types/types.ts b/src/types/types.ts index 437440c8..31f89a66 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -142,19 +142,3 @@ export class Header { return res; } } - -export class Event { - constructor( - readonly key: string, - readonly payload: Buffer, - readonly attributes: Record - ) {} - - public json(): T { - return JSON.parse(this.payload.toString("utf-8")) as T; - } - - public body(): Uint8Array { - return this.payload; - } -} diff --git a/src/utils/assumptions.ts b/src/utils/assumptions.ts deleted file mode 100644 index f11bf2de..00000000 --- a/src/utils/assumptions.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { RpcRequest } from "../generated/proto/dynrpc"; -import { TerminalError } from "../types/errors"; - -const ASSUME_UNKEYED_SINCE_FIRST_PARAM_NOT_STRING = 1; -const ASSUME_UNKEYED_SINCE_ZERO_ARGS = 2; -const ASSUME_KEYED_SINCE_TWO_ARGS_STR_AND_ANY = 3; -const ASSUME_EITHER_KEYED_OR_UNKEYED_ONE_STR_ARG = 4; - -export const requestFromArgs = (args: unknown[]): RpcRequest => { - switch (args.length) { - case 0: { - return RpcRequest.create({ - senderAssumes: ASSUME_UNKEYED_SINCE_ZERO_ARGS, - }); - } - case 1: { - if (typeof args[0] === "string") { - return RpcRequest.create({ - key: args[0], - senderAssumes: ASSUME_EITHER_KEYED_OR_UNKEYED_ONE_STR_ARG, - }); - } else { - return RpcRequest.create({ - request: args[0], - senderAssumes: ASSUME_UNKEYED_SINCE_FIRST_PARAM_NOT_STRING, - }); - } - } - case 2: { - if (typeof args[0] !== "string") { - throw new TerminalError( - `Two argument handlers are only possible for keyed handlers. Where the first argument must be of type 'string'.` - ); - } - return RpcRequest.create({ - key: args[0], - request: args[1], - senderAssumes: ASSUME_KEYED_SINCE_TWO_ARGS_STR_AND_ANY, - }); - } - default: { - throw new TerminalError("wrong number of arguments " + args.length); - } - } -}; - -/* eslint-disable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ -export type JsType = - | string - | number - | boolean - | Object - | null - | Array - | undefined; -/* eslint-enable @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any */ - -const requireThat = (condition: boolean, errorMessage: string) => { - if (!condition) { - throw new TerminalError(errorMessage); - } -}; - -export const verifyAssumptions = ( - isKeyed: boolean, - request: RpcRequest -): { key?: string; request?: JsType } => { - const assumption = request.senderAssumes ?? 0; - switch (assumption) { - case 0: { - // no assumption: this comes from an ingress. - const hasKeyProperty = - typeof request.key === "string" && request.key.length > 0; - if (isKeyed) { - requireThat( - hasKeyProperty, - "Trying to call a keyed handler with a missing or empty 'key' property." - ); - } else { - requireThat( - !hasKeyProperty, - "Trying to call a an unkeyed handler with a 'key' property. Did you mean using the 'request' property instead?" - ); - } - return { key: request.key, request: request.request }; - } - case ASSUME_UNKEYED_SINCE_FIRST_PARAM_NOT_STRING: { - requireThat( - !isKeyed, - "Trying to call a keyed handler with a missing key. This could happen if the first argument passed is not a 'string'." - ); - return { request: request.request }; - } - case ASSUME_UNKEYED_SINCE_ZERO_ARGS: { - requireThat( - !isKeyed, - "A keyed handler must at least be invoked with a single non empty string argument, that represents the key. 0 arguments given." - ); - return { request: request.request }; - } - case ASSUME_KEYED_SINCE_TWO_ARGS_STR_AND_ANY: { - requireThat( - isKeyed, - "An unkeyed handler must have at most 1 argument. two given." - ); - return { key: request.key, request: request.request }; - } - case ASSUME_EITHER_KEYED_OR_UNKEYED_ONE_STR_ARG: { - if (isKeyed) { - return { key: request.key }; - } - return { request: request.key }; - } - default: { - throw new TerminalError( - `Unknown assumption id ${assumption}. This indicates an incorrect (or involuntary) setting of the assumption property at the ingress request, or an SDK bug.` - ); - } - } -}; diff --git a/src/utils/serde.ts b/src/utils/serde.ts new file mode 100644 index 00000000..ccc683ff --- /dev/null +++ b/src/utils/serde.ts @@ -0,0 +1,20 @@ +import { Buffer } from "node:buffer"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function serializeJson(item: any | undefined): Uint8Array { + if (item === undefined) { + return Buffer.alloc(0); + } + const str = JSON.stringify(item); + return Buffer.from(str); +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function deserializeJson(buf: Uint8Array): any | undefined { + if (buf.length === 0) { + return undefined; + } + const b = Buffer.from(buf); + const str = b.toString("utf8"); + return JSON.parse(str); +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index da60713b..c1a2b325 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -47,30 +47,6 @@ export function jsonDeserialize(json: string): T { ) as T; } -// When using google.protobuf.Value in RPC handler responses, we want to roughly match the behaviour of JSON.stringify -// for example in converting Date objects to a UTC string -export function jsonSafeAny(key: string, value: any): any { - if ( - value !== undefined && - value !== null && - typeof value.toJSON == "function" - ) { - return value.toJSON(key) as any; - } else if (globalThis.Array.isArray(value)) { - // in place replace - value.forEach((_, i) => (value[i] = jsonSafeAny(i.toString(), value[i]))); - return value; - } else if (typeof value === "object") { - Object.keys(value).forEach((key) => { - value[key] = jsonSafeAny(key, value[key]); - }); - return value; - } else { - // primitive that doesn't have a toJSON method, with no children - return value; - } -} - export function formatMessageAsJson(obj: any): string { const newObj = { ...(obj as Record) }; for (const [key, value] of Object.entries(newObj)) { diff --git a/src/workflows/workflow.ts b/src/workflows/workflow.ts index 4514169b..b084f31a 100644 --- a/src/workflows/workflow.ts +++ b/src/workflows/workflow.ts @@ -32,9 +32,9 @@ export function workflow( ): WorkflowServices { // the state service manages all state and promises for us const stateServiceRouter = wss.workflowStateService; - const stateServiceApi: restate.ServiceApi = { - path: path + STATE_SERVICE_PATH_SUFFIX, - }; + const stateServiceApi = restate.objectApi( + path + STATE_SERVICE_PATH_SUFFIX + ); // the wrapper service manages life cycle, contexts, delegation to the state service const wrapperServiceRouter = wws.createWrapperService( @@ -46,8 +46,8 @@ export function workflow( return { api: { path } as restate.ServiceApi>, registerServices: (endpoint: restate.RestateEndpoint) => { - endpoint.bindKeyedRouter(stateServiceApi.path, stateServiceRouter); - endpoint.bindRouter(path, wrapperServiceRouter); + endpoint.object(stateServiceApi.path, stateServiceRouter); + endpoint.service(path, wrapperServiceRouter); }, } satisfies WorkflowServices; } @@ -126,7 +126,7 @@ export interface SharedWfContext { * This is a full context as for stateful durable keyed services, plus the * workflow-specific bits, like workflowID and durable promises. */ -export interface WfContext extends SharedWfContext, restate.KeyedContext {} +export interface WfContext extends SharedWfContext, restate.ObjectContext {} export enum LifecycleStatus { NOT_STARTED = "NOT_STARTED", diff --git a/src/workflows/workflow_state_service.ts b/src/workflows/workflow_state_service.ts index 7893d132..7fd606ce 100644 --- a/src/workflows/workflow_state_service.ts +++ b/src/workflows/workflow_state_service.ts @@ -24,9 +24,9 @@ export type ValueOrError = { error?: string; }; -export const workflowStateService = restate.keyedRouter({ +export const workflowStateService = restate.object({ startWorkflow: async ( - ctx: restate.KeyedContext + ctx: restate.ObjectContext ): Promise => { const status = (await ctx.get(LIFECYCLE_STATUS_STATE_NAME)) ?? @@ -43,8 +43,7 @@ export const workflowStateService = restate.keyedRouter({ }, finishOrFailWorkflow: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, result: ValueOrError ): Promise => { if (result.error === undefined && result.value === undefined) { @@ -73,7 +72,7 @@ export const workflowStateService = restate.keyedRouter({ ); }, - getStatus: async (ctx: restate.KeyedContext): Promise => { + getStatus: async (ctx: restate.ObjectContext): Promise => { return ( (await ctx.get(LIFECYCLE_STATUS_STATE_NAME)) ?? LifecycleStatus.NOT_STARTED @@ -81,8 +80,7 @@ export const workflowStateService = restate.keyedRouter({ }, completePromise: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, req: { promiseName: string; completion: ValueOrError } ): Promise => { // we don't accept writes after the workflow is done @@ -99,16 +97,14 @@ export const workflowStateService = restate.keyedRouter({ }, peekPromise: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, req: { promiseName: string } ): Promise | null> => { return peekPromise(ctx, PROMISE_STATE_PREFIX + req.promiseName); }, subscribePromise: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, req: { promiseName: string; awkId: string } ): Promise | null> => { return subscribePromise( @@ -120,14 +116,13 @@ export const workflowStateService = restate.keyedRouter({ }, getResult: async ( - ctx: restate.KeyedContext + ctx: restate.ObjectContext ): Promise | null> => { return peekPromise(ctx, RESULT_STATE_NAME); }, subscribeResult: async ( - ctx: restate.KeyedContext, - workflowId: string, + ctx: restate.ObjectContext, awkId: string ): Promise | null> => { const status = @@ -135,7 +130,7 @@ export const workflowStateService = restate.keyedRouter({ LifecycleStatus.NOT_STARTED; if (status === LifecycleStatus.NOT_STARTED) { throw new restate.TerminalError( - `Workflow with id '${workflowId}' does not exist.` + `Workflow with id '${ctx.key()}' does not exist.` ); } return subscribePromise( @@ -147,16 +142,14 @@ export const workflowStateService = restate.keyedRouter({ }, getState: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, stateName: string ): Promise => { return ctx.get(USER_STATE_PREFIX + stateName); }, setState: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, request: { stateName: string; value: T } ): Promise => { if (!request?.stateName) { @@ -179,20 +172,19 @@ export const workflowStateService = restate.keyedRouter({ }, clearState: async ( - ctx: restate.KeyedContext, - _workflowId: string, + ctx: restate.ObjectContext, stateName: string ): Promise => { ctx.clear(USER_STATE_PREFIX + stateName); }, - stateKeys: async (ctx: restate.KeyedContext): Promise> => { + stateKeys: async (ctx: restate.ObjectContext): Promise> => { return (await ctx.stateKeys()).filter((name) => name.startsWith(USER_STATE_PREFIX) ); }, - clearAllState: async (ctx: restate.KeyedContext): Promise => { + clearAllState: async (ctx: restate.ObjectContext): Promise => { const stateNames = (await ctx.stateKeys()).filter((name) => name.startsWith(USER_STATE_PREFIX) ); @@ -201,7 +193,7 @@ export const workflowStateService = restate.keyedRouter({ } }, - dispose: async (ctx: restate.KeyedContext): Promise => { + dispose: async (ctx: restate.ObjectContext): Promise => { ctx.clearAll(); }, }); @@ -211,7 +203,7 @@ export type api = typeof workflowStateService; // ---------------------------------------------------------------------------- async function completePromise( - ctx: restate.KeyedContext, + ctx: restate.ObjectContext, stateName: string, awakeableStateName: string, completion: ValueOrError @@ -253,7 +245,7 @@ async function completePromise( } async function subscribePromise( - ctx: restate.KeyedContext, + ctx: restate.ObjectContext, stateName: string, awakeableStateName: string, awakeableId: string @@ -287,13 +279,13 @@ async function subscribePromise( } async function peekPromise( - ctx: restate.KeyedContext, + ctx: restate.ObjectContext, stateName: string ): Promise | null> { return ctx.get>(stateName); } -async function checkIfRunning(ctx: restate.KeyedContext): Promise { +async function checkIfRunning(ctx: restate.ObjectContext): Promise { const status = await ctx.get(LIFECYCLE_STATUS_STATE_NAME); return status === LifecycleStatus.RUNNING; } diff --git a/src/workflows/workflow_wrapper_service.ts b/src/workflows/workflow_wrapper_service.ts index 15ce0dee..f0ad1c74 100644 --- a/src/workflows/workflow_wrapper_service.ts +++ b/src/workflows/workflow_wrapper_service.ts @@ -23,7 +23,7 @@ class SharedContextImpl implements wf.SharedWfContext { constructor( protected readonly ctx: restate.Context, protected readonly wfId: string, - protected readonly stateServiceApi: restate.ServiceApi + protected readonly stateServiceApi: restate.ObjectApi ) {} workflowId(): string { @@ -32,14 +32,14 @@ class SharedContextImpl implements wf.SharedWfContext { get(stateName: string): Promise { return this.ctx - .rpc(this.stateServiceApi) - .getState(this.wfId, stateName) as Promise; + .object(this.stateServiceApi, this.wfId) + .getState(stateName) as Promise; } promise(name: string): wf.DurablePromise { // Create the awakeable to complete const awk = this.ctx.awakeable(); - this.ctx.send(this.stateServiceApi).subscribePromise(this.wfId, { + this.ctx.objectSend(this.stateServiceApi, this.wfId).subscribePromise({ promiseName: name, awkId: awk.id, }); @@ -48,8 +48,8 @@ class SharedContextImpl implements wf.SharedWfContext { const peek = async (): Promise => { const result = await this.ctx - .rpc(this.stateServiceApi) - .peekPromise(this.wfId, { promiseName: name }); + .object(this.stateServiceApi, this.wfId) + .peekPromise({ promiseName: name }); if (result === null) { return null; @@ -63,14 +63,14 @@ class SharedContextImpl implements wf.SharedWfContext { const resolve = (value: T) => { const currentValue = value === undefined ? null : value; - this.ctx.send(this.stateServiceApi).completePromise(this.wfId, { + this.ctx.objectSend(this.stateServiceApi, this.wfId).completePromise({ promiseName: name, completion: { value: currentValue }, }); }; const reject = (errorMsg: string) => { - this.ctx.send(this.stateServiceApi).completePromise(this.wfId, { + this.ctx.objectSend(this.stateServiceApi, this.wfId).completePromise({ promiseName: name, completion: { error: errorMsg }, }); @@ -99,7 +99,7 @@ class ExclusiveContextImpl extends SharedContextImpl implements wf.WfContext { constructor( ctx: restate.Context, wfId: string, - stateServiceApi: restate.ServiceApi + stateServiceApi: restate.ObjectApi ) { super(ctx, wfId, stateServiceApi); this.id = ctx.id; @@ -108,30 +108,26 @@ class ExclusiveContextImpl extends SharedContextImpl implements wf.WfContext { this.console = ctx.console; } - grpcChannel(): restate.RestateGrpcChannel { - return this.ctx.grpcChannel(); - } - set(stateName: string, value: T): void { if (value === undefined || value === null) { throw new restate.TerminalError("Cannot set state to null or undefined"); } this.ctx - .send(this.stateServiceApi) - .setState(this.wfId, { stateName, value }); + .objectSend(this.stateServiceApi, this.wfId) + .setState({ stateName, value }); } clear(stateName: string): void { - this.ctx.send(this.stateServiceApi).clearState(this.wfId, stateName); + this.ctx.objectSend(this.stateServiceApi, this.wfId).clearState(stateName); } stateKeys(): Promise> { - return this.ctx.rpc(this.stateServiceApi).stateKeys(this.wfId); + return this.ctx.object(this.stateServiceApi, this.wfId).stateKeys(); } clearAll(): void { - this.ctx.send(this.stateServiceApi).clearAllState(this.wfId); + this.ctx.objectSend(this.stateServiceApi, this.wfId).clearAllState(); } sideEffect( @@ -155,17 +151,38 @@ class ExclusiveContextImpl extends SharedContextImpl implements wf.WfContext { return this.ctx.sleep(millis); } - rpc(opts: restate.ServiceApi): restate.Client { - return this.ctx.rpc(opts); + key(): string { + const kctx = this.ctx as restate.ObjectContext; + return kctx.key(); + } + + service(opts: restate.ServiceApi): restate.Client { + return this.ctx.service(opts); + } + object(opts: restate.ServiceApi, key: string): restate.Client { + return this.ctx.object(opts, key); } - send(opts: restate.ServiceApi): restate.SendClient { - return this.ctx.send(opts); + objectSend( + opts: restate.ServiceApi, + key: string + ): restate.SendClient { + return this.ctx.objectSend(opts, key); + } + serviceSend(opts: restate.ServiceApi): restate.SendClient { + return this.ctx.serviceSend(opts); + } + objectSendDelayed( + opts: restate.ServiceApi, + delay: number, + key: string + ): restate.SendClient { + return this.ctx.objectSendDelayed(opts, delay, key); } - sendDelayed( + serviceSendDelayed( opts: restate.ServiceApi, delay: number ): restate.SendClient { - return this.ctx.sendDelayed(opts, delay); + return this.ctx.serviceSendDelayed(opts, delay); } } @@ -176,7 +193,7 @@ class ExclusiveContextImpl extends SharedContextImpl implements wf.WfContext { export function createWrapperService( workflow: wf.Workflow, path: string, - stateServiceApi: restate.ServiceApi + stateServiceApi: restate.ObjectApi ) { const wrapperService = { submit: async ( @@ -186,10 +203,10 @@ export function createWrapperService( checkRequestAndWorkflowId(request); const started = await ctx - .rpc(stateServiceApi) - .startWorkflow(request.workflowId); + .object(stateServiceApi, request.workflowId) + .startWorkflow(); if (started === wf.WorkflowStartResult.STARTED) { - ctx.send(wrapperServiceApi).run(request); + ctx.service(wrapperServiceApi).run(request); } return started; }, @@ -209,19 +226,23 @@ export function createWrapperService( const result = await workflow.run(wfCtx, request); const resultValue = result !== undefined ? result : {}; await ctx - .rpc(stateServiceApi) - .finishOrFailWorkflow(request.workflowId, { value: resultValue }); + .object(stateServiceApi, request.workflowId) + .finishOrFailWorkflow({ value: resultValue }); return result; } catch (err) { const msg = stringifyError(err); await ctx - .rpc(stateServiceApi) - .finishOrFailWorkflow(request.workflowId, { error: msg }); + .object(stateServiceApi, request.workflowId) + .finishOrFailWorkflow({ error: msg }); throw err; } finally { ctx - .sendDelayed(stateServiceApi, DEFAULT_RETENTION_PERIOD) - .dispose(request.workflowId); + .objectSendDelayed( + stateServiceApi, + DEFAULT_RETENTION_PERIOD, + request.workflowId + ) + .dispose(); } }, @@ -233,8 +254,8 @@ export function createWrapperService( const awakeable = ctx.awakeable(); await ctx - .rpc(stateServiceApi) - .subscribeResult(request.workflowId, awakeable.id); + .object(stateServiceApi, request.workflowId) + .subscribeResult(awakeable.id); return awakeable.promise; }, @@ -243,7 +264,7 @@ export function createWrapperService( request: wf.WorkflowRequest ): Promise => { checkRequestAndWorkflowId(request); - return ctx.rpc(stateServiceApi).getStatus(request.workflowId); + return ctx.object(stateServiceApi, request.workflowId).getStatus(); }, }; @@ -281,7 +302,7 @@ export function createWrapperService( (wrapperService as any)[route] = wrappingHandler; } - const wrapperServiceRouter = restate.router(wrapperService); + const wrapperServiceRouter = restate.service(wrapperService); const wrapperServiceApi: restate.ServiceApi = { path, }; diff --git a/test/awakeable.test.ts b/test/awakeable.test.ts index 3bafcb79..3f33c9a6 100644 --- a/test/awakeable.test.ts +++ b/test/awakeable.test.ts @@ -11,7 +11,6 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; import { awakeableMessage, checkJournalMismatchError, @@ -28,13 +27,12 @@ import { suspensionMessage, END_MESSAGE, } from "./protoutils"; -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; -import { ProtocolMode } from "../src/generated/proto/discovery"; -class AwakeableGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); +import { TestDriver, TestResponse, TestGreeter } from "./testdriver"; +import { ProtocolMode } from "../src/types/discovery"; +class AwakeableGreeter implements TestGreeter { + async greet(ctx: restate.ObjectContext): Promise { const awakeable = ctx.awakeable(); const result = await awakeable.promise; @@ -48,7 +46,7 @@ class AwakeableGreeter implements TestGreeter { describe("AwakeableGreeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), ]).run(); @@ -58,7 +56,10 @@ describe("AwakeableGreeter", () => { it("sends message to runtime for request-response case", async () => { const result = await new TestDriver( new AwakeableGreeter(), - [startMessage(1), inputMessage(greetRequest("Till"))], + [ + startMessage({ knownEntries: 1, key: "Till" }), + inputMessage(greetRequest("Till")), + ], ProtocolMode.REQUEST_RESPONSE ).run(); @@ -67,7 +68,7 @@ describe("AwakeableGreeter", () => { it("handles completion with value", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify("Francesco")), ]).run(); @@ -81,7 +82,7 @@ describe("AwakeableGreeter", () => { it("handles completion with empty string value", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify("")), ]).run(); @@ -95,7 +96,7 @@ describe("AwakeableGreeter", () => { it("handles completion with empty object value", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify({})), ]).run(); @@ -111,7 +112,7 @@ describe("AwakeableGreeter", () => { it("handles completion with failure", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), completionMessage( 1, @@ -129,7 +130,7 @@ describe("AwakeableGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), awakeableMessage("Francesco"), ]).run(); @@ -142,7 +143,7 @@ describe("AwakeableGreeter", () => { it("handles replay with failure", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), awakeableMessage(undefined, failure("Something went wrong")), ]).run(); @@ -154,7 +155,7 @@ describe("AwakeableGreeter", () => { it("fails on journal mismatch. Completed with CompleteAwakeable during replay.", async () => { const result = await new TestDriver(new AwakeableGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), resolveAwakeableMessage("awakeable-1", "hello"), // should have been an awakeableMessage ]).run(); @@ -165,9 +166,7 @@ describe("AwakeableGreeter", () => { }); class AwakeableNull implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const awakeable = ctx.awakeable(); await awakeable.promise; @@ -181,7 +180,7 @@ class AwakeableNull implements TestGreeter { describe("AwakeableNull", () => { it("handles completion with null value", async () => { const result = await new TestDriver(new AwakeableNull(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify(null)), ]).run(); diff --git a/test/complete_awakeable.test.ts b/test/complete_awakeable.test.ts index 620e718b..df66c2b6 100644 --- a/test/complete_awakeable.test.ts +++ b/test/complete_awakeable.test.ts @@ -9,7 +9,6 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; import * as restate from "../src/public_api"; import { checkJournalMismatchError, @@ -25,25 +24,23 @@ import { END_MESSAGE, } from "./protoutils"; import { describe, expect } from "@jest/globals"; -import { TestDriver } from "./testdriver"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; class ResolveAwakeableGreeter implements TestGreeter { constructor(readonly payload: string | undefined) {} - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const awakeableIdentifier = getAwakeableId(1); ctx.resolveAwakeable(awakeableIdentifier, this.payload); - return TestResponse.create({ greeting: `Hello` }); + return { greeting: `Hello` }; } } describe("ResolveAwakeableGreeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter("hello"), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), ]).run(); @@ -57,7 +54,7 @@ describe("ResolveAwakeableGreeter", () => { it("resolve with undefined value", async () => { const result = await new TestDriver( new ResolveAwakeableGreeter(undefined), - [startMessage(), inputMessage(greetRequest("Till"))] + [startMessage({}), inputMessage(greetRequest("Till"))] ).run(); expect(result).toStrictEqual([ @@ -69,7 +66,7 @@ describe("ResolveAwakeableGreeter", () => { it("sends message to runtime for empty string", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter(""), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), ]).run(); @@ -82,7 +79,7 @@ describe("ResolveAwakeableGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter("hello"), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), resolveAwakeableMessage(getAwakeableId(1), "hello"), ]).run(); @@ -95,7 +92,7 @@ describe("ResolveAwakeableGreeter", () => { it("handles replay with value empty string", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter(""), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), resolveAwakeableMessage(getAwakeableId(1), ""), ]).run(); @@ -108,7 +105,7 @@ describe("ResolveAwakeableGreeter", () => { it("fails on journal mismatch. Completed with invoke during replay.", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter("hello"), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), invokeMessage( "test.TestGreeter", @@ -124,7 +121,7 @@ describe("ResolveAwakeableGreeter", () => { it("fails on journal mismatch. Completed with wrong id.", async () => { const result = await new TestDriver(new ResolveAwakeableGreeter("hello"), [ - startMessage(2), + startMessage({ knownEntries: 2 }), inputMessage(greetRequest("Till")), resolveAwakeableMessage( "1234", // this should have been getAwakeableId(1) @@ -140,13 +137,11 @@ describe("ResolveAwakeableGreeter", () => { class RejectAwakeableGreeter implements TestGreeter { constructor(readonly reason: string) {} - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const awakeableIdentifier = getAwakeableId(1); ctx.rejectAwakeable(awakeableIdentifier, this.reason); - return TestResponse.create({ greeting: `Hello` }); + return { greeting: `Hello` }; } } @@ -154,7 +149,7 @@ describe("RejectAwakeableGreeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver( new RejectAwakeableGreeter("my bad error"), - [startMessage(), inputMessage(greetRequest("Till"))] + [startMessage({}), inputMessage(greetRequest("Till"))] ).run(); expect(result).toStrictEqual([ diff --git a/test/eager_state.test.ts b/test/eager_state.test.ts index c611fbd5..59ff0508 100644 --- a/test/eager_state.test.ts +++ b/test/eager_state.test.ts @@ -9,13 +9,13 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ +import * as restate from "../src/public_api"; import { + TestDriver, + TestResponse, TestGreeter, TestRequest, - TestResponse, -} from "../src/generated/proto/test"; -import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; +} from "./testdriver"; import { CLEAR_ALL_STATE_ENTRY_MESSAGE, clearStateMessage, @@ -33,18 +33,16 @@ import { startMessage, suspensionMessage, } from "./protoutils"; -import { ProtocolMode } from "../src/generated/proto/discovery"; +import { ProtocolMode } from "../src/types/discovery"; const input = inputMessage(greetRequest("Two")); const COMPLETE_STATE = false; class GetEmpty implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const stateIsEmpty = (await ctx.get("STATE")) === null; - return TestResponse.create({ greeting: `${stateIsEmpty}` }); + return { greeting: `${stateIsEmpty}` }; } } @@ -52,7 +50,7 @@ describe("GetEmpty", () => { it("handles complete state without key present", async () => { const result = await new TestDriver( new GetEmpty(), - [startMessage(1, COMPLETE_STATE), input], + [startMessage({ knownEntries: 1, partialState: COMPLETE_STATE }), input], ProtocolMode.BIDI_STREAM ).run(); @@ -66,7 +64,7 @@ describe("GetEmpty", () => { it("handles partial state without key present ", async () => { const result = await new TestDriver( new GetEmpty(), - [startMessage(1), input], + [startMessage({ knownEntries: 1 }), input], ProtocolMode.BIDI_STREAM ).run(); @@ -79,7 +77,11 @@ describe("GetEmpty", () => { it("handles replay of partial state", async () => { const result = await new TestDriver( new GetEmpty(), - [startMessage(2), input, getStateMessage("STATE", undefined, true)], + [ + startMessage({ knownEntries: 2 }), + input, + getStateMessage("STATE", undefined, true), + ], ProtocolMode.BIDI_STREAM ).run(); @@ -91,9 +93,7 @@ describe("GetEmpty", () => { }); class Get implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const state = (await ctx.get("STATE")) || "nothing"; return TestResponse.create({ greeting: state }); @@ -104,7 +104,14 @@ describe("Get", () => { it("handles complete state with key present", async () => { const result = await new TestDriver( new Get(), - [startMessage(1, COMPLETE_STATE, [keyVal("STATE", "One")]), input], + [ + startMessage({ + knownEntries: 1, + partialState: COMPLETE_STATE, + state: [keyVal("STATE", "One")], + }), + input, + ], ProtocolMode.BIDI_STREAM ).run(); @@ -118,7 +125,10 @@ describe("Get", () => { it("handles partial state with key present ", async () => { const result = await new TestDriver( new Get(), - [startMessage(1, undefined, [keyVal("STATE", "One")]), input], + [ + startMessage({ knownEntries: 1, state: [keyVal("STATE", "One")] }), + input, + ], ProtocolMode.BIDI_STREAM ).run(); @@ -132,7 +142,7 @@ describe("Get", () => { it("handles partial state without key present", async () => { const result = await new TestDriver( new Get(), - [startMessage(2), input], + [startMessage({ knownEntries: 2 }), input], ProtocolMode.BIDI_STREAM ).run(); @@ -144,9 +154,10 @@ describe("Get", () => { }); class GetAppendAndGet implements TestGreeter { - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - + async greet( + ctx: restate.ObjectContext, + request: TestRequest + ): Promise { const oldState = (await ctx.get("STATE")) || "nothing"; ctx.set("STATE", oldState + request.name); const newState = (await ctx.get("STATE")) || "nothing"; @@ -159,7 +170,14 @@ describe("GetAppendAndGet", () => { it("handles complete state with key present", async () => { const result = await new TestDriver( new GetAppendAndGet(), - [startMessage(1, COMPLETE_STATE, [keyVal("STATE", "One")]), input], + [ + startMessage({ + knownEntries: 1, + partialState: COMPLETE_STATE, + state: [keyVal("STATE", "One")], + }), + input, + ], ProtocolMode.BIDI_STREAM ).run(); @@ -175,7 +193,11 @@ describe("GetAppendAndGet", () => { it("handles partial state with key not present ", async () => { const result = await new TestDriver( new GetAppendAndGet(), - [startMessage(1), input, completionMessage(1, JSON.stringify("One"))], + [ + startMessage({ knownEntries: 1 }), + input, + completionMessage(1, JSON.stringify("One")), + ], ProtocolMode.BIDI_STREAM ).run(); @@ -190,9 +212,7 @@ describe("GetAppendAndGet", () => { }); class GetClearAndGet implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const oldState = (await ctx.get("STATE")) || "not-nothing"; ctx.clear("STATE"); const newState = (await ctx.get("STATE")) || "nothing"; @@ -205,7 +225,14 @@ describe("GetClearAndGet", () => { it("handles complete state with key present", async () => { const result = await new TestDriver( new GetClearAndGet(), - [startMessage(1, COMPLETE_STATE, [keyVal("STATE", "One")]), input], + [ + startMessage({ + knownEntries: 1, + partialState: COMPLETE_STATE, + state: [keyVal("STATE", "One")], + }), + input, + ], ProtocolMode.BIDI_STREAM ).run(); @@ -221,7 +248,11 @@ describe("GetClearAndGet", () => { it("handles partial state with key not present ", async () => { const result = await new TestDriver( new GetClearAndGet(), - [startMessage(1), input, completionMessage(1, JSON.stringify("One"))], + [ + startMessage({ knownEntries: 1 }), + input, + completionMessage(1, JSON.stringify("One")), + ], ProtocolMode.BIDI_STREAM ).run(); @@ -236,9 +267,7 @@ describe("GetClearAndGet", () => { }); class MultipleGet implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const state = (await ctx.get("STATE")) || "nothing"; const state1 = (await ctx.get("STATE")) || "nothing"; const state2 = (await ctx.get("STATE")) || "nothing"; @@ -253,7 +282,7 @@ describe("MultipleGet", () => { it("handles multiple gets with partial state not present with completion", async () => { const result = await new TestDriver( new MultipleGet(), - [startMessage(), input, completionMessage(1, JSON.stringify("One"))], + [startMessage({}), input, completionMessage(1, JSON.stringify("One"))], ProtocolMode.BIDI_STREAM ).run(); @@ -270,7 +299,7 @@ describe("MultipleGet", () => { it("handles multiple gets with partial state not present with replay", async () => { const result = await new TestDriver( new MultipleGet(), - [startMessage(), input, getStateMessage("STATE", "One")], + [startMessage({}), input, getStateMessage("STATE", "One")], ProtocolMode.BIDI_STREAM ).run(); @@ -285,9 +314,7 @@ describe("MultipleGet", () => { }); class GetClearAllThenGet implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const state1 = (await ctx.get("STATE")) || "nothing"; const state2 = (await ctx.get("ANOTHER_STATE")) || "nothing"; @@ -307,8 +334,12 @@ describe("GetClearAllThenGet", () => { const result = await new TestDriver( new GetClearAllThenGet(), [ - startMessage(1, false, [keyVal("STATE", "One")]), - inputMessage(greetRequest("")), + startMessage({ + knownEntries: 1, + partialState: false, + state: [keyVal("STATE", "One")], + }), + inputMessage(greetRequest("bob")), ], ProtocolMode.REQUEST_RESPONSE ).run(); @@ -329,8 +360,12 @@ describe("GetClearAllThenGet", () => { it("with lazy state in the eager state map", async () => { const result = await new TestDriver(new GetClearAllThenGet(), [ - startMessage(1, true, [keyVal("STATE", "One")]), - inputMessage(greetRequest("")), + startMessage({ + knownEntries: 1, + partialState: true, + state: [keyVal("STATE", "One")], + }), + inputMessage(greetRequest("bob")), completionMessageWithEmpty(2), ]).run(); diff --git a/test/get_and_set_state.test.ts b/test/get_and_set_state.test.ts index 392a294c..d34abacc 100644 --- a/test/get_and_set_state.test.ts +++ b/test/get_and_set_state.test.ts @@ -11,7 +11,13 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; + +import { + TestDriver, + TestGreeter, + TestRequest, + TestResponse, +} from "./testdriver"; import { checkJournalMismatchError, clearStateMessage, @@ -26,17 +32,13 @@ import { startMessage, suspensionMessage, } from "./protoutils"; -import { - TestGreeter, - TestRequest, - TestResponse, -} from "../src/generated/proto/test"; -import { ProtocolMode } from "../src/generated/proto/discovery"; +import { ProtocolMode } from "../src/types/discovery"; class GetAndSetGreeter implements TestGreeter { - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - + async greet( + ctx: restate.ObjectContext, + request: TestRequest + ): Promise { // state const state = (await ctx.get("STATE")) || "nobody"; @@ -49,7 +51,7 @@ class GetAndSetGreeter implements TestGreeter { describe("GetAndSetGreeter", () => { it("sends get and set state message to the runtime", async () => { const result = await new TestDriver(new GetAndSetGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify("Pete")), ]).run(); @@ -77,7 +79,7 @@ describe("GetAndSetGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new GetAndSetGreeter(), [ - startMessage(), + startMessage({ key: "Till" }), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), setStateMessage("STATE", "Till"), @@ -162,9 +164,10 @@ describe("GetAndSetGreeter", () => { }); class ClearStateGreeter implements TestGreeter { - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - + async greet( + ctx: restate.ObjectContext, + request: TestRequest + ): Promise { // state const state = (await ctx.get("STATE")) || "nobody"; @@ -194,7 +197,7 @@ describe("ClearState", () => { it("handles replay", async () => { const result = await new TestDriver(new ClearStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), setStateMessage("STATE", "Till"), @@ -209,7 +212,7 @@ describe("ClearState", () => { it("fails on journal mismatch. ClearState completed with getState.", async () => { const result = await new TestDriver(new ClearStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), setStateMessage("STATE", "Till"), @@ -222,7 +225,7 @@ describe("ClearState", () => { it("fails on journal mismatch. ClearState completed with setState.", async () => { const result = await new TestDriver(new ClearStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), setStateMessage("STATE", "Till"), @@ -235,7 +238,7 @@ describe("ClearState", () => { it("fails on journal mismatch. ClearState completed with different key.", async () => { const result = await new TestDriver(new ClearStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), setStateMessage("STATE", "Till"), @@ -253,9 +256,7 @@ enum OrderStatus { } class GetAndSetEnumGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { // state const oldState = await ctx.get("STATE"); @@ -270,7 +271,7 @@ class GetAndSetEnumGreeter implements TestGreeter { describe("GetAndSetEnumGreeter", () => { it("handles replays with value.", async () => { const result = await new TestDriver(new GetAndSetEnumGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", OrderStatus.DELIVERED), setStateMessage("STATE", OrderStatus.ORDERED), @@ -285,7 +286,7 @@ describe("GetAndSetEnumGreeter", () => { it("handles replays with value. First empty state, then enum state.", async () => { const result = await new TestDriver(new GetAndSetEnumGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", undefined, true), setStateMessage("STATE", OrderStatus.ORDERED), diff --git a/test/get_state.test.ts b/test/get_state.test.ts index 5163e458..9bc2632c 100644 --- a/test/get_state.test.ts +++ b/test/get_state.test.ts @@ -11,7 +11,6 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; import { checkJournalMismatchError, checkTerminalError, @@ -27,13 +26,11 @@ import { startMessage, suspensionMessage, } from "./protoutils"; -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; -import { ProtocolMode } from "../src/generated/proto/discovery"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; +import { ProtocolMode } from "../src/types/discovery"; class GetStringStateGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { // state let state = await ctx.get("STATE"); if (state === null) { @@ -47,7 +44,7 @@ class GetStringStateGreeter implements TestGreeter { describe("GetStringStateGreeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), ]).run(); @@ -60,7 +57,7 @@ describe("GetStringStateGreeter", () => { it("sends message to runtime for request-response mode", async () => { const result = await new TestDriver( new GetStringStateGreeter(), - [startMessage(1), inputMessage(greetRequest("Till"))], + [startMessage({ knownEntries: 1 }), inputMessage(greetRequest("Till"))], ProtocolMode.REQUEST_RESPONSE ).run(); @@ -72,7 +69,7 @@ describe("GetStringStateGreeter", () => { it("handles completion with value", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, JSON.stringify("Francesco")), ]).run(); @@ -86,7 +83,7 @@ describe("GetStringStateGreeter", () => { it("handles completion with empty", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, undefined, true), ]).run(); @@ -100,7 +97,7 @@ describe("GetStringStateGreeter", () => { it("handles completion with empty string", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, Buffer.from(JSON.stringify(""))), ]).run(); @@ -114,7 +111,7 @@ describe("GetStringStateGreeter", () => { it("handles completion with failure", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, undefined, undefined, failure("Canceled")), ]).run(); @@ -127,7 +124,7 @@ describe("GetStringStateGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", "Francesco"), ]).run(); @@ -140,7 +137,7 @@ describe("GetStringStateGreeter", () => { it("handles replay with empty", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", undefined, true), ]).run(); @@ -153,7 +150,7 @@ describe("GetStringStateGreeter", () => { it("handles replay with failure", async () => { const result = await new TestDriver(new GetStringStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", undefined, undefined, failure("Canceled")), ]).run(); @@ -165,9 +162,7 @@ describe("GetStringStateGreeter", () => { }); class GetNumberStateGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { // state const state = await ctx.get("STATE"); @@ -178,7 +173,7 @@ class GetNumberStateGreeter implements TestGreeter { describe("GetNumberStateGreeter", () => { it("sends message to the runtime", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), ]).run(); @@ -190,7 +185,7 @@ describe("GetNumberStateGreeter", () => { it("handles completion with value", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, Buffer.from(JSON.stringify(70))), ]).run(); @@ -204,7 +199,7 @@ describe("GetNumberStateGreeter", () => { it("handles completion with value 0", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, Buffer.from(JSON.stringify(0))), ]).run(); @@ -218,7 +213,7 @@ describe("GetNumberStateGreeter", () => { it("handles completion with empty", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, undefined, true), ]).run(); @@ -232,7 +227,7 @@ describe("GetNumberStateGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", 70), ]).run(); @@ -245,7 +240,7 @@ describe("GetNumberStateGreeter", () => { it("handles replay with value 0", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", 0), ]).run(); @@ -258,7 +253,7 @@ describe("GetNumberStateGreeter", () => { it("handles replay with empty", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", undefined, true), ]).run(); @@ -271,7 +266,7 @@ describe("GetNumberStateGreeter", () => { it("fails on journal mismatch. Completed with SetStateMessage.", async () => { const result = await new TestDriver(new GetNumberStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), setStateMessage("STATE", 0), ]).run(); @@ -282,9 +277,7 @@ describe("GetNumberStateGreeter", () => { }); class GetNumberListStateGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { // state const state = await ctx.get("STATE"); if (state) { @@ -300,7 +293,7 @@ class GetNumberListStateGreeter implements TestGreeter { describe("GetNumberListStateGreeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver(new GetNumberListStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), ]).run(); @@ -312,7 +305,7 @@ describe("GetNumberListStateGreeter", () => { it("handles completion with value", async () => { const result = await new TestDriver(new GetNumberListStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, Buffer.from(JSON.stringify([5, 4]))), ]).run(); @@ -326,7 +319,7 @@ describe("GetNumberListStateGreeter", () => { it("handles completion with value empty list", async () => { const result = await new TestDriver(new GetNumberListStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, Buffer.from(JSON.stringify([]))), ]).run(); @@ -344,7 +337,7 @@ describe("GetNumberListStateGreeter", () => { it("handles completion with empty", async () => { const result = await new TestDriver(new GetNumberListStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), completionMessage(1, undefined, true), ]).run(); @@ -358,7 +351,7 @@ describe("GetNumberListStateGreeter", () => { it("handles replay with value", async () => { const result = await new TestDriver(new GetNumberListStateGreeter(), [ - startMessage(), + startMessage({}), inputMessage(greetRequest("Till")), getStateMessage("STATE", [5, 4]), ]).run(); diff --git a/test/lambda.test.ts b/test/lambda.test.ts index 921e928a..8df7db15 100644 --- a/test/lambda.test.ts +++ b/test/lambda.test.ts @@ -11,17 +11,7 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { - protoMetadata, - TestEmpty, - TestGreeter, - TestResponse, -} from "../src/generated/proto/test"; import { APIGatewayProxyEvent } from "aws-lambda"; -import { - ServiceDiscoveryRequest, - ServiceDiscoveryResponse, -} from "../src/generated/proto/discovery"; import { encodeMessage } from "../src/io/encoder"; import { Message } from "../src/types/types"; import { @@ -35,12 +25,14 @@ import { startMessage, } from "./protoutils"; import { decodeLambdaBody } from "../src/io/decoder"; -import { Empty } from "../src/generated/google/protobuf/empty"; +import { TestGreeter, TestResponse } from "./testdriver"; +import { ComponentType, Deployment } from "../src/types/discovery"; class LambdaGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet( + ctx: restate.ObjectContext + /*req: TestRequest */ + ): Promise { // state const state = (await ctx.get("STATE")) || "nobody"; @@ -51,7 +43,7 @@ class LambdaGreeter implements TestGreeter { describe("Lambda: decodeMessage", () => { it("returns a list of decoded messages", async () => { const messages: Message[] = [ - startMessage(2), + startMessage({ knownEntries: 2 }), inputMessage(greetRequest("Pete")), getStateMessage("STATE", "Foo"), ]; @@ -64,12 +56,8 @@ describe("Lambda: decodeMessage", () => { it("returns a list of decoded messages when last message body is empty", async () => { const messages: Message[] = [ - startMessage(2), - inputMessage( - TestEmpty.encode( - TestEmpty.create({ greeting: Empty.create({}) }) - ).finish() - ), + startMessage({ knownEntries: 2 }), + inputMessage(Buffer.alloc(0)), ]; const serializedMsgs = serializeMessages(messages); @@ -80,7 +68,7 @@ describe("Lambda: decodeMessage", () => { it("returns a list of decoded messages when last message body is empty", async () => { const messages: Message[] = [ - startMessage(2), + startMessage({ knownEntries: 2 }), inputMessage(greetRequest("Pete")), awakeableMessage(), ]; @@ -93,7 +81,7 @@ describe("Lambda: decodeMessage", () => { it("fails on an invalid input message with random signs at end of message", async () => { const messages: Message[] = [ - startMessage(2), + startMessage({ knownEntries: 2 }), inputMessage(greetRequest("Pete")), getStateMessage("STATE", "Fooo"), ]; @@ -108,7 +96,7 @@ describe("Lambda: decodeMessage", () => { it("fails on an invalid input message with random signs in front of message", async () => { const messages: Message[] = [ - startMessage(2), + startMessage({ knownEntries: 2 }), inputMessage(greetRequest("Pete")), getStateMessage("STATE", "Fooo"), ]; @@ -127,10 +115,10 @@ describe("LambdaGreeter", () => { const handler = getTestHandler(); const request = apiProxyGatewayEvent( - "/invoke/test.TestGreeter/Greet", + "/invoke/greeter/greet", "application/restate", serializeMessages([ - startMessage(2), + startMessage({ knownEntries: 2, key: "Pete" }), inputMessage(greetRequest("Pete")), getStateMessage("STATE", "Foo"), ]) @@ -148,74 +136,55 @@ describe("LambdaGreeter", () => { ]); }); - it("fails on query parameters in path", async () => { - const handler = getTestHandler(); - - const request = apiProxyGatewayEvent( - "/invoke/test.TestGreeter/Greet?count=5", - "application/restate", - serializeMessages([startMessage(1), inputMessage(greetRequest("Pete"))]) - ); - const result = await handler(request, {}); - - expect(result.statusCode).toStrictEqual(500); - expect(result.headers).toStrictEqual({ - "content-type": "application/restate", - }); - expect(result.isBase64Encoded).toStrictEqual(true); - expect(Buffer.from(result.body, "base64").toString()).toContain( - "" + - "Invalid path: path URL seems to include query parameters: /invoke/test.TestGreeter/Greet?count=5" - ); - }); - it("fails on invalid path", async () => { const handler = getTestHandler(); const request = apiProxyGatewayEvent( - "/invoke/test.TestGreeter", + "/invoke/greeter", "application/restate", - serializeMessages([startMessage(1), inputMessage(greetRequest("Pete"))]) + serializeMessages([ + startMessage({ knownEntries: 1 }), + inputMessage(greetRequest("Pete")), + ]) ); const result = await handler(request, {}); - expect(result.statusCode).toStrictEqual(500); + expect(result.statusCode).toStrictEqual(404); expect(result.headers).toStrictEqual({ "content-type": "application/restate", }); expect(result.isBase64Encoded).toStrictEqual(true); - expect(Buffer.from(result.body, "base64").toString()).toContain( - "Invalid path: path doesn't end in /invoke/SvcName/MethodName and also not in /discover: /invoke/test.TestGreeter" - ); }); it("fails on invalid path no 'invoke' or 'discover'", async () => { const handler = getTestHandler(); const request = apiProxyGatewayEvent( - "/something/test.TestGreeter/Greet", + "/something/greeter/greet", "application/restate", - serializeMessages([startMessage(1), inputMessage(greetRequest("Pete"))]) + serializeMessages([ + startMessage({ knownEntries: 1 }), + inputMessage(greetRequest("Pete")), + ]) ); const result = await handler(request, {}); - expect(result.statusCode).toStrictEqual(500); + expect(result.statusCode).toStrictEqual(404); expect(result.headers).toStrictEqual({ "content-type": "application/restate", }); - expect(result.isBase64Encoded).toStrictEqual(true); - expect(Buffer.from(result.body, "base64").toString()).toContain( - "Invalid path: path doesn't end in /invoke/SvcName/MethodName and also not in /discover: /something/test.TestGreeter/Greet" - ); }); it("fails on invalid path non-existing URL", async () => { const handler = getTestHandler(); const request = apiProxyGatewayEvent( - "/invoke/test.TestGreeter/Greets", + "/invoke/greeter/greets", "application/restate", - serializeMessages([startMessage(1), inputMessage(greetRequest("Pete"))]) + serializeMessages([ + startMessage({ knownEntries: 1 }), + inputMessage(greetRequest("Pete")), + ]) ); const result = await handler(request, {}); @@ -223,43 +192,33 @@ describe("LambdaGreeter", () => { expect(result.headers).toStrictEqual({ "content-type": "application/restate", }); - expect(result.isBase64Encoded).toStrictEqual(true); - expect(Buffer.from(result.body, "base64").toString()).toContain( - "No service found for URL: /invoke/test.TestGreeter/Greets" - ); }); it("handles discovery", async () => { const handler = getTestHandler(); - const discoverRequest = Buffer.from( - ServiceDiscoveryRequest.encode(ServiceDiscoveryRequest.create()).finish() - ).toString("base64"); const request: APIGatewayProxyEvent = apiProxyGatewayEvent( "/discover", - "application/proto", - discoverRequest + "application/json", + Buffer.alloc(0).toString("base64") ); const result = await handler(request, {}); expect(result.statusCode).toStrictEqual(200); expect(result.headers).toStrictEqual({ - "content-type": "application/proto", + "content-type": "application/json", }); expect(result.isBase64Encoded).toStrictEqual(true); - const decodedResponse = ServiceDiscoveryResponse.decode( - Buffer.from(result.body, "base64") + const decodedResponse: Deployment = JSON.parse( + Buffer.from(result.body, "base64").toString("utf8") ); - - expect(decodedResponse.services).toContain("test.TestGreeter"); - expect(decodedResponse.files?.file.map((el) => el.name)).toEqual( - expect.arrayContaining([ - "dev/restate/ext.proto", - "google/protobuf/descriptor.proto", - "proto/test.proto", - ]) + expect( + decodedResponse.components[0].fullyQualifiedComponentName + ).toStrictEqual("greeter"); + expect(decodedResponse.components[0].componentType).toEqual( + ComponentType.VIRTUAL_OBJECT ); }); }); @@ -267,11 +226,14 @@ describe("LambdaGreeter", () => { function getTestHandler() { return restate .endpoint() - .bindService({ - descriptor: protoMetadata, - service: "TestGreeter", - instance: new LambdaGreeter(), - }) + .object( + "greeter", + restate.object({ + // eslint-disable @typescript-eslint/no-unused-vars + greet: (ctx: restate.ObjectContext) => + new LambdaGreeter().greet(ctx /*req*/), + }) + ) .lambdaHandler(); } diff --git a/test/promise_combinators.test.ts b/test/promise_combinators.test.ts index 60592453..94ff1287 100644 --- a/test/promise_combinators.test.ts +++ b/test/promise_combinators.test.ts @@ -11,7 +11,7 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; import { awakeableMessage, completionMessage, @@ -28,7 +28,6 @@ import { sideEffectMessage, ackMessage, } from "./protoutils"; -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; import { COMBINATOR_ENTRY_MESSAGE, SLEEP_ENTRY_MESSAGE_TYPE, @@ -37,9 +36,7 @@ import { TimeoutError } from "../src/types/errors"; import { CombineablePromise } from "../src/context"; class AwakeableSleepRaceGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const awakeable = ctx.awakeable(); const sleep = ctx.sleep(1); @@ -174,9 +171,7 @@ describe("AwakeableSleepRaceGreeter", () => { class AwakeableSleepRaceInterleavedWithSideEffectGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const awakeable = ctx.awakeable(); const sleep = ctx.sleep(1); const combinatorPromise = CombineablePromise.race([ @@ -252,9 +247,7 @@ describe("AwakeableSleepRaceInterleavedWithSideEffectGreeter", () => { }); class CombineablePromiseThenSideEffect implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const a1 = ctx.awakeable(); const a2 = ctx.awakeable(); const combinatorResult = await CombineablePromise.race([ @@ -350,9 +343,7 @@ describe("CombineablePromiseThenSideEffect", () => { }); class AwakeableOrTimeoutGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const { promise } = ctx.awakeable(); try { const result = await promise.orTimeout(100); diff --git a/test/protoutils.ts b/test/protoutils.ts index 4021dce8..2b299707 100644 --- a/test/protoutils.ts +++ b/test/protoutils.ts @@ -53,7 +53,6 @@ import { GetStateKeysEntryMessage, } from "../src/types/protocol"; import { Message } from "../src/types/types"; -import { TestRequest, TestResponse } from "../src/generated/proto/test"; import { CombinatorEntryMessage, FailureWithTerminal, @@ -69,11 +68,19 @@ import { rlog } from "../src/logger"; import { ErrorCodes, RestateErrorCodes } from "../src/types/errors"; import { SUPPORTED_PROTOCOL_VERSION } from "../src/io/decoder"; -export function startMessage( - knownEntries?: number, - partialState?: boolean, - state?: Buffer[][] -): Message { +export type StartMessageOpts = { + knownEntries?: number; + partialState?: boolean; + state?: Buffer[][]; + key?: string; +}; + +export function startMessage({ + knownEntries, + partialState, + state, + key, +}: StartMessageOpts = {}): Message { return new Message( START_MESSAGE_TYPE, StartMessage.create({ @@ -85,6 +92,7 @@ export function startMessage( knownEntries: knownEntries, // only used for the Lambda case. For bidi streaming, this will be imputed by the testdriver stateMap: toStateEntries(state || []), partialState: partialState !== false, + key: key ?? "Till", }), undefined, SUPPORTED_PROTOCOL_VERSION, @@ -338,7 +346,8 @@ export function invokeMessage( methodName: string, parameter: Uint8Array, value?: Uint8Array, - failure?: Failure + failure?: Failure, + key?: string ): Message { if (value != undefined) { return new Message( @@ -348,6 +357,7 @@ export function invokeMessage( methodName: methodName, parameter: Buffer.from(parameter), value: Buffer.from(value), + key, }) ); } else if (failure != undefined) { @@ -358,6 +368,7 @@ export function invokeMessage( methodName: methodName, parameter: Buffer.from(parameter), failure: failure, + key, }) ); } else { @@ -367,6 +378,7 @@ export function invokeMessage( serviceName: serviceName, methodName: methodName, parameter: Buffer.from(parameter), + key, }) ); } @@ -376,7 +388,8 @@ export function backgroundInvokeMessage( serviceName: string, methodName: string, parameter: Uint8Array, - invokeTime?: number + invokeTime?: number, + key?: string ): Message { return invokeTime ? new Message( @@ -386,6 +399,7 @@ export function backgroundInvokeMessage( methodName: methodName, parameter: Buffer.from(parameter), invokeTime: invokeTime, + key, }) ) : new Message( @@ -518,13 +532,13 @@ export function failureWithTerminal( } export function greetRequest(myName: string): Uint8Array { - return TestRequest.encode(TestRequest.create({ name: myName })).finish(); + const str = JSON.stringify({ name: myName }); + return Buffer.from(str); } export function greetResponse(myGreeting: string): Uint8Array { - return TestResponse.encode( - TestResponse.create({ greeting: myGreeting }) - ).finish(); + const str = JSON.stringify({ greeting: myGreeting }); + return Buffer.from(str); } export function checkError( diff --git a/test/send_request.test.ts b/test/send_request.test.ts deleted file mode 100644 index 0835dfb8..00000000 --- a/test/send_request.test.ts +++ /dev/null @@ -1,842 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { describe, expect } from "@jest/globals"; -import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; -import { - backgroundInvokeMessage, - checkJournalMismatchError, - checkTerminalError, - completionMessage, - END_MESSAGE, - failure, - greetRequest, - greetResponse, - inputMessage, - invokeMessage, - outputMessage, - setStateMessage, - startMessage, - suspensionMessage, -} from "./protoutils"; -import { - TestGreeter, - TestGreeterClientImpl, - TestRequest, - TestResponse, -} from "../src/generated/proto/test"; -import { ProtocolMode } from "../src/generated/proto/discovery"; -import { BackgroundInvokeEntryMessage } from "../src/generated/proto/protocol"; -import { BACKGROUND_INVOKE_ENTRY_MESSAGE_TYPE } from "../src/types/protocol"; - -class SyncCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - const response = await client.greet( - TestRequest.create({ name: "Francesco" }) - ); - - return response; - } -} - -describe("SyncCallGreeter", () => { - it("sends message to runtime", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - suspensionMessage([1]), - ]); - }); - - it("handles completion with value", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - completionMessage(1, greetResponse("Pete")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - outputMessage(greetResponse("Pete")), - END_MESSAGE, - ]); - }); - - it("handles completion with failure", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - completionMessage( - 1, - undefined, - undefined, - failure("Something went wrong") - ), - ]).run(); - - expect(result[0]).toStrictEqual( - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")) - ); - checkTerminalError(result[1], "Something went wrong"); - }); - - it("handles replay with value", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - greetResponse("Pete") - ), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Pete")), - END_MESSAGE, - ]); - }); - - it("handles replay without value", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - ]).run(); - - expect(result).toStrictEqual([suspensionMessage([1])]); - }); - - it("handles replay with failure", async () => { - const result = await new TestDriver(new SyncCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - undefined, - failure("Something went wrong") - ), - ]).run(); - - checkTerminalError(result[0], "Something went wrong"); - }); -}); - -class ReverseAwaitOrder implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - const greetingPromise1 = client.greet( - TestRequest.create({ name: "Francesco" }) - ); - const greetingPromise2 = client.greet(TestRequest.create({ name: "Till" })); - - const greeting2 = await greetingPromise2; - ctx.set("A2", greeting2.greeting); - - const greeting1 = await greetingPromise1; - - return TestResponse.create({ - greeting: `Hello ${greeting1.greeting}-${greeting2.greeting}`, - }); - } -} - -describe("ReverseAwaitOrder", () => { - it("sends message to runtime", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Till")), - suspensionMessage([1, 2]), - ]); - }); - - it("sends message to runtime for request-response mode", async () => { - const result = await new TestDriver( - new ReverseAwaitOrder(), - [startMessage(1), inputMessage(greetRequest("Till"))], - ProtocolMode.REQUEST_RESPONSE - ).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Till")), - suspensionMessage([1, 2]), - ]); - }); - - it("handles completion with value A1 and then A2", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - completionMessage(1, greetResponse("FRANCESCO")), - completionMessage(2, greetResponse("TILL")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Till")), - setStateMessage("A2", "TILL"), - outputMessage(greetResponse("Hello FRANCESCO-TILL")), - END_MESSAGE, - ]); - }); - - it("handles completion with value A2 and then A1", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - completionMessage(2, greetResponse("TILL")), - completionMessage(1, greetResponse("FRANCESCO")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Till")), - setStateMessage("A2", "TILL"), - outputMessage(greetResponse("Hello FRANCESCO-TILL")), - END_MESSAGE, - ]); - }); - - it("handles replay with value for A1 and A2", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(4), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - greetResponse("FRANCESCO") - ), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Till"), - greetResponse("TILL") - ), - setStateMessage("A2", "TILL"), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello FRANCESCO-TILL")), - END_MESSAGE, - ]); - }); - - it("fails on journal mismatch. A1 completed with wrong service name", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(4), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeterWrong", // should have been test.TestGreeter - "Greet", - greetRequest("Francesco"), - greetResponse("FRANCESCO") - ), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Till"), - greetResponse("TILL") - ), - setStateMessage("A2", "TILL"), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. A1 completed with wrong method name.", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(4), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greetzz", // should have been Greet - greetRequest("Francesco"), - greetResponse("FRANCESCO") - ), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Till"), - greetResponse("TILL") - ), - setStateMessage("A2", "TILL"), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. A1 completed with wrong request", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(4), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("AnotherName"), // should have been Francesco - greetResponse("FRANCESCO") - ), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Till"), - greetResponse("TILL") - ), - setStateMessage("A2", "TILL"), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. A2 completed with backgroundInvoke", async () => { - const result = await new TestDriver(new ReverseAwaitOrder(), [ - startMessage(4), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - greetResponse("FRANCESCO") - ), - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Till") - ), // should have been an invoke message - setStateMessage("A2", "TILL"), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - //TODO - /* -Late completions after the state machine has been closed lead to weird behavior -The following happens: -The service: ReverseAwaitOrder -gets completed in this order: (test: https://github.com/restatedev/sdk-typescript/blob/96cacb7367bc521c19d65592b27ce50dea406659/test/send_request.test.ts#L348) - startMessage(1), - inputMessage(greetRequest("Till")), - completionMessage( - 1, - undefined, - undefined, - failure("Error") - ), - completionMessage(2, greetResponse("TILL")), -The current behaviour is that the first completion (error) throws a user-code error that isn't catched. So the entire call fails and sends back an output entry stream message. -But then the completion of the other call comes in. This can happen in the case where the runtime didn't yet see the output message before sending the completion. -This gives the following error: -(node:15318) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: 2) - at handledRejection (node:internal/process/promises:172:23) - at promiseRejectHandler (node:internal/process/promises:118:7) - at ReverseAwaitOrder.greet (/home/giselle/dev/sdk-typescript/test/send_request.test.ts:41:23) - at GrpcServiceMethod.localMethod [as localFn] (/home/giselle/dev/sdk-typescript/src/server/endpoint_impl.ts:201:16) - */ - // it("handles completion with failure", async () => { - // const result = await new TestDriver( - // new ReverseAwaitOrder(), - // [ - // startMessage(1), - // inputMessage(greetRequest("Till")), - // completionMessage( - // 1, - // undefined, - // undefined, - // failure("Error") - // ), - // completionMessage(2, greetResponse("TILL")), - // ] - // ).run(); - // - // expect(result.length).toStrictEqual(4); - // expect(result[0]).toStrictEqual( - // invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")) - // ); - // expect(result[1]).toStrictEqual( - // invokeMessage("test.TestGreeter", "Greet", greetRequest("Till")) - // ); - // expect(result[2]).toStrictEqual(setStateMessage("A2", "TILL")); - // checkError(result[3], "Error"); // Error comes from the failed completion - // }); - // -}); - -class FailingForwardGreetingService implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - - try { - // This will get an failure back as a completion or replay message - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const greeting = await client.greet( - TestRequest.create({ name: "Francesco" }) - ); - } catch (error) { - if (error instanceof Error) { - // If we call another service and get back a failure. - // The failure should be thrown in the user code. - return TestResponse.create({ - greeting: `Hello ${error.message}`, - }); - } - throw new Error("Error is not instanceof Error: " + typeof error); - } - - return TestResponse.create({ - greeting: `Hello, you shouldn't be here...`, - }); - } -} - -describe("FailingForwardGreetingService", () => { - it("handles completion with failure", async () => { - const result = await new TestDriver(new FailingForwardGreetingService(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - completionMessage( - 1, - undefined, - undefined, - failure("Sorry, something went terribly wrong...") - ), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - outputMessage( - greetResponse("Hello Sorry, something went terribly wrong...") - ), - END_MESSAGE, - ]); - }); - - it("handles replay with failure", async () => { - const result = await new TestDriver(new FailingForwardGreetingService(), [ - startMessage(2), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - undefined, - failure("Sorry, something went terribly wrong...") - ), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage( - greetResponse("Hello Sorry, something went terribly wrong...") - ), - END_MESSAGE, - ]); - }); -}); - -class OneWayCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - await ctx - .grpcChannel() - .oneWayCall(() => - client.greet(TestRequest.create({ name: "Francesco" })) - ); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("OneWayCallGreeter", () => { - it("sends message to runtime", async () => { - const result = await new TestDriver(new OneWayCallGreeter(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco") - ), - outputMessage(greetResponse("Hello")), - END_MESSAGE, - ]); - }); - - it("fails on journal mismatch. Completed with invoke", async () => { - const result = await new TestDriver(new OneWayCallGreeter(), [ - startMessage(2), - inputMessage(greetRequest("Till")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), // should have been BackgroundInvoke - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. Completed with different service name.", async () => { - const result = await new TestDriver(new OneWayCallGreeter(), [ - startMessage(2), - inputMessage(greetRequest("Till")), - backgroundInvokeMessage( - "test.TestGreeterWrong", // should have been "test.TestGreeter" - "Greet", - greetRequest("Francesco") - ), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. Completed with different method", async () => { - const result = await new TestDriver(new OneWayCallGreeter(), [ - startMessage(2), - inputMessage(greetRequest("Till")), - backgroundInvokeMessage( - "test.TestGreeter", - "Greetzzz", // should have been "Greet" - greetRequest("Francesco") - ), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); - - it("fails on journal mismatch. Completed with different request.", async () => { - const result = await new TestDriver(new OneWayCallGreeter(), [ - startMessage(2), - inputMessage(greetRequest("Till")), - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("AnotherName") // should have been "Francesco" - ), - ]).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); -}); - -class FailingOneWayCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - - await ctx.grpcChannel().oneWayCall(async () => ctx.set("state", 13)); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("FailingOneWayCallGreeter", () => { - it("fails on illegal operation set state in oneWayCall", async () => { - const result = await new TestDriver(new FailingOneWayCallGreeter(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result.length).toStrictEqual(2); - checkTerminalError( - result[0], - "Cannot do a set state from within ctx.oneWayCall(...)." - ); - expect(result[1]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingAwakeableOneWayCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - await ctx.grpcChannel().oneWayCall(async () => ctx.awakeable()); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("FailingAwakeableOneWayCallGreeter", () => { - it("fails on illegal operation awakeable in oneWayCall", async () => { - const result = await new TestDriver( - new FailingAwakeableOneWayCallGreeter(), - [startMessage(1), inputMessage(greetRequest("Till"))] - ).run(); - - expect(result.length).toStrictEqual(2); - checkTerminalError( - result[0], - "Cannot do a awakeable from within ctx.oneWayCall(...)." - ); - expect(result[1]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingSideEffectInOneWayCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - await ctx - .grpcChannel() - .oneWayCall(async () => ctx.sideEffect(async () => 13)); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("FailingSideEffectInOneWayCallGreeter", () => { - it("fails on illegal operation sideEffect in oneWayCall", async () => { - const result = await new TestDriver( - new FailingSideEffectInOneWayCallGreeter(), - [startMessage(1), inputMessage(greetRequest("Till"))] - ).run(); - - expect(result.length).toStrictEqual(2); - checkTerminalError( - result[0], - "Cannot do a side effect from within ctx.oneWayCall(...). Context method ctx.oneWayCall() can only be used to invoke other services unidirectionally. e.g. ctx.oneWayCall(() => client.greet(my_request))" - ); - expect(result[1]).toStrictEqual(END_MESSAGE); - }); -}); - -class CatchTwoFailingInvokeGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - // Do a failing async call - try { - await ctx.grpcChannel().oneWayCall(async () => { - throw new Error("This fails."); - }); - } catch (e) { - // do nothing - } - - // Do a succeeding async call - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - await ctx - .grpcChannel() - .oneWayCall(() => client.greet(TestRequest.create({ name: "Pete" }))); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("CatchTwoFailingInvokeGreeter", () => { - it("catches the failed oneWayCall", async () => { - const result = await new TestDriver(new CatchTwoFailingInvokeGreeter(), [ - startMessage(1), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result.length).toStrictEqual(3); - expect(result).toStrictEqual([ - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Pete"), - undefined - ), - outputMessage(greetResponse("Hello")), - END_MESSAGE, - ]); - }); -}); - -class DelayedOneWayCallGreeter implements TestGreeter { - constructor(private readonly delayedCallTime: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - await ctx - .grpcChannel() - .delayedCall( - () => client.greet(TestRequest.create({ name: "Francesco" })), - this.delayedCallTime - Date.now() - ); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("DelayedOneWayCallGreeter", () => { - it("sends message to runtime", async () => { - const delayedCallTime = 1835661783000; - - const result = await new TestDriver( - new DelayedOneWayCallGreeter(delayedCallTime), - [startMessage(1), inputMessage(greetRequest("Till"))] - ).run(); - - // Delayed call time is slightly larger or smaller based on test execution speed... So test the range - expect(result[0].messageType).toStrictEqual( - BACKGROUND_INVOKE_ENTRY_MESSAGE_TYPE - ); - const msg = result[0].message as BackgroundInvokeEntryMessage; - expect(msg.serviceName).toStrictEqual("test.TestGreeter"); - expect(msg.methodName).toStrictEqual("Greet"); - expect(msg.parameter.toString().trim()).toStrictEqual("Francesco"); - expect(msg.invokeTime).toBeGreaterThanOrEqual(delayedCallTime); - expect(msg.invokeTime).toBeLessThanOrEqual(delayedCallTime + 10); - expect(result[1]).toStrictEqual(outputMessage(greetResponse("Hello"))); - }); - - it("handles replay", async () => { - const delayedCallTime = 1835661783000; - - const result = await new TestDriver( - new DelayedOneWayCallGreeter(delayedCallTime), - [ - startMessage(2), - inputMessage(greetRequest("Till")), - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - delayedCallTime - ), - ] - ).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello")), - END_MESSAGE, - ]); - }); - - it("fails on journal mismatch. Completed with InvokeMessage.", async () => { - const delayedCallTime = 1835661783000; - - const result = await new TestDriver( - new DelayedOneWayCallGreeter(delayedCallTime), - [ - startMessage(2), - inputMessage(greetRequest("Till")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - ] - ).run(); - - expect(result.length).toStrictEqual(1); - checkJournalMismatchError(result[0]); - }); -}); - -class DelayedAndNormalInOneWayCallGreeter implements TestGreeter { - constructor(private readonly delayedCallTime: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - await ctx - .grpcChannel() - .delayedCall( - () => client.greet(TestRequest.create({ name: "Francesco" })), - this.delayedCallTime - Date.now() - ); - await ctx - .grpcChannel() - .oneWayCall(() => - client.greet(TestRequest.create({ name: "Francesco" })) - ); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("DelayedAndNormalInOneWayCallGreeter", () => { - it("sends delayed and normal oneWayCall to runtime", async () => { - const delayedCallTime = 1835661783000; - - const result = await new TestDriver( - new DelayedAndNormalInOneWayCallGreeter(delayedCallTime), - [startMessage(1), inputMessage(greetRequest("Till"))] - ).run(); - - expect(result[0].messageType).toStrictEqual( - BACKGROUND_INVOKE_ENTRY_MESSAGE_TYPE - ); - const msg = result[0].message as BackgroundInvokeEntryMessage; - expect(msg.serviceName).toStrictEqual("test.TestGreeter"); - expect(msg.methodName).toStrictEqual("Greet"); - expect(msg.parameter.toString().trim()).toStrictEqual("Francesco"); - expect(msg.invokeTime).toBeGreaterThanOrEqual(delayedCallTime); - expect(msg.invokeTime).toBeLessThanOrEqual(delayedCallTime + 10); - expect(result[1]).toStrictEqual( - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco") - ) - ); - expect(result[2]).toStrictEqual(outputMessage(greetResponse("Hello"))); - }); -}); - -class UnawaitedRequestResponseCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - client.greet(TestRequest.create({ name: "Francesco" })); - - return TestResponse.create({ greeting: `Hello` }); - } -} - -describe("UnawaitedRequestResponseCallGreeter", () => { - it("does not await the response of the call after journal mismatch checks have been done", async () => { - const result = await new TestDriver( - new UnawaitedRequestResponseCallGreeter(), - [ - startMessage(2), - inputMessage(greetRequest("Till")), - invokeMessage("test.TestGreeter", "Greet", greetRequest("Francesco")), - ] - ).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello")), - END_MESSAGE, - ]); - }); -}); diff --git a/test/service_bind.test.ts b/test/service_bind.test.ts index 5e7a840d..ead94aa5 100644 --- a/test/service_bind.test.ts +++ b/test/service_bind.test.ts @@ -10,19 +10,21 @@ */ import { + TestDriver, TestGreeter, TestRequest, TestResponse, -} from "../src/generated/proto/test"; +} from "./testdriver"; import * as restate from "../src/public_api"; import { describe } from "@jest/globals"; -import { TestDriver } from "./testdriver"; import { greetRequest, inputMessage, startMessage } from "./protoutils"; const greeter: TestGreeter = { /* eslint-disable @typescript-eslint/no-unused-vars */ - greet: async (req: TestRequest): Promise => { - restate.useContext(this); + greet: async ( + ctx: restate.ObjectContext, + req: TestRequest + ): Promise => { return TestResponse.create({ greeting: `Hello` }); }, }; @@ -30,7 +32,7 @@ const greeter: TestGreeter = { describe("BindService", () => { it("should bind object literals", async () => { await new TestDriver(greeter, [ - startMessage(1), + startMessage({ knownEntries: 1 }), inputMessage(greetRequest("Pete")), ]).run(); }); diff --git a/test/side_effect.test.ts b/test/side_effect.test.ts deleted file mode 100644 index bc647209..00000000 --- a/test/side_effect.test.ts +++ /dev/null @@ -1,1086 +0,0 @@ -/* - * Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH - * - * This file is part of the Restate SDK for Node.js/TypeScript, - * which is released under the MIT license. - * - * You can find a copy of the license in file LICENSE in the root - * directory of this repository or package, or at - * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE - */ - -import { describe, expect } from "@jest/globals"; -import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; -import { - ackMessage, - backgroundInvokeMessage, - checkJournalMismatchError, - checkTerminalError, - completionMessage, - END_MESSAGE, - failureWithTerminal, - getAwakeableId, - greetRequest, - greetResponse, - inputMessage, - invokeMessage, - outputMessage, - sideEffectMessage, - startMessage, - suspensionMessage, -} from "./protoutils"; -import { - TestGreeter, - TestGreeterClientImpl, - TestRequest, - TestResponse, -} from "../src/generated/proto/test"; -import { ErrorCodes, TerminalError } from "../src/types/errors"; -import { ProtocolMode } from "../src/generated/proto/discovery"; - -class SideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: unknown) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - return this.sideEffectOutput; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("SideEffectGreeter", () => { - it("sends message to runtime", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage("Francesco"), - suspensionMessage([1]), - ]); - }); - - it("sends message to runtime for undefined result", async () => { - const result = await new TestDriver(new SideEffectGreeter(undefined), [ - startMessage(), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(undefined), - suspensionMessage([1]), - ]); - }); - - it("sends message to runtime for empty object", async () => { - const result = await new TestDriver(new SideEffectGreeter({}), [ - startMessage(), - inputMessage(greetRequest("Till")), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage({}), - suspensionMessage([1]), - ]); - }); - - it("handles acks", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage("Francesco"), - outputMessage(greetResponse("Hello Francesco")), - END_MESSAGE, - ]); - }); - - it("handles replay with undefined value", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello undefined")), - END_MESSAGE, - ]); - }); - - it("handles replay with empty object value", async () => { - const result = await new TestDriver(new SideEffectGreeter({}), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello undefined")), - END_MESSAGE, - ]); - }); - - it("handles replay with value", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage("Francesco"), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello Francesco")), - END_MESSAGE, - ]); - }); - - it("handles replay with empty string", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(""), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello ")), - END_MESSAGE, - ]); - }); - - it("handles replay with failure", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage( - undefined, - failureWithTerminal(true, "Something went wrong.") - ), - ]).run(); - - checkTerminalError(result[0], "Something went wrong."); - }); - - it("fails on journal mismatch. Completed with invoke.", async () => { - const result = await new TestDriver(new SideEffectGreeter("Francesco"), [ - startMessage(), - inputMessage(greetRequest("Till")), - invokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("Francesco"), - greetResponse("FRANCESCO") - ), // should have been side effect - ]).run(); - - checkJournalMismatchError(result[0]); - }); -}); - -class SideEffectAndInvokeGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - - // state - const result = await ctx.sideEffect(async () => "abcd"); - - const greetingPromise1 = await client.greet( - TestRequest.create({ name: result }) - ); - - return TestResponse.create({ - greeting: `Hello ${greetingPromise1.greeting}`, - }); - } -} - -// Checks if the side effect flag is put back to false when we are in replay and do not execute the side effect -describe("SideEffectAndInvokeGreeter", () => { - it("handles replay and then invoke.", async () => { - const result = await new TestDriver(new SideEffectAndInvokeGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage("abcd"), - completionMessage(2, greetResponse("FRANCESCO")), - ]).run(); - - expect(result).toStrictEqual([ - invokeMessage("test.TestGreeter", "Greet", greetRequest("abcd")), - outputMessage(greetResponse("Hello FRANCESCO")), - END_MESSAGE, - ]); - }); - - it("handles completion and then invoke", async () => { - const result = await new TestDriver(new SideEffectAndInvokeGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - completionMessage(2, greetResponse("FRANCESCO")), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage("abcd"), - invokeMessage("test.TestGreeter", "Greet", greetRequest("abcd")), - outputMessage(greetResponse("Hello FRANCESCO")), - END_MESSAGE, - ]); - }); -}); - -class SideEffectAndOneWayCallGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - - // state - const result = await ctx.sideEffect(async () => "abcd"); - - await ctx - .grpcChannel() - .oneWayCall(() => client.greet(TestRequest.create({ name: result }))); - const response = await client.greet(TestRequest.create({ name: result })); - - return TestResponse.create({ greeting: `Hello ${response.greeting}` }); - } -} - -// Checks if the side effect flag is put back to false when we are in replay and do not execute the side effect -describe("SideEffectAndOneWayCallGreeter", () => { - it("handles completion and then invoke", async () => { - const result = await new TestDriver(new SideEffectAndOneWayCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - completionMessage(3, greetResponse("FRANCESCO")), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage("abcd"), - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("abcd") - ), - invokeMessage("test.TestGreeter", "Greet", greetRequest("abcd")), - outputMessage(greetResponse("Hello FRANCESCO")), - END_MESSAGE, - ]); - }); - - it("handles replay and then invoke", async () => { - const result = await new TestDriver(new SideEffectAndOneWayCallGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage("abcd"), - completionMessage(3, greetResponse("FRANCESCO")), - ]).run(); - - expect(result).toStrictEqual([ - backgroundInvokeMessage( - "test.TestGreeter", - "Greet", - greetRequest("abcd") - ), - invokeMessage("test.TestGreeter", "Greet", greetRequest("abcd")), - outputMessage(greetResponse("Hello FRANCESCO")), - END_MESSAGE, - ]); - }); -}); - -class NumericSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - return this.sideEffectOutput; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("NumericSideEffectGreeter", () => { - it("handles acks", async () => { - const result = await new TestDriver(new NumericSideEffectGreeter(123), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(123), - outputMessage(greetResponse("Hello 123")), - END_MESSAGE, - ]); - }); - - it("handles replay with value", async () => { - const result = await new TestDriver(new NumericSideEffectGreeter(123), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(123), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello 123")), - END_MESSAGE, - ]); - }); -}); - -enum OrderStatus { - ORDERED, - DELIVERED, -} - -class EnumSideEffectGreeter implements TestGreeter { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - return OrderStatus.ORDERED; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("EnumSideEffectGreeter", () => { - it("handles acks with value enum", async () => { - const result = await new TestDriver(new EnumSideEffectGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(OrderStatus.ORDERED), - outputMessage(greetResponse("Hello 0")), - END_MESSAGE, - ]); - }); - - it("handles replay with value enum", async () => { - const result = await new TestDriver(new EnumSideEffectGreeter(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(OrderStatus.ORDERED), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("Hello 0")), - END_MESSAGE, - ]); - }); -}); - -class FailingSideEffectGreeter implements TestGreeter { - constructor(private readonly terminalError: boolean) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - throw this.terminalError - ? new TerminalError("Failing user code") - : new Error("Failing user code"); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("Side effects error-handling", () => { - it("handles terminal errors by producing error output", async () => { - const result = await new TestDriver(new FailingSideEffectGreeter(true), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - // When the user code fails we do want to see a side effect message with a failure - // For invalid user code, we do not want to see this. - expect(result.length).toStrictEqual(3); - checkTerminalError(result[1], "Failing user code"); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); - - it("handles non-terminal errors by retrying (with sleep and suspension)", async () => { - const result = await new TestDriver(new FailingSideEffectGreeter(false), [ - startMessage(), - inputMessage(greetRequest("Till")), - completionMessage(1), - ]).run(); - - // When the user code fails we do want to see a side effect message with a failure - // For invalid user code, we do not want to see this. - expect(result.length).toBeGreaterThanOrEqual(1); - expect(result[0]).toStrictEqual( - sideEffectMessage( - undefined, - failureWithTerminal(false, "Failing user code") - ) - ); - }); -}); - -class FailingGetSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - - // state - const response = await ctx.sideEffect(async () => { - await ctx.get("state"); - return this.sideEffectOutput; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingGetSideEffectGreeter", () => { - it("fails on invalid operation getState in sideEffect", async () => { - const result = await new TestDriver(new FailingGetSideEffectGreeter(123), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do get state calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingSetSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - - // state - const response = await ctx.sideEffect(async () => { - ctx.set("state", 13); - return this.sideEffectOutput; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingSetSideEffectGreeter", () => { - it("fails on invalid operation setState in sideEffect", async () => { - const result = await new TestDriver(new FailingSetSideEffectGreeter(123), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do set state calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingClearSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - - // state - const response = await ctx.sideEffect(async () => { - ctx.clear("state"); - return this.sideEffectOutput; - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingClearSideEffectGreeter", () => { - it("fails on invalid operation clearState in sideEffect", async () => { - const result = await new TestDriver( - new FailingClearSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do clear state calls from within a side effect" - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingNestedSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - await ctx.sideEffect(async () => { - return this.sideEffectOutput; - }); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingNestedSideEffectGreeter", () => { - it("fails on invalid operation sideEffect in sideEffect", async () => { - const result = await new TestDriver( - new FailingNestedSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do sideEffect calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); - - it("fails on invalid replayed operation sideEffect in sideEffect", async () => { - const result = await new TestDriver( - new FailingNestedSideEffectGreeter(123), - [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage( - undefined, - failureWithTerminal( - true, - "Error: You cannot do sideEffect state calls from within a side effect.", - ErrorCodes.INTERNAL - ) - ), - ] - ).run(); - - expect(result.length).toStrictEqual(2); - checkTerminalError( - result[0], - "You cannot do sideEffect state calls from within a side effect" - ); - expect(result[1]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingNestedWithoutAwaitSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - // without awaiting - ctx.sideEffect(async () => { - return this.sideEffectOutput; - }); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -// //TODO This test causes one of the later tests to fail -// // it seems there is something not cleaned up correctly... -// // describe("FailingNestedWithoutAwaitSideEffectGreeter: invalid nested side effect in side effect with ack", () => { -// // it("should call greet", async () => { -// // const result = await new TestDriver( -// // new FailingNestedWithoutAwaitSideEffectGreeter(123), -// // [startMessage(), inputMessage(greetRequest("Till"))] -// // ).run(); -// // -// // expect(result.length).toStrictEqual(1); -// // checkError( -// // result[0], -// // "You cannot do sideEffect calls from within a side effect." -// // ); -// // }); -// // }); - -describe("FailingNestedWithoutAwaitSideEffectGreeter", () => { - it("fails on invalid operation unawaited side effect in sideEffect", async () => { - const result = await new TestDriver( - new FailingNestedWithoutAwaitSideEffectGreeter(123), - [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage( - undefined, - failureWithTerminal( - true, - "Error: You cannot do sideEffect state calls from within a side effect.", - ErrorCodes.INTERNAL - ) - ), - ] - ).run(); - - expect(result.length).toStrictEqual(2); - checkTerminalError( - result[0], - "You cannot do sideEffect state calls from within a side effect" - ); - expect(result[1]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingOneWayCallInSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - await ctx.grpcChannel().oneWayCall(async () => { - return; - }); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingOneWayCallInSideEffectGreeter", () => { - it("fails on invalid operation oneWayCall in sideEffect", async () => { - const result = await new TestDriver( - new FailingOneWayCallInSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do oneWayCall calls from within a side effect" - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingResolveAwakeableSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - const awakeableIdentifier = getAwakeableId(1); - ctx.resolveAwakeable(awakeableIdentifier, "hello"); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingResolveAwakeableSideEffectGreeter", () => { - it("fails on invalid operation resolveAwakeable in sideEffect", async () => { - const result = await new TestDriver( - new FailingResolveAwakeableSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do resolveAwakeable calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingRejectAwakeableSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - const awakeableIdentifier = getAwakeableId(1); - ctx.rejectAwakeable(awakeableIdentifier, "hello"); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingRejectAwakeableSideEffectGreeter", () => { - it("fails on invalid operation rejectAwakeable in sideEffect", async () => { - const result = await new TestDriver( - new FailingRejectAwakeableSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do rejectAwakeable calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingSleepSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - await ctx.sleep(1000); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingSleepSideEffectGreeter", () => { - it("fails on invalid operation sleep in sideEffect", async () => { - const result = await new TestDriver( - new FailingSleepSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do sleep calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -class FailingAwakeableSideEffectGreeter implements TestGreeter { - constructor(readonly sideEffectOutput: number) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - // state - const response = await ctx.sideEffect(async () => { - ctx.awakeable(); - }); - - return TestResponse.create({ greeting: `Hello ${response}` }); - } -} - -describe("FailingAwakeableSideEffectGreeter", () => { - it("fails on invalid operation awakeable in sideEffect", async () => { - const result = await new TestDriver( - new FailingAwakeableSideEffectGreeter(123), - [startMessage(), inputMessage(greetRequest("Till")), ackMessage(1)] - ).run(); - - expect(result.length).toStrictEqual(3); - checkTerminalError( - result[1], - "You cannot do awakeable calls from within a side effect." - ); - expect(result[2]).toStrictEqual(END_MESSAGE); - }); -}); - -export class AwaitSideEffectService implements TestGreeter { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - - let invocationCount = 0; - - await ctx.sideEffect(async () => { - invocationCount++; - }); - await ctx.sideEffect(async () => { - invocationCount++; - }); - await ctx.sideEffect(async () => { - invocationCount++; - }); - - return { greeting: invocationCount.toString() }; - } -} - -describe("AwaitSideEffectService", () => { - it("handles acks of all side effects", async () => { - const result = await new TestDriver(new AwaitSideEffectService(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ackMessage(2), - ackMessage(3), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(), - sideEffectMessage(), - sideEffectMessage(), - outputMessage(greetResponse("3")), - END_MESSAGE, - ]); - }); - - it("handles replay of first side effect and acks of the others", async () => { - const result = await new TestDriver(new AwaitSideEffectService(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(), - ackMessage(2), - ackMessage(3), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(), - sideEffectMessage(), - outputMessage(greetResponse("2")), - END_MESSAGE, - ]); - }); - - it("handles replay of first two side effect and ack of the other", async () => { - const result = await new TestDriver(new AwaitSideEffectService(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(), - sideEffectMessage(), - ackMessage(3), - ]).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(), - outputMessage(greetResponse("1")), - END_MESSAGE, - ]); - }); - - it("handles replay of all side effects", async () => { - const result = await new TestDriver(new AwaitSideEffectService(), [ - startMessage(), - inputMessage(greetRequest("Till")), - sideEffectMessage(), - sideEffectMessage(), - sideEffectMessage(), - ]).run(); - - expect(result).toStrictEqual([ - outputMessage(greetResponse("0")), - END_MESSAGE, - ]); - }); -}); - -export class UnawaitedSideEffectShouldFailSubsequentContextCallService - implements TestGreeter -{ - constructor( - // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars - private readonly next = (ctx: restate.RestateContext): void => {} - ) {} - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - - ctx.sideEffect(async () => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - return new Promise(() => {}); - }); - this.next(ctx); - - throw new Error("code should not reach this point"); - } -} - -describe("UnawaitedSideEffectShouldFailSubsequentContextCall", () => { - const defineTestCase = ( - contextMethodCall: string, - next: (ctx: restate.RestateContext) => void - ): void => { - it( - "Not awaiting side effect should fail at next " + contextMethodCall, - async () => { - const result = await new TestDriver( - new UnawaitedSideEffectShouldFailSubsequentContextCallService(next), - [startMessage(), inputMessage(greetRequest("Till"))] - ).run(); - - checkTerminalError( - result[0], - `Invoked a RestateContext method while a side effect is still executing. - Make sure you await the ctx.sideEffect call before using any other RestateContext method.` - ); - expect(result.slice(1)).toStrictEqual([END_MESSAGE]); - } - ); - }; - - defineTestCase("side effect", (ctx) => - ctx.sideEffect(async () => { - return 1; - }) - ); - defineTestCase("get", (ctx) => ctx.get("123")); - defineTestCase("set", (ctx) => ctx.set("123", "abc")); - defineTestCase("call", (ctx) => { - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - client.greet(TestRequest.create({ name: "Francesco" })); - }); - defineTestCase("one way call", (ctx) => { - const client = new TestGreeterClientImpl(ctx.grpcChannel()); - ctx - .grpcChannel() - .oneWayCall(() => - client.greet(TestRequest.create({ name: "Francesco" })) - ); - }); -}); - -export class UnawaitedSideEffectShouldFailSubsequentSetService - implements TestGreeter -{ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useKeyedContext(this); - - ctx.sideEffect(async () => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - return new Promise(() => {}); - }); - ctx.set("123", "abc"); - - throw new Error("code should not reach this point"); - } -} - -describe("UnawaitedSideEffectShouldFailSubsequentSetService", () => { - it("Not awaiting side effects should fail", async () => { - const result = await new TestDriver( - new UnawaitedSideEffectShouldFailSubsequentSetService(), - [startMessage(), inputMessage(greetRequest("Till"))] - ).run(); - - checkTerminalError( - result[0], - `Invoked a RestateContext method while a side effect is still executing. - Make sure you await the ctx.sideEffect call before using any other RestateContext method.` - ); - expect(result.slice(1)).toStrictEqual([END_MESSAGE]); - }); -}); - -export class TerminalErrorSideEffectService implements TestGreeter { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - - await ctx.sideEffect(async () => { - throw new TerminalError("Something bad happened."); - }); - - return { greeting: `Hello ${request.name}` }; - } -} - -describe("TerminalErrorSideEffectService", () => { - it("handles terminal error", async () => { - const result = await new TestDriver(new TerminalErrorSideEffectService(), [ - startMessage(), - inputMessage(greetRequest("Till")), - ackMessage(1), - ]).run(); - - expect(result[0]).toStrictEqual( - sideEffectMessage( - undefined, - failureWithTerminal( - true, - "Something bad happened.", - ErrorCodes.INTERNAL - ) - ) - ); - checkTerminalError(result[1], "Something bad happened."); - }); -}); - -class SideEffectWithMutableVariable implements TestGreeter { - constructor(readonly externalSideEffect: { effectExecuted: boolean }) {} - - async greet(): Promise { - const ctx = restate.useContext(this); - - await ctx.sideEffect(() => { - // I'm trying to simulate a case where the side effect resolution - // happens on the next event loop tick. - return new Promise((resolve) => { - setTimeout(() => { - this.externalSideEffect.effectExecuted = true; - resolve(undefined); - }, 100); - }); - }); - - throw new Error("It should not reach this point"); - } -} - -describe("SideEffectWithMutableVariable", () => { - it("should suspend after mutating the variable, and not before!", async () => { - const externalSideEffect = { effectExecuted: false }; - - const result = await new TestDriver( - new SideEffectWithMutableVariable(externalSideEffect), - [startMessage(), inputMessage(greetRequest("Till"))], - ProtocolMode.REQUEST_RESPONSE - ).run(); - - expect(result).toStrictEqual([ - sideEffectMessage(undefined), - suspensionMessage([1]), - ]); - expect(externalSideEffect.effectExecuted).toBeTruthy(); - }); -}); diff --git a/test/sleep.test.ts b/test/sleep.test.ts index ca451ff1..ba55bdb5 100644 --- a/test/sleep.test.ts +++ b/test/sleep.test.ts @@ -11,7 +11,6 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; import { awakeableMessage, checkJournalMismatchError, @@ -30,20 +29,17 @@ import { } from "./protoutils"; import { SLEEP_ENTRY_MESSAGE_TYPE } from "../src/types/protocol"; import { Empty } from "../src/generated/google/protobuf/empty"; -import { - TestGreeter, - TestRequest, - TestResponse, -} from "../src/generated/proto/test"; -import { ProtocolMode } from "../src/generated/proto/discovery"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; +import { ProtocolMode } from "../src/types/discovery"; const wakeupTime = 1835661783000; class SleepGreeter implements TestGreeter { // eslint-disable-next-line @typescript-eslint/no-unused-vars - async greet(request: TestRequest): Promise { - const ctx = restate.useContext(this); - + async greet( + ctx: restate.ObjectContext + /*request: TestRequest*/ + ): Promise { await ctx.sleep(wakeupTime - Date.now()); return TestResponse.create({ greeting: `Hello` }); @@ -65,7 +61,7 @@ describe("SleepGreeter", () => { it("sends message to runtime for request-response mode", async () => { const result = await new TestDriver( new SleepGreeter(), - [startMessage(1), inputMessage(greetRequest("Till"))], + [startMessage({ knownEntries: 1 }), inputMessage(greetRequest("Till"))], ProtocolMode.REQUEST_RESPONSE ).run(); @@ -159,9 +155,7 @@ describe("SleepGreeter", () => { }); class ManySleepsGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useContext(this); - + async greet(ctx: restate.ObjectContext): Promise { await Promise.all( Array.from(Array(5).keys()).map(() => ctx.sleep(wakeupTime - Date.now())) ); @@ -261,9 +255,7 @@ describe("ManySleepsGreeter: With sleep not complete", () => { }); class ManySleepsAndSetGreeter implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { const mySleeps = Promise.all( Array.from(Array(5).keys()).map(() => ctx.sleep(wakeupTime - Date.now())) ); diff --git a/test/state_keys.test.ts b/test/state_keys.test.ts index 6f3066b4..a806e5e0 100644 --- a/test/state_keys.test.ts +++ b/test/state_keys.test.ts @@ -11,7 +11,7 @@ import { describe, expect } from "@jest/globals"; import * as restate from "../src/public_api"; -import { TestDriver } from "./testdriver"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; import { completionMessage, END_MESSAGE, @@ -24,10 +24,9 @@ import { startMessage, suspensionMessage, } from "./protoutils"; -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; import { GetStateKeysEntryMessage_StateKeys } from "../src/generated/proto/protocol"; -const INPUT_MESSAGE = inputMessage(greetRequest("")); +const INPUT_MESSAGE = inputMessage(greetRequest("bob")); function stateKeys(...keys: Array): GetStateKeysEntryMessage_StateKeys { return { @@ -36,9 +35,7 @@ function stateKeys(...keys: Array): GetStateKeysEntryMessage_StateKeys { } class ListKeys implements TestGreeter { - async greet(): Promise { - const ctx = restate.useKeyedContext(this); - + async greet(ctx: restate.ObjectContext): Promise { return { greeting: (await ctx.stateKeys()).join(","), }; @@ -48,7 +45,11 @@ class ListKeys implements TestGreeter { describe("ListKeys", () => { it("with partial state suspends", async () => { const result = await new TestDriver(new ListKeys(), [ - startMessage(1, true, [keyVal("A", "1")]), + startMessage({ + knownEntries: 1, + partialState: true, + state: [keyVal("A", "1")], + }), INPUT_MESSAGE, ]).run(); @@ -60,7 +61,11 @@ describe("ListKeys", () => { it("with partial state", async () => { const result = await new TestDriver(new ListKeys(), [ - startMessage(1, true, [keyVal("A", "1")]), + startMessage({ + knownEntries: 1, + partialState: true, + state: [keyVal("A", "1")], + }), INPUT_MESSAGE, completionMessage( 1, @@ -77,7 +82,11 @@ describe("ListKeys", () => { it("with complete state", async () => { const result = await new TestDriver(new ListKeys(), [ - startMessage(1, false, [keyVal("A", "1")]), + startMessage({ + knownEntries: 1, + partialState: false, + state: [keyVal("A", "1")], + }), INPUT_MESSAGE, ]).run(); @@ -90,7 +99,11 @@ describe("ListKeys", () => { it("replay", async () => { const result = await new TestDriver(new ListKeys(), [ - startMessage(1, true, [keyVal("A", "1")]), + startMessage({ + knownEntries: 1, + partialState: true, + state: [keyVal("A", "1")], + }), INPUT_MESSAGE, getStateKeysMessage(["A", "B", "C"]), ]).run(); diff --git a/test/state_machine.test.ts b/test/state_machine.test.ts index 3f5f0095..d2047b11 100644 --- a/test/state_machine.test.ts +++ b/test/state_machine.test.ts @@ -9,10 +9,8 @@ * https://github.com/restatedev/sdk-typescript/blob/main/LICENSE */ -import { TestGreeter, TestResponse } from "../src/generated/proto/test"; -import * as restate from "../src/public_api"; import { describe, expect } from "@jest/globals"; -import { TestDriver } from "./testdriver"; +import { TestDriver, TestGreeter, TestResponse } from "./testdriver"; import { checkTerminalError, END_MESSAGE, @@ -26,8 +24,6 @@ import { class Greeter implements TestGreeter { async greet(): Promise { - restate.useContext(this); - return TestResponse.create({ greeting: `Hello` }); } } @@ -35,7 +31,7 @@ class Greeter implements TestGreeter { describe("Greeter", () => { it("sends message to runtime", async () => { const result = await new TestDriver(new Greeter(), [ - startMessage(1), + startMessage({ knownEntries: 1, key: "Pete" }), inputMessage(greetRequest("Pete")), ]).run(); @@ -47,7 +43,7 @@ describe("Greeter", () => { it("handles replay of output message", async () => { const result = await new TestDriver(new Greeter(), [ - startMessage(2), + startMessage({ knownEntries: 2, key: "Pete" }), inputMessage(greetRequest("Pete")), outputMessage(greetResponse("Hello")), ]).run(); @@ -57,7 +53,7 @@ describe("Greeter", () => { it("fails invocation if input is failed", async () => { const result = await new TestDriver(new Greeter(), [ - startMessage(1), + startMessage({ knownEntries: 1 }), inputMessage(undefined, failure("Canceled")), ]).run(); diff --git a/test/testdriver.ts b/test/testdriver.ts index 6cb5de05..c7a2327d 100644 --- a/test/testdriver.ts +++ b/test/testdriver.ts @@ -18,44 +18,57 @@ import { import { Connection } from "../src/connection/connection"; import { formatMessageAsJson } from "../src/utils/utils"; import { Message } from "../src/types/types"; -import { HostedGrpcServiceMethod } from "../src/types/grpc"; -import { ProtocolMode } from "../src/generated/proto/discovery"; import { rlog } from "../src/logger"; import { StateMachine } from "../src/state_machine"; import { InvocationBuilder } from "../src/invocation"; -import { protoMetadata } from "../src/generated/proto/test"; import { EndpointImpl } from "../src/endpoint/endpoint_impl"; +import { ObjectContext, ServiceApi } from "../src/context"; +import { object } from "../src/public_api"; +import { ProtocolMode } from "../src/types/discovery"; -export class TestDriver implements Connection { +export type TestRequest = { + name: string; +}; + +export type TestResponse = { + greeting: string; +}; + +export const TestResponse = { + create: (test: TestResponse): TestResponse => test, +}; + +export type GreetType = { + greet: (key: string, arg: TestRequest) => Promise; +}; + +export const GreeterApi: ServiceApi = { path: "greeter" }; + +export interface TestGreeter { + greet(ctx: ObjectContext, message: TestRequest): Promise; +} + +export class TestDriver implements Connection { private readonly result: Message[] = []; private restateServer: TestRestateServer; - private method: HostedGrpcServiceMethod; - private stateMachine: StateMachine; + private stateMachine: StateMachine; private completionMessages: Message[]; constructor( - instance: object, + instance: TestGreeter, entries: Message[], private readonly protocolMode: ProtocolMode = ProtocolMode.BIDI_STREAM ) { this.restateServer = new TestRestateServer(); - this.restateServer.bindService({ - descriptor: protoMetadata, - service: "TestGreeter", - instance: instance, - }); - const methodName = "/test.TestGreeter/Greet"; - - const hostedGrpcServiceMethod: HostedGrpcServiceMethod | undefined = - this.restateServer.methodByUrl("/invoke" + methodName); + const svc = object({ + greet: async (ctx: ObjectContext, arg: TestRequest) => { + return instance.greet(ctx, arg); + }, + }); - if (hostedGrpcServiceMethod) { - this.method = hostedGrpcServiceMethod; - } else { - throw new Error("Method not found: " + methodName); - } + this.restateServer.object(GreeterApi.path, svc); if (entries.length < 2) { throw new Error( @@ -89,6 +102,7 @@ export class TestDriver implements Connection { knownEntries: endOfReplay - 1, stateMap: startEntry.stateMap, partialState: startEntry.partialState, + key: startEntry.key, }), msg.completed, msg.protocolVersion, @@ -122,7 +136,18 @@ export class TestDriver implements Connection { ); } - const invocationBuilder = new InvocationBuilder(this.method); + const method = this.restateServer + .componentByName("greeter") + ?.handlerMatching({ + componentName: "greeter", + handlerName: "greet", + }); + + if (!method) { + throw new Error("Something is wrong with the test setup"); + } + + const invocationBuilder = new InvocationBuilder(method); replayMessages.forEach((el) => invocationBuilder.handleMessage(el)); const invocation = invocationBuilder.build(); @@ -188,10 +213,4 @@ export class TestDriver implements Connection { * make it simpler for users to understand what methods are relevant for them, * and which ones are not. */ -class TestRestateServer extends EndpointImpl { - public methodByUrl( - url: string | null | undefined - ): HostedGrpcServiceMethod | undefined { - return super.methodByUrl(url); - } -} +class TestRestateServer extends EndpointImpl {} diff --git a/test/utils.test.ts b/test/utils.test.ts index e31f1b93..274f48e4 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -12,7 +12,6 @@ import { describe, expect } from "@jest/globals"; import { jsonDeserialize, - jsonSafeAny, jsonSerialize, formatMessageAsJson, } from "../src/utils/utils"; @@ -128,75 +127,3 @@ describe("rand", () => { expect(actual).toStrictEqual(expected); }); }); - -describe("jsonSafeAny", () => { - it("handles dates", () => { - expect(jsonSafeAny("", new Date(1701878170682))).toStrictEqual( - "2023-12-06T15:56:10.682Z" - ); - expect(jsonSafeAny("", { date: new Date(1701878170682) })).toStrictEqual({ - date: "2023-12-06T15:56:10.682Z", - }); - expect( - jsonSafeAny("", { - dates: [new Date(1701878170682), new Date(1701878170683)], - }) - ).toStrictEqual({ - dates: ["2023-12-06T15:56:10.682Z", "2023-12-06T15:56:10.683Z"], - }); - }); - it("handles urls", () => { - expect(jsonSafeAny("", new URL("https://restate.dev"))).toStrictEqual( - "https://restate.dev/" - ); - }); - it("handles patched BigInts", () => { - // by default should do nothing - expect(jsonSafeAny("", BigInt("9007199254740991"))).toStrictEqual( - BigInt("9007199254740991") - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (BigInt.prototype as any).toJSON = function () { - return this.toString(); - }; - expect(jsonSafeAny("", BigInt("9007199254740991"))).toStrictEqual( - "9007199254740991" - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (BigInt.prototype as any).toJSON; - }); - it("handles custom types", () => { - const numberType = { - toJSON(): number { - return 1; - }, - }; - const stringType = { - toJSON(): string { - return "foo"; - }, - }; - expect(jsonSafeAny("", numberType)).toStrictEqual(1); - expect(jsonSafeAny("", stringType)).toStrictEqual("foo"); - }); - it("provides the correct key", () => { - const keys: string[] = []; - const typ = { - toJSON(key: string): string { - keys.push(key); - return ""; - }, - }; - expect(jsonSafeAny("", typ)).toStrictEqual(""); - expect(jsonSafeAny("", { key: typ })).toStrictEqual({ key: "" }); - expect(jsonSafeAny("", { key: [typ] })).toStrictEqual({ key: [""] }); - expect(jsonSafeAny("", { key: [0, typ] })).toStrictEqual({ key: [0, ""] }); - expect(jsonSafeAny("", { key: [0, { key2: typ }] })).toStrictEqual({ - key: [0, { key2: "" }], - }); - expect(jsonSafeAny("", { key: [0, { key2: [typ] }] })).toStrictEqual({ - key: [0, { key2: [""] }], - }); - expect(keys).toStrictEqual(["", "key", "0", "1", "key2", "0"]); - }); -});