From ae475923a63dbb6d328a43b1d48a02885330f124 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Tue, 7 Apr 2026 16:47:15 +0200 Subject: [PATCH 01/10] Introduce RestatePromise.resolve and RestatePromise.reject, to construct already completed restate promises. Fix #611 --- packages/libs/restate-sdk/src/context.ts | 41 ++--- packages/libs/restate-sdk/src/context_impl.ts | 35 +--- packages/libs/restate-sdk/src/promises.ts | 156 ++++++++++++++++-- 3 files changed, 155 insertions(+), 77 deletions(-) diff --git a/packages/libs/restate-sdk/src/context.ts b/packages/libs/restate-sdk/src/context.ts index dd5f24aa..0a7d4728 100644 --- a/packages/libs/restate-sdk/src/context.ts +++ b/packages/libs/restate-sdk/src/context.ts @@ -25,8 +25,11 @@ import type { Serde, Duration, } from "@restatedev/restate-sdk-core"; -import { ContextImpl } from "./context_impl.js"; import type { TerminalError } from "./types/errors.js"; +import { + RestateCombinatorPromise, + RestateCompletedPromise, +} from "./promises.js"; /** * Represents the original request as sent to this handler. @@ -705,6 +708,14 @@ export type InvocationHandle = { export type InvocationPromise = RestatePromise & InvocationHandle; export const RestatePromise = { + resolve(value: T): RestatePromise> { + return new RestateCompletedPromise(Promise.resolve(value)); + }, + + reject(reason: TerminalError): RestatePromise { + return new RestateCompletedPromise(Promise.reject(reason)); + }, + /** * Creates a Promise that is resolved with an array of results when all of the provided Promises * resolve, or rejected when any Promise is rejected. @@ -717,12 +728,7 @@ export const RestatePromise = { all[]>( values: T ): RestatePromise<{ -readonly [P in keyof T]: Awaited }> { - if (values.length === 0) { - throw new Error( - "Expected combineable promise to have at least one promise" - ); - } - return ContextImpl.createCombinator( + return RestateCombinatorPromise.fromPromises( (p) => Promise.all(p), values ) as RestatePromise<{ @@ -742,12 +748,7 @@ export const RestatePromise = { race[]>( values: T ): RestatePromise> { - if (values.length === 0) { - throw new Error( - "Expected combineable promise to have at least one promise" - ); - } - return ContextImpl.createCombinator( + return RestateCombinatorPromise.fromPromises( (p) => Promise.race(p), values ) as RestatePromise>; @@ -766,12 +767,7 @@ export const RestatePromise = { any[]>( values: T ): RestatePromise> { - if (values.length === 0) { - throw new Error( - "Expected combineable promise to have at least one promise" - ); - } - return ContextImpl.createCombinator( + return RestateCombinatorPromise.fromPromises( (p) => Promise.any(p), values ) as RestatePromise>; @@ -791,12 +787,7 @@ export const RestatePromise = { ): RestatePromise<{ -readonly [P in keyof T]: PromiseSettledResult>; }> { - if (values.length === 0) { - throw new Error( - "Expected combineable promise to have at least one promise" - ); - } - return ContextImpl.createCombinator( + return RestateCombinatorPromise.fromPromises( (p) => Promise.allSettled(p), values ) as RestatePromise<{ diff --git a/packages/libs/restate-sdk/src/context_impl.ts b/packages/libs/restate-sdk/src/context_impl.ts index 8c946a19..6c57d67d 100644 --- a/packages/libs/restate-sdk/src/context_impl.ts +++ b/packages/libs/restate-sdk/src/context_impl.ts @@ -61,13 +61,11 @@ import type { import { millisOrDurationToMillis, serde } from "@restatedev/restate-sdk-core"; import { RandImpl } from "./utils/rand.js"; import { CompletablePromise } from "./utils/completable_promise.js"; -import type { AsyncResultValue, InternalRestatePromise } from "./promises.js"; +import type { AsyncResultValue } from "./promises.js"; import { - extractContext, InvocationPendingPromise, pendingPromise, PromisesExecutor, - RestateCombinatorPromise, RestateInvocationPromise, RestatePendingPromise, RestateSinglePromise, @@ -647,37 +645,6 @@ export class ContextImpl return new DurablePromiseImpl(this, name, serde); } - // Used by static methods of RestatePromise - public static createCombinator[]>( - combinatorConstructor: (promises: Promise[]) => Promise, - promises: T - ): RestatePromise { - // Extract context from first promise - const self = extractContext(promises[0]); - if (!self) { - throw new Error("Not a combinable promise"); - } - - // Collect first the promises downcasted to the internal promise type - const castedPromises: InternalRestatePromise[] = []; - for (const promise of promises) { - if (extractContext(promise) !== self) { - self.handleInvocationEndError( - new Error( - "You're mixing up RestatePromises from different RestateContext. This is not supported." - ) - ); - return new RestatePendingPromise(self); - } - castedPromises.push(promise as InternalRestatePromise); - } - return new RestateCombinatorPromise( - self, - combinatorConstructor, - castedPromises - ); - } - // -- Various private methods private processNonCompletableEntry( diff --git a/packages/libs/restate-sdk/src/promises.ts b/packages/libs/restate-sdk/src/promises.ts index 97e3b0c0..78225ce6 100644 --- a/packages/libs/restate-sdk/src/promises.ts +++ b/packages/libs/restate-sdk/src/promises.ts @@ -43,15 +43,36 @@ enum PromiseState { NOT_COMPLETED, } -export const RESTATE_CTX_SYMBOL = Symbol("restateContext"); +export abstract class InternalRestatePromise implements RestatePromise { + abstract then( + onfulfilled: + | ((value: T) => PromiseLike | TResult1) + | undefined + | null, + onrejected: + | ((reason: any) => PromiseLike | TResult2) + | undefined + | null + ): Promise; + abstract catch( + onrejected: + | ((reason: any) => PromiseLike | TResult) + | undefined + | null + ): Promise; + abstract finally(onfinally: (() => void) | undefined | null): Promise; + + abstract map( + mapper: (value?: T, failure?: TerminalError) => U + ): RestatePromise; + abstract orTimeout(millis: Duration | number): RestatePromise; -export interface InternalRestatePromise extends RestatePromise { - [RESTATE_CTX_SYMBOL]: ContextImpl; + abstract tryCancel(): void; + abstract tryComplete(): Promise; + abstract uncompletedLeaves(): Array; + abstract publicPromise(): Promise; - tryCancel(): void; - tryComplete(): Promise; - uncompletedLeaves(): Array; - publicPromise(): Promise; + abstract readonly [Symbol.toStringTag]: string; } export type AsyncResultValue = @@ -61,17 +82,20 @@ export type AsyncResultValue = | { StateKeys: string[] } | { InvocationId: string }; -export function extractContext(n: any): ContextImpl | undefined { +const RESTATE_CTX_SYMBOL = Symbol("restateContext"); + +function extractContext(n: any): ContextImpl | undefined { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return n[RESTATE_CTX_SYMBOL] as ContextImpl | undefined; } -abstract class AbstractRestatePromise implements InternalRestatePromise { +abstract class BaseRestatePromise extends InternalRestatePromise { [RESTATE_CTX_SYMBOL]: ContextImpl; private pollingPromise?: Promise; private cancelPromise: CompletablePromise = new CompletablePromise(); protected constructor(ctx: ContextImpl) { + super(); this[RESTATE_CTX_SYMBOL] = ctx; } @@ -144,16 +168,16 @@ abstract class AbstractRestatePromise implements InternalRestatePromise { this.cancelPromise.reject(new CancelledError()); } - abstract tryComplete(): Promise; + abstract override tryComplete(): Promise; - abstract uncompletedLeaves(): Array; + abstract override uncompletedLeaves(): Array; - abstract publicPromise(): Promise; + abstract override publicPromise(): Promise; - abstract [Symbol.toStringTag]: string; + abstract override [Symbol.toStringTag]: string; } -export class RestateSinglePromise extends AbstractRestatePromise { +export class RestateSinglePromise extends BaseRestatePromise { private state: PromiseState = PromiseState.NOT_COMPLETED; private completablePromise: CompletablePromise = new CompletablePromise(); @@ -214,7 +238,7 @@ export class RestateInvocationPromise } } -export class RestateCombinatorPromise extends AbstractRestatePromise { +export class RestateCombinatorPromise extends BaseRestatePromise { private state: PromiseState = PromiseState.NOT_COMPLETED; private readonly combinatorPromise: Promise; @@ -231,6 +255,45 @@ export class RestateCombinatorPromise extends AbstractRestatePromise { }); } + // Used by static methods of RestatePromise + public static fromPromises[]>( + combinatorConstructor: (promises: Promise[]) => Promise, + promises: T + ): RestatePromise { + const castedPromises: InternalRestatePromise[] = []; + let foundContext: ContextImpl | undefined = undefined; + + for (const [idx, promise] of promises.entries()) { + if (!(promise instanceof InternalRestatePromise)) { + throw new Error( + `Promise index ${idx} used inside the combinator is not an instance of RestatePromise. This is not supported.` + ); + } else if (foundContext === undefined) { + foundContext = extractContext(promise); + } else { + const thisContext = extractContext(promise); + if (thisContext !== undefined && thisContext !== foundContext) { + throw new Error( + "You're mixing up RestatePromises from different RestateContext. This is not supported." + ); + } + } + castedPromises.push(promise); + } + + if (foundContext === undefined) { + // The only situation where this can happen is when the combined promise contains only RestateCompletedPromise as children. + // In this case, just return back a nice and clean RestateCompletedPromise. + return new RestateCompletedPromise(combinatorConstructor(castedPromises)); + } + + return new RestateCombinatorPromise( + foundContext, + combinatorConstructor, + castedPromises + ); + } + uncompletedLeaves(): number[] { return this.state === PromiseState.COMPLETED ? [] @@ -248,10 +311,11 @@ export class RestateCombinatorPromise extends AbstractRestatePromise { readonly [Symbol.toStringTag] = "RestateCombinatorPromise"; } -export class RestatePendingPromise implements InternalRestatePromise { +export class RestatePendingPromise extends InternalRestatePromise { [RESTATE_CTX_SYMBOL]: ContextImpl; constructor(ctx: ContextImpl) { + super(); this[RESTATE_CTX_SYMBOL] = ctx; } @@ -309,7 +373,7 @@ export class InvocationPendingPromise } } -export class RestateMappedPromise extends AbstractRestatePromise { +export class RestateMappedPromise extends BaseRestatePromise { private publicPromiseMapper: ( value?: T, failure?: TerminalError @@ -361,6 +425,62 @@ export class RestateMappedPromise extends AbstractRestatePromise { readonly [Symbol.toStringTag] = "RestateMappedPromise"; } +export class RestateCompletedPromise extends InternalRestatePromise { + constructor(private readonly completedPromise: Promise) { + super(); + } + + // --- Promise methods + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.completedPromise.then(onfulfilled, onrejected); + } + + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.completedPromise.catch(onrejected); + } + + finally(onfinally?: (() => void) | null): Promise { + return this.completedPromise.finally(onfinally); + } + + // --- RestatePromise methods + + orTimeout(): RestatePromise { + return this; // Timeout never kicks in! + } + + map(mapper: (value?: T, failure?: TerminalError) => U): RestatePromise { + return new RestateCompletedPromise( + this.completedPromise.then( + (value) => mapper(value, undefined), + (reason) => mapper(undefined, reason as TerminalError) + ) + ); + } + + tryCancel() {} + + publicPromise(): Promise { + return this.completedPromise; + } + + tryComplete(): Promise { + return Promise.resolve(); + } + + uncompletedLeaves(): Array { + return []; + } + + readonly [Symbol.toStringTag] = "RestateCombinatorPromise"; +} + /** * Promises executor, gluing VM with I/O and Promises given to user space. */ @@ -388,7 +508,7 @@ export class PromisesExecutor { } catch (e) { // This can happen if either take_notification throws an exception or completer throws an exception. // This could either happen for a deserialization issue, or for an SDK bug, but we cover them here. - restatePromise[RESTATE_CTX_SYMBOL].handleInvocationEndError(e); + this.errorCallback(e); return Promise.resolve(); } From 217e7719b291536e056967916245b1a9e26ba65f Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Tue, 7 Apr 2026 18:34:13 +0200 Subject: [PATCH 02/10] Add tests using the Custom test feature from sdk-test-suite See https://github.com/restatedev/sdk-test-suite/pull/72 These tests will auto-run on CI --- .../restate-e2e-services/custom_tests.yaml | 3 + .../tests/restate-e2e-services/package.json | 1 - .../tests/restate-e2e-services/src/app.ts | 12 +- .../src/promise_combinators.ts | 137 ++++++++++++++++++ .../restate-e2e-services/src/services.ts | 6 + .../test/promise_combinators.test.ts | 117 +++++++++++++++ .../tests/restate-e2e-services/test/utils.ts | 30 ++++ 7 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 packages/tests/restate-e2e-services/custom_tests.yaml create mode 100644 packages/tests/restate-e2e-services/src/promise_combinators.ts create mode 100644 packages/tests/restate-e2e-services/test/promise_combinators.test.ts create mode 100644 packages/tests/restate-e2e-services/test/utils.ts diff --git a/packages/tests/restate-e2e-services/custom_tests.yaml b/packages/tests/restate-e2e-services/custom_tests.yaml new file mode 100644 index 00000000..214923cb --- /dev/null +++ b/packages/tests/restate-e2e-services/custom_tests.yaml @@ -0,0 +1,3 @@ +tests: + - name: "all vitest" + command: "pnpm --filter @restatedev/restate-e2e-services run _test" diff --git a/packages/tests/restate-e2e-services/package.json b/packages/tests/restate-e2e-services/package.json index 235c7da4..8a0046ee 100644 --- a/packages/tests/restate-e2e-services/package.json +++ b/packages/tests/restate-e2e-services/package.json @@ -18,7 +18,6 @@ "scripts": { "build": "turbo run _build --filter={.}...", "_build": "tsc -b tsconfig.test.json", - "test": "turbo run _test --filter={.}...", "_test": "vitest run", "test:watch": "vitest watch", "clean": "rm -rf dist *.tsbuildinfo .turbo", diff --git a/packages/tests/restate-e2e-services/src/app.ts b/packages/tests/restate-e2e-services/src/app.ts index 88e75d3e..86baa129 100644 --- a/packages/tests/restate-e2e-services/src/app.ts +++ b/packages/tests/restate-e2e-services/src/app.ts @@ -24,6 +24,7 @@ import "./proxy.js"; import "./test_utils.js"; import "./kill.js"; import "./virtual_object_command_interpreter.js"; +import "./promise_combinators.js"; import * as http2 from "http2"; import * as heapdump from "heapdump"; import path from "path"; @@ -41,12 +42,13 @@ process.on("SIGUSR2", () => { import { REGISTRY } from "./services.js"; -if (!process.env.SERVICES) { - throw new Error("Cannot find SERVICES env"); -} -const fqdns = new Set(process.env.SERVICES.split(",")); const endpoint = restate.endpoint(); -REGISTRY.register(fqdns, endpoint); +if (!process.env.SERVICES || process.env.SERVICES == "*") { + REGISTRY.registerAll(endpoint); +} else { + const fqdns = new Set(process.env.SERVICES.split(",")); + REGISTRY.register(fqdns, endpoint); +} const settings: http2.Settings = {}; if (process.env.MAX_CONCURRENT_STREAMS) { diff --git a/packages/tests/restate-e2e-services/src/promise_combinators.ts b/packages/tests/restate-e2e-services/src/promise_combinators.ts new file mode 100644 index 00000000..d5051600 --- /dev/null +++ b/packages/tests/restate-e2e-services/src/promise_combinators.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate e2e tests, +// which are 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/e2e/blob/main/LICENSE + +import * as restate from "@restatedev/restate-sdk"; +import { REGISTRY } from "./services.js"; + +const promiseCombinators = restate.service({ + name: "PromiseCombinators", + handlers: { + // --- RestatePromise.resolve / reject --- + + resolveWithValue: async ( + _ctx: restate.Context, + value: string + ): Promise => { + return RestatePromise.resolve(value); + }, + + rejectWithTerminalError: async ( + _ctx: restate.Context, + message: string + ): Promise => { + return RestatePromise.reject(new restate.TerminalError(message)); + }, + + // --- Combinators with RestatePromise.resolve/reject --- + + allWithResolvedPromises: async ( + _ctx: restate.Context, + values: string[] + ): Promise => { + const promises = values.map((v) => RestatePromise.resolve(v)); + return RestatePromise.all(promises); + }, + + allWithOneRejected: async ( + _ctx: restate.Context, + input: { values: string[]; rejectIndex: number; errorMessage: string } + ): Promise => { + const promises = input.values.map((v, i) => + i === input.rejectIndex + ? RestatePromise.reject( + new restate.TerminalError(input.errorMessage) + ) + : RestatePromise.resolve(v) + ); + return RestatePromise.all(promises); + }, + + raceWithResolvedPromises: async ( + _ctx: restate.Context, + values: string[] + ): Promise => { + const promises = values.map((v) => RestatePromise.resolve(v)); + return RestatePromise.race(promises); + }, + + anyWithResolvedPromises: async ( + _ctx: restate.Context, + values: string[] + ): Promise => { + const promises = values.map((v) => RestatePromise.resolve(v)); + return RestatePromise.any(promises); + }, + + anyWithAllRejected: async ( + _ctx: restate.Context, + messages: string[] + ): Promise => { + const promises = messages.map((m) => + RestatePromise.reject(new restate.TerminalError(m)) + ); + return RestatePromise.any(promises); + }, + + allSettledMixed: async ( + _ctx: restate.Context, + input: { values: string[]; rejectIndices: number[] } + ): Promise[]> => { + const rejectSet = new Set(input.rejectIndices); + const promises = input.values.map((v, i) => + rejectSet.has(i) + ? RestatePromise.reject(new restate.TerminalError(v)) + : RestatePromise.resolve(v) + ); + return RestatePromise.allSettled(promises); + }, + + // --- Empty array combinators --- + + allEmpty: async (_ctx: restate.Context): Promise => { + return RestatePromise.all([]); + }, + + allSettledEmpty: async ( + _ctx: restate.Context + ): Promise[]> => { + return RestatePromise.allSettled([]); + }, + + // --- Mixed: context promises + resolved/rejected --- + + allMixedWithSleep: async ( + ctx: restate.Context, + input: { sleepMs: number; resolvedValue: string } + ): Promise<[string, string]> => { + const sleepPromise = ctx + .sleep(input.sleepMs) + .map(() => "slept" as string); + const resolvedPromise = RestatePromise.resolve(input.resolvedValue); + return RestatePromise.all([sleepPromise, resolvedPromise]); + }, + + raceMixedWithSleep: async ( + ctx: restate.Context, + input: { sleepMs: number; resolvedValue: string } + ): Promise => { + const sleepPromise = ctx + .sleep(input.sleepMs) + .map(() => "slept" as string); + const resolvedPromise = RestatePromise.resolve(input.resolvedValue); + return RestatePromise.race([sleepPromise, resolvedPromise]); + }, + }, +}); + +const { RestatePromise } = restate; + +REGISTRY.addService(promiseCombinators); + +export type PromiseCombinators = typeof promiseCombinators; diff --git a/packages/tests/restate-e2e-services/src/services.ts b/packages/tests/restate-e2e-services/src/services.ts index ce2a5690..778c4137 100644 --- a/packages/tests/restate-e2e-services/src/services.ts +++ b/packages/tests/restate-e2e-services/src/services.ts @@ -47,6 +47,12 @@ export class ComponentRegistry { }); } + registerAll(e: RestateEndpoint) { + this.components.forEach((svc) => { + svc.binder(e); + }); + } + register(fqdns: Set, e: RestateEndpoint) { fqdns.forEach((fqdn) => { const c = this.components.get(fqdn); diff --git a/packages/tests/restate-e2e-services/test/promise_combinators.test.ts b/packages/tests/restate-e2e-services/test/promise_combinators.test.ts new file mode 100644 index 00000000..a195f3f0 --- /dev/null +++ b/packages/tests/restate-e2e-services/test/promise_combinators.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate e2e tests, +// which are 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/e2e/blob/main/LICENSE + +import { describe, it, expect } from "vitest"; +import { ingressClient } from "./utils.js"; +import type { PromiseCombinators } from "../src/promise_combinators.js"; + +const PromiseCombinators: PromiseCombinators = { + name: "PromiseCombinators", +}; + +describe("PromiseCombinators", () => { + const ingress = ingressClient(); + const client = ingress.serviceClient(PromiseCombinators); + + // --- RestatePromise.resolve --- + + it("resolve returns the value", async () => { + const result = await client.resolveWithValue("hello"); + expect(result).toBe("hello"); + }); + + // --- RestatePromise.reject --- + + it("reject throws TerminalError", async () => { + await expect(client.rejectWithTerminalError("boom")).rejects.toThrow( + "boom" + ); + }); + + // --- RestatePromise.all with resolved promises --- + + it("all with resolved promises returns all values", async () => { + const result = await client.allWithResolvedPromises(["a", "b", "c"]); + expect(result).toEqual(["a", "b", "c"]); + }); + + it("all with one rejected propagates the rejection", async () => { + await expect( + client.allWithOneRejected({ + values: ["a", "b", "c"], + rejectIndex: 1, + errorMessage: "fail at 1", + }) + ).rejects.toThrow("fail at 1"); + }); + + // --- RestatePromise.race with resolved promises --- + + it("race with resolved promises returns first value", async () => { + const result = await client.raceWithResolvedPromises(["first", "second"]); + expect(result).toBe("first"); + }); + + // --- RestatePromise.any --- + + it("any with resolved promises returns first fulfilled", async () => { + const result = await client.anyWithResolvedPromises(["x", "y"]); + expect(result).toBe("x"); + }); + + // TODO: Skipped - AggregateError from Promise.any is not converted to TerminalError by the SDK. + // See: https://github.com/restatedev/sdk-typescript/issues/672 + it.skip("any with all rejected throws", async () => { + await expect(client.anyWithAllRejected(["err1", "err2"])).rejects.toThrow(); + }); + + // --- RestatePromise.allSettled mixed --- + + it("allSettled with mixed resolved and rejected", async () => { + const result = await client.allSettledMixed({ + values: ["ok", "fail", "ok2"], + rejectIndices: [1], + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ status: "fulfilled", value: "ok" }); + expect(result[1]).toMatchObject({ status: "rejected" }); + expect(result[2]).toEqual({ status: "fulfilled", value: "ok2" }); + }); + + // --- Empty array combinators --- + + it("all with empty array returns empty array", async () => { + const result = await client.allEmpty(); + expect(result).toEqual([]); + }); + + it("allSettled with empty array returns empty array", async () => { + const result = await client.allSettledEmpty(); + expect(result).toEqual([]); + }); + + // --- Mixed context promises + resolved/rejected --- + + it("all mixed with sleep and resolved promise", async () => { + const result = await client.allMixedWithSleep({ + sleepMs: 100, + resolvedValue: "instant", + }); + expect(result).toEqual(["slept", "instant"]); + }); + + it("race mixed: resolved promise wins over sleep", async () => { + const result = await client.raceMixedWithSleep({ + sleepMs: 60000, + resolvedValue: "instant", + }); + expect(result).toBe("instant"); + }); +}); diff --git a/packages/tests/restate-e2e-services/test/utils.ts b/packages/tests/restate-e2e-services/test/utils.ts new file mode 100644 index 00000000..46119b63 --- /dev/null +++ b/packages/tests/restate-e2e-services/test/utils.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate e2e tests, +// which are 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/e2e/blob/main/LICENSE + +import * as restate from "@restatedev/restate-sdk-clients"; + +export function getIngressUrl(): string { + const url = process.env.RESTATE_INGRESS_URL; + if (!url) { + throw new Error("RESTATE_INGRESS_URL environment variable is not set"); + } + return url; +} + +export function getAdminUrl(): string { + const url = process.env.RESTATE_ADMIN_URL; + if (!url) { + throw new Error("RESTATE_ADMIN_URL environment variable is not set"); + } + return url; +} + +export function ingressClient() { + return restate.connect({ url: getIngressUrl() }); +} From beaba02cb658929428dec90866a24f9d7a711e1f Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Tue, 7 Apr 2026 18:34:23 +0200 Subject: [PATCH 03/10] Assorted AI slop --- .claude/skills/add-config-option/SKILL.md | 46 +++++++++ .claude/skills/add-e2e-test/SKILL.md | 120 ++++++++++++++++++++++ CLAUDE.md | 1 + 3 files changed, 167 insertions(+) create mode 100644 .claude/skills/add-config-option/SKILL.md create mode 100644 .claude/skills/add-e2e-test/SKILL.md create mode 120000 CLAUDE.md diff --git a/.claude/skills/add-config-option/SKILL.md b/.claude/skills/add-config-option/SKILL.md new file mode 100644 index 00000000..4e1eb6a2 --- /dev/null +++ b/.claude/skills/add-config-option/SKILL.md @@ -0,0 +1,46 @@ +--- +name: add-config-option +description: When the user asks to add a new option/field/config to service, handler, endpoint, ServiceOptions, HandlerOpts, ObjectOptions, WorkflowOptions, or the discovery schema +user-invocable: false +--- + +# Adding a config option to the Restate TypeScript SDK + +There are two kinds of config options: + +1. **Discovery options** — sent to the Restate server during service discovery (e.g. `ingressPrivate`, `enableLazyState`, timeouts). These need to be in the discovery schema. +2. **Runtime-only options** — used only by the SDK at execution time, never sent to the server (e.g. `asTerminalError`, `serde`). These skip the discovery layer entirely. + +Ask the user which kind if unclear. + +## All options: Type definitions — `packages/libs/restate-sdk/src/types/rpc.ts` + +1. **`ServiceHandlerOpts`** — add the field with JSDoc. All handler types inherit this. + - If object/workflow-only (like `enableLazyState`): add to `ObjectHandlerOpts` / `WorkflowHandlerOpts` instead. +2. **`ServiceOptions`** — add the field with JSDoc. + - If object/workflow-only: add to `ObjectOptions` / `WorkflowOptions` instead. + - `DefaultServiceOptions` in `endpoint.ts` = `ServiceOptions & ObjectOptions & WorkflowOptions`, so endpoint-level gets it free. +3. **`HandlerWrapper.from()`** — add `opts?.fieldName` to the positional constructor call. + - Object/workflow-only fields: `opts !== undefined && "fieldName" in opts ? opts?.fieldName : undefined` +4. **`HandlerWrapper` constructor** — add `public readonly fieldName?: Type` parameter. + +## Discovery options only: Wire through discovery + +### `packages/libs/restate-sdk/src/endpoint/discovery.ts` + +Add the field to both **`Service`** and **`Handler`** interfaces. Use wire types (`number` for millis, `boolean` for flags). + +### `packages/libs/restate-sdk/src/endpoint/components.ts` + +- **`commonServiceOptions()`**: `fieldName: options?.fieldName,` +- **`commonHandlerOptions()`**: `fieldName: wrapper.fieldName,` +- Durations: wrap with `millisOrDurationToMillis()` + `!== undefined` guard +- Object/workflow-only in `commonServiceOptions`: `"fieldName" in options` guard + +## Runtime-only options: Wire through execution + +Options that affect handler execution but not discovery (like `asTerminalError`, `serde`) just need to be read where the handler is invoked. Check how existing runtime options are consumed in `components.ts` handler classes. + +## Verification + +Run `npx tsc --noEmit` from `packages/libs/restate-sdk/`. \ No newline at end of file diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md new file mode 100644 index 00000000..85f9049b --- /dev/null +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -0,0 +1,120 @@ +--- +name: add-e2e-test +description: When the user asks to add a new e2e test to the restate-e2e-services package +user-invocable: true +--- + +# Adding an e2e test to restate-e2e-services + +All e2e test infrastructure lives under `packages/tests/restate-e2e-services/`. These tests are NOT run directly — they are executed by the Java e2e test runner which spins up Restate, deploys services, and injects `RESTATE_INGRESS_URL` and `RESTATE_ADMIN_URL` environment variables. + +## Architecture + +- **Services** (`src/`): Restate service definitions (the SUT). Registered via `REGISTRY` and imported in `app.ts`. +- **Tests** (`test/`): Vitest test files that call services through the ingress client. +- **Test config** (`custom_tests.yaml`): YAML config read by the Java e2e test runner to know which commands to execute. +- **Shared utils** (`test/utils.ts`): Provides `ingressClient()`, `getIngressUrl()`, `getAdminUrl()` — reads from env vars injected by the test runner. + +## Steps to add a new e2e test + +### 1. Create the service under test in `src/` + +Create `src/.ts` following this pattern: + +```typescript +import * as restate from "@restatedev/restate-sdk"; +import { REGISTRY } from "./services.js"; + +const myService = restate.service({ + name: "MyService", + handlers: { + myHandler: async (ctx: restate.Context, input: string): Promise => { + // handler logic + return `result: ${input}`; + }, + }, +}); + +REGISTRY.addService(myService); +// Use REGISTRY.addObject() for virtual objects, REGISTRY.addWorkflow() for workflows + +export type MyService = typeof myService; +``` + +Key points: +- Always register with `REGISTRY` so the service is discoverable +- Always export the type (`export type MyService = typeof myService`) so tests can import it for typed client calls + +### 2. Import the service in `src/app.ts` + +Add an import line alongside the other service imports: + +```typescript +import "./my_service.js"; +``` + +### 3. Create the test file in `test/` + +Create `test/.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { ingressClient } from "./utils.js"; +import type { MyService } from "../src/my_service.js"; + +const MyService: MyService = { name: "MyService" }; + +describe("MyService", () => { + it("should do something", async () => { + const ingress = ingressClient(); + const client = ingress.serviceClient(MyService); + + const result = await client.myHandler("input"); + + expect(result).toBe("result: input"); + }); +}); +``` + +Key points: +- Import the service TYPE from `../src/` for typed ingress client calls +- Create a const with `{ name: "ServiceName" }` matching the service's registered name +- Use `ingressClient()` from `./utils.js` — never hardcode URLs +- For virtual objects use `ingress.objectClient(MyObject, "key")` +- For workflows use `ingress.workflowClient(MyWorkflow, "workflowId")` + +### 4. Type-check + +Run from repo root: + +```bash +pnpm --filter @restatedev/restate-e2e-services run _check:types +``` + +## Available client patterns in tests + +From `@restatedev/restate-sdk-clients`: + +```typescript +const ingress = ingressClient(); + +// Service call +const svc = ingress.serviceClient(MyService); +await svc.handler(input); + +// Virtual object call +const obj = ingress.objectClient(MyObject, "key"); +await obj.handler(input); + +// Workflow +const wf = ingress.workflowClient(MyWorkflow, "wfId"); +await wf.workflowSubmit(input); +await wf.workflowAttach(); + +// Send (fire and forget) +const send = ingress.serviceSendClient(MyService); +await send.handler(input); + +// Idempotent call +await svc.handler(input, restate.rpc.opts({ idempotencyKey: "key" })); +``` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 1b422d0010ed0ba51e03789893e0cd9ccf4789ea Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Tue, 7 Apr 2026 19:54:08 +0200 Subject: [PATCH 04/10] Use test suite 4.1 --- .github/workflows/integration.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index d02a4d5c..e048a789 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -133,11 +133,12 @@ jobs: cache-to: type=gha,url=http://127.0.0.1:49160/,mode=max,version=1,scope=${{ github.workflow }} - name: Run test tool - uses: restatedev/sdk-test-suite@v4.0 + uses: restatedev/sdk-test-suite@v4.1 with: restateContainerImage: ${{ inputs.restateCommit != '' && 'localhost/restatedev/restate-commit-download:latest' || (inputs.restateImage != '' && inputs.restateImage || 'ghcr.io/restatedev/restate:main') }} serviceContainerImage: ${{ inputs.serviceImage != '' && inputs.serviceImage || 'restatedev/typescript-test-services' }} exclusionsFile: "packages/tests/restate-e2e-services/exclusions.yaml" + customTestsFile: "packages/tests/restate-e2e-services/custom_tests.yaml" envVars: ${{ inputs.envVars }} testArtifactOutput: ${{ inputs.testArtifactOutput != '' && inputs.testArtifactOutput || 'sdk-typescript-integration-test-report' }} serviceContainerEnvFile: "packages/tests/restate-e2e-services/.env" From 171f2f673ac17129f31fd5a666ca1b2329c0728f Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Wed, 8 Apr 2026 09:13:04 +0200 Subject: [PATCH 05/10] Fix scripts name --- packages/tests/restate-e2e-services/custom_tests.yaml | 2 +- packages/tests/restate-e2e-services/package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/tests/restate-e2e-services/custom_tests.yaml b/packages/tests/restate-e2e-services/custom_tests.yaml index 214923cb..06eec779 100644 --- a/packages/tests/restate-e2e-services/custom_tests.yaml +++ b/packages/tests/restate-e2e-services/custom_tests.yaml @@ -1,3 +1,3 @@ tests: - name: "all vitest" - command: "pnpm --filter @restatedev/restate-e2e-services run _test" + command: "pnpm --filter @restatedev/restate-e2e-services run testrunner" diff --git a/packages/tests/restate-e2e-services/package.json b/packages/tests/restate-e2e-services/package.json index 8a0046ee..df3bb8a2 100644 --- a/packages/tests/restate-e2e-services/package.json +++ b/packages/tests/restate-e2e-services/package.json @@ -18,8 +18,7 @@ "scripts": { "build": "turbo run _build --filter={.}...", "_build": "tsc -b tsconfig.test.json", - "_test": "vitest run", - "test:watch": "vitest watch", + "testrunner": "vitest run", "clean": "rm -rf dist *.tsbuildinfo .turbo", "check:types": "turbo run _check:types --filter={.}...", "_check:types": "tsc --noEmit --project tsconfig.test.json", From 2f0a8985f67e102dfbe9f5d0c8220b821bd96bfc Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Wed, 8 Apr 2026 09:37:09 +0200 Subject: [PATCH 06/10] Install node and pnpm --- .github/workflows/integration.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index e048a789..4ffcbc83 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -61,12 +61,27 @@ jobs: checks: write pull-requests: write actions: read + strategy: + matrix: + node-version: [ 20.x ] steps: - uses: actions/checkout@v4 with: repository: restatedev/sdk-typescript + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + registry-url: "https://registry.npmjs.org" + - uses: pnpm/action-setup@v4 + with: + version: 10.13.1 + - run: pnpm install --frozen-lockfile + - run: pnpm build + # support importing oci-format restate.tar - name: Set up Docker containerd snapshotter uses: docker/setup-docker-action@v4 From 8b26147d96b3335936fbc45f6a22af7a2bc403cc Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Wed, 8 Apr 2026 17:29:52 +0200 Subject: [PATCH 07/10] RestatePromise.resolve() now handles RestatePromise as first param --- packages/libs/restate-sdk/src/context.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/libs/restate-sdk/src/context.ts b/packages/libs/restate-sdk/src/context.ts index 0a7d4728..1096ee38 100644 --- a/packages/libs/restate-sdk/src/context.ts +++ b/packages/libs/restate-sdk/src/context.ts @@ -27,6 +27,7 @@ import type { } from "@restatedev/restate-sdk-core"; import type { TerminalError } from "./types/errors.js"; import { + InternalRestatePromise, RestateCombinatorPromise, RestateCompletedPromise, } from "./promises.js"; @@ -708,10 +709,28 @@ export type InvocationHandle = { export type InvocationPromise = RestatePromise & InvocationHandle; export const RestatePromise = { + /** + * Creates a {@link RestatePromise} that is resolved with the given value. + * + * If the value is already a {@link RestatePromise}, it is returned as-is (mirroring + * the behavior of {@link Promise.resolve} when given a native {@link Promise}). + * + * @param value The value to resolve, or an existing {@link RestatePromise} to return. + * @returns A resolved {@link RestatePromise}, or the input promise unchanged. + */ resolve(value: T): RestatePromise> { + if (value instanceof InternalRestatePromise) { + return value as unknown as RestatePromise>; + } return new RestateCompletedPromise(Promise.resolve(value)); }, + /** + * Creates a {@link RestatePromise} that is rejected with the given {@link TerminalError}. + * + * @param reason The {@link TerminalError} to reject with. + * @returns A rejected {@link RestatePromise}. + */ reject(reason: TerminalError): RestatePromise { return new RestateCompletedPromise(Promise.reject(reason)); }, From 4e9826b513681ab3a31bdd8304a7db0804cf845e Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Wed, 8 Apr 2026 17:37:26 +0200 Subject: [PATCH 08/10] Renames --- packages/libs/restate-sdk/src/context.ts | 16 ++++----- packages/libs/restate-sdk/src/context_impl.ts | 34 +++++++++---------- packages/libs/restate-sdk/src/promises.ts | 28 +++++++-------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/libs/restate-sdk/src/context.ts b/packages/libs/restate-sdk/src/context.ts index 1096ee38..93a0a9f8 100644 --- a/packages/libs/restate-sdk/src/context.ts +++ b/packages/libs/restate-sdk/src/context.ts @@ -28,8 +28,8 @@ import type { import type { TerminalError } from "./types/errors.js"; import { InternalRestatePromise, - RestateCombinatorPromise, - RestateCompletedPromise, + CombinatorRestatePromise, + CompletedRestatePromise, } from "./promises.js"; /** @@ -722,7 +722,7 @@ export const RestatePromise = { if (value instanceof InternalRestatePromise) { return value as unknown as RestatePromise>; } - return new RestateCompletedPromise(Promise.resolve(value)); + return new CompletedRestatePromise(Promise.resolve(value)); }, /** @@ -732,7 +732,7 @@ export const RestatePromise = { * @returns A rejected {@link RestatePromise}. */ reject(reason: TerminalError): RestatePromise { - return new RestateCompletedPromise(Promise.reject(reason)); + return new CompletedRestatePromise(Promise.reject(reason)); }, /** @@ -747,7 +747,7 @@ export const RestatePromise = { all[]>( values: T ): RestatePromise<{ -readonly [P in keyof T]: Awaited }> { - return RestateCombinatorPromise.fromPromises( + return CombinatorRestatePromise.fromPromises( (p) => Promise.all(p), values ) as RestatePromise<{ @@ -767,7 +767,7 @@ export const RestatePromise = { race[]>( values: T ): RestatePromise> { - return RestateCombinatorPromise.fromPromises( + return CombinatorRestatePromise.fromPromises( (p) => Promise.race(p), values ) as RestatePromise>; @@ -786,7 +786,7 @@ export const RestatePromise = { any[]>( values: T ): RestatePromise> { - return RestateCombinatorPromise.fromPromises( + return CombinatorRestatePromise.fromPromises( (p) => Promise.any(p), values ) as RestatePromise>; @@ -806,7 +806,7 @@ export const RestatePromise = { ): RestatePromise<{ -readonly [P in keyof T]: PromiseSettledResult>; }> { - return RestateCombinatorPromise.fromPromises( + return CombinatorRestatePromise.fromPromises( (p) => Promise.allSettled(p), values ) as RestatePromise<{ diff --git a/packages/libs/restate-sdk/src/context_impl.ts b/packages/libs/restate-sdk/src/context_impl.ts index 6c57d67d..24dc159f 100644 --- a/packages/libs/restate-sdk/src/context_impl.ts +++ b/packages/libs/restate-sdk/src/context_impl.ts @@ -63,12 +63,12 @@ import { RandImpl } from "./utils/rand.js"; import { CompletablePromise } from "./utils/completable_promise.js"; import type { AsyncResultValue } from "./promises.js"; import { - InvocationPendingPromise, + PendingInvocationRestatePromise, pendingPromise, PromisesExecutor, - RestateInvocationPromise, - RestatePendingPromise, - RestateSinglePromise, + InvocationRestatePromise, + PendingRestatePromise, + SingleRestatePromise, } from "./promises.js"; import { InputPump, OutputPump } from "./io.js"; import type { ContextInternal } from "./internal.js"; @@ -241,7 +241,7 @@ export class ContextImpl WasmCommandType.Call ) ); - return new InvocationPendingPromise(this); + return new PendingInvocationRestatePromise(this); } try { @@ -260,7 +260,7 @@ export class ContextImpl ); const commandIndex = this.coreVm.last_command_index(); - const invocationIdPromise = new RestateSinglePromise( + const invocationIdPromise = new SingleRestatePromise( this, call_handles.invocation_id_completion_id, completeCommandPromiseUsing( @@ -270,7 +270,7 @@ export class ContextImpl ) ); - return new RestateInvocationPromise( + return new InvocationRestatePromise( this, call_handles.call_completion_id, completeCommandPromiseUsing( @@ -284,7 +284,7 @@ export class ContextImpl } catch (e) { this.handleInvocationEndError(e); // We return a pending promise to avoid the caller to see the error. - return new InvocationPendingPromise(this); + return new PendingInvocationRestatePromise(this); } } @@ -306,7 +306,7 @@ export class ContextImpl WasmCommandType.OneWayCall ) ); - return new InvocationPendingPromise(this); + return new PendingInvocationRestatePromise(this); } try { @@ -332,7 +332,7 @@ export class ContextImpl const commandIndex = this.coreVm.last_command_index(); return { - invocationId: new RestateSinglePromise( + invocationId: new SingleRestatePromise( this, handles.invocation_id_completion_id, completeCommandPromiseUsing( @@ -439,7 +439,7 @@ export class ContextImpl handle = this.coreVm.sys_run(name ?? ""); } catch (e) { this.handleInvocationEndError(e); - return new RestatePendingPromise(this); + return new PendingRestatePromise(this); } const commandIndex = this.coreVm.last_command_index(); @@ -539,7 +539,7 @@ export class ContextImpl // TODO: here as well // Return the promise - return new RestateSinglePromise( + return new SingleRestatePromise( this, handle, completeCommandPromiseUsing( @@ -587,13 +587,13 @@ export class ContextImpl this.handleInvocationEndError(e); return { id: "invalid", - promise: new RestatePendingPromise(this), + promise: new PendingRestatePromise(this), }; } return { id: awakeable.id, - promise: new RestateSinglePromise( + promise: new SingleRestatePromise( this, awakeable.handle, completeSignalPromiseUsing( @@ -690,7 +690,7 @@ export class ContextImpl commandType ) ); - return new RestatePendingPromise(this); + return new PendingRestatePromise(this); } let handle: number; @@ -698,10 +698,10 @@ export class ContextImpl handle = vmCall(this.coreVm, input); } catch (e) { this.handleInvocationEndError(e); - return new RestatePendingPromise(this); + return new PendingRestatePromise(this); } const commandIndex = this.coreVm.last_command_index(); - return new RestateSinglePromise( + return new SingleRestatePromise( this, handle, completeCommandPromiseUsing(commandType, commandIndex, ...completers) diff --git a/packages/libs/restate-sdk/src/promises.ts b/packages/libs/restate-sdk/src/promises.ts index 78225ce6..7cf1ef26 100644 --- a/packages/libs/restate-sdk/src/promises.ts +++ b/packages/libs/restate-sdk/src/promises.ts @@ -143,7 +143,7 @@ abstract class BaseRestatePromise extends InternalRestatePromise { // --- RestatePromise methods orTimeout(duration: number | Duration): RestatePromise { - return new RestateCombinatorPromise( + return new CombinatorRestatePromise( this[RESTATE_CTX_SYMBOL], ([thisPromise, sleepPromise]) => { return new Promise((resolve, reject) => { @@ -161,7 +161,7 @@ abstract class BaseRestatePromise extends InternalRestatePromise { } map(mapper: (value?: T, failure?: TerminalError) => U): RestatePromise { - return new RestateMappedPromise(this[RESTATE_CTX_SYMBOL], this, mapper); + return new MappedRestatePromise(this[RESTATE_CTX_SYMBOL], this, mapper); } tryCancel() { @@ -177,7 +177,7 @@ abstract class BaseRestatePromise extends InternalRestatePromise { abstract override [Symbol.toStringTag]: string; } -export class RestateSinglePromise extends BaseRestatePromise { +export class SingleRestatePromise extends BaseRestatePromise { private state: PromiseState = PromiseState.NOT_COMPLETED; private completablePromise: CompletablePromise = new CompletablePromise(); @@ -217,8 +217,8 @@ export class RestateSinglePromise extends BaseRestatePromise { readonly [Symbol.toStringTag] = "RestateSinglePromise"; } -export class RestateInvocationPromise - extends RestateSinglePromise +export class InvocationRestatePromise + extends SingleRestatePromise implements InvocationPromise { constructor( @@ -238,7 +238,7 @@ export class RestateInvocationPromise } } -export class RestateCombinatorPromise extends BaseRestatePromise { +export class CombinatorRestatePromise extends BaseRestatePromise { private state: PromiseState = PromiseState.NOT_COMPLETED; private readonly combinatorPromise: Promise; @@ -284,10 +284,10 @@ export class RestateCombinatorPromise extends BaseRestatePromise { if (foundContext === undefined) { // The only situation where this can happen is when the combined promise contains only RestateCompletedPromise as children. // In this case, just return back a nice and clean RestateCompletedPromise. - return new RestateCompletedPromise(combinatorConstructor(castedPromises)); + return new CompletedRestatePromise(combinatorConstructor(castedPromises)); } - return new RestateCombinatorPromise( + return new CombinatorRestatePromise( foundContext, combinatorConstructor, castedPromises @@ -311,7 +311,7 @@ export class RestateCombinatorPromise extends BaseRestatePromise { readonly [Symbol.toStringTag] = "RestateCombinatorPromise"; } -export class RestatePendingPromise extends InternalRestatePromise { +export class PendingRestatePromise extends InternalRestatePromise { [RESTATE_CTX_SYMBOL]: ContextImpl; constructor(ctx: ContextImpl) { @@ -360,8 +360,8 @@ export class RestatePendingPromise extends InternalRestatePromise { readonly [Symbol.toStringTag] = "RestatePendingPromise"; } -export class InvocationPendingPromise - extends RestatePendingPromise +export class PendingInvocationRestatePromise + extends PendingRestatePromise implements InvocationPromise { constructor(ctx: ContextImpl) { @@ -373,7 +373,7 @@ export class InvocationPendingPromise } } -export class RestateMappedPromise extends BaseRestatePromise { +export class MappedRestatePromise extends BaseRestatePromise { private publicPromiseMapper: ( value?: T, failure?: TerminalError @@ -425,7 +425,7 @@ export class RestateMappedPromise extends BaseRestatePromise { readonly [Symbol.toStringTag] = "RestateMappedPromise"; } -export class RestateCompletedPromise extends InternalRestatePromise { +export class CompletedRestatePromise extends InternalRestatePromise { constructor(private readonly completedPromise: Promise) { super(); } @@ -456,7 +456,7 @@ export class RestateCompletedPromise extends InternalRestatePromise { } map(mapper: (value?: T, failure?: TerminalError) => U): RestatePromise { - return new RestateCompletedPromise( + return new CompletedRestatePromise( this.completedPromise.then( (value) => mapper(value, undefined), (reason) => mapper(undefined, reason as TerminalError) From 1abcf8e6840fd01d274455cf40c87922405e3387 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Thu, 9 Apr 2026 11:48:33 +0200 Subject: [PATCH 09/10] Replace `CompletedRestatePromise` with `ConstRestatePromise` and remove `PendingRestatePromise` (now covered by `ConstRestatePromise`) Fix `orTimeout` behavior. --- packages/libs/restate-sdk/src/context.ts | 11 +- packages/libs/restate-sdk/src/context_impl.ts | 17 ++- packages/libs/restate-sdk/src/promises.ts | 121 +++++++----------- 3 files changed, 60 insertions(+), 89 deletions(-) diff --git a/packages/libs/restate-sdk/src/context.ts b/packages/libs/restate-sdk/src/context.ts index 93a0a9f8..7f6e89a1 100644 --- a/packages/libs/restate-sdk/src/context.ts +++ b/packages/libs/restate-sdk/src/context.ts @@ -29,7 +29,7 @@ import type { TerminalError } from "./types/errors.js"; import { InternalRestatePromise, CombinatorRestatePromise, - CompletedRestatePromise, + ConstRestatePromise, } from "./promises.js"; /** @@ -722,7 +722,7 @@ export const RestatePromise = { if (value instanceof InternalRestatePromise) { return value as unknown as RestatePromise>; } - return new CompletedRestatePromise(Promise.resolve(value)); + return ConstRestatePromise.resolve(value); }, /** @@ -732,7 +732,7 @@ export const RestatePromise = { * @returns A rejected {@link RestatePromise}. */ reject(reason: TerminalError): RestatePromise { - return new CompletedRestatePromise(Promise.reject(reason)); + return ConstRestatePromise.reject(reason); }, /** @@ -767,6 +767,9 @@ export const RestatePromise = { race[]>( values: T ): RestatePromise> { + if (values.length === 0) { + return ConstRestatePromise.pending(); + } return CombinatorRestatePromise.fromPromises( (p) => Promise.race(p), values @@ -818,7 +821,7 @@ export const RestatePromise = { /** * Workflow bound durable promise * - * See {@link WorkflowSharedContext} promise.. + * See {@link WorkflowSharedContext}. */ export type DurablePromise = Promise & { /** diff --git a/packages/libs/restate-sdk/src/context_impl.ts b/packages/libs/restate-sdk/src/context_impl.ts index 24dc159f..a2e63127 100644 --- a/packages/libs/restate-sdk/src/context_impl.ts +++ b/packages/libs/restate-sdk/src/context_impl.ts @@ -63,11 +63,10 @@ import { RandImpl } from "./utils/rand.js"; import { CompletablePromise } from "./utils/completable_promise.js"; import type { AsyncResultValue } from "./promises.js"; import { - PendingInvocationRestatePromise, + ConstRestatePromise, pendingPromise, PromisesExecutor, InvocationRestatePromise, - PendingRestatePromise, SingleRestatePromise, } from "./promises.js"; import { InputPump, OutputPump } from "./io.js"; @@ -241,7 +240,7 @@ export class ContextImpl WasmCommandType.Call ) ); - return new PendingInvocationRestatePromise(this); + return Object.assign(ConstRestatePromise.pending(), { invocationId: pendingPromise() }); } try { @@ -284,7 +283,7 @@ export class ContextImpl } catch (e) { this.handleInvocationEndError(e); // We return a pending promise to avoid the caller to see the error. - return new PendingInvocationRestatePromise(this); + return Object.assign(ConstRestatePromise.pending(), { invocationId: pendingPromise() }); } } @@ -306,7 +305,7 @@ export class ContextImpl WasmCommandType.OneWayCall ) ); - return new PendingInvocationRestatePromise(this); + return { invocationId: pendingPromise() }; } try { @@ -439,7 +438,7 @@ export class ContextImpl handle = this.coreVm.sys_run(name ?? ""); } catch (e) { this.handleInvocationEndError(e); - return new PendingRestatePromise(this); + return ConstRestatePromise.pending(); } const commandIndex = this.coreVm.last_command_index(); @@ -587,7 +586,7 @@ export class ContextImpl this.handleInvocationEndError(e); return { id: "invalid", - promise: new PendingRestatePromise(this), + promise: ConstRestatePromise.pending(), }; } @@ -690,7 +689,7 @@ export class ContextImpl commandType ) ); - return new PendingRestatePromise(this); + return ConstRestatePromise.pending(); } let handle: number; @@ -698,7 +697,7 @@ export class ContextImpl handle = vmCall(this.coreVm, input); } catch (e) { this.handleInvocationEndError(e); - return new PendingRestatePromise(this); + return ConstRestatePromise.pending(); } const commandIndex = this.coreVm.last_command_index(); return new SingleRestatePromise( diff --git a/packages/libs/restate-sdk/src/promises.ts b/packages/libs/restate-sdk/src/promises.ts index 7cf1ef26..86be68a0 100644 --- a/packages/libs/restate-sdk/src/promises.ts +++ b/packages/libs/restate-sdk/src/promises.ts @@ -282,9 +282,13 @@ export class CombinatorRestatePromise extends BaseRestatePromise { } if (foundContext === undefined) { - // The only situation where this can happen is when the combined promise contains only RestateCompletedPromise as children. - // In this case, just return back a nice and clean RestateCompletedPromise. - return new CompletedRestatePromise(combinatorConstructor(castedPromises)); + // The only situation where this can happen is when the combined promise contains only ConstRestatePromise as children. + // In this case, just return back a nice and clean ConstRestatePromise. + // There is a specific workaround for the funky interface of Promise.race, inside the RestatePromise.race factory method. + return ConstRestatePromise.fromPromise( + combinatorConstructor(castedPromises), + true + ); } return new CombinatorRestatePromise( @@ -311,68 +315,6 @@ export class CombinatorRestatePromise extends BaseRestatePromise { readonly [Symbol.toStringTag] = "RestateCombinatorPromise"; } -export class PendingRestatePromise extends InternalRestatePromise { - [RESTATE_CTX_SYMBOL]: ContextImpl; - - constructor(ctx: ContextImpl) { - super(); - this[RESTATE_CTX_SYMBOL] = ctx; - } - - // --- Promise methods - - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null - ): Promise { - return pendingPromise().then(onfulfilled, onrejected); - } - - catch( - onrejected?: ((reason: any) => TResult | PromiseLike) | null - ): Promise { - return pendingPromise().catch(onrejected); - } - - finally(onfinally?: (() => void) | null): Promise { - return pendingPromise().finally(onfinally); - } - - // --- RestatePromise methods - - orTimeout(): RestatePromise { - return this; - } - - map(): RestatePromise { - return this as unknown as RestatePromise; - } - - tryCancel(): void {} - async tryComplete(): Promise {} - uncompletedLeaves(): number[] { - return []; - } - publicPromise(): Promise { - return pendingPromise(); - } - - readonly [Symbol.toStringTag] = "RestatePendingPromise"; -} - -export class PendingInvocationRestatePromise - extends PendingRestatePromise - implements InvocationPromise -{ - constructor(ctx: ContextImpl) { - super(ctx); - } - - get invocationId(): Promise { - return pendingPromise(); - } -} - export class MappedRestatePromise extends BaseRestatePromise { private publicPromiseMapper: ( value?: T, @@ -425,49 +367,76 @@ export class MappedRestatePromise extends BaseRestatePromise { readonly [Symbol.toStringTag] = "RestateMappedPromise"; } -export class CompletedRestatePromise extends InternalRestatePromise { - constructor(private readonly completedPromise: Promise) { +export class ConstRestatePromise extends InternalRestatePromise { + private constructor( + private readonly constPromise: Promise, + private readonly settled: boolean + ) { super(); } + static resolve(value: T): ConstRestatePromise> { + return new ConstRestatePromise( + Promise.resolve(value), + true + ); + } + + static reject(reason: TerminalError): ConstRestatePromise { + return new ConstRestatePromise(Promise.reject(reason), true); + } + + static pending(): ConstRestatePromise { + return new ConstRestatePromise(pendingPromise(), false); + } + + static fromPromise( + promise: Promise, + settled: boolean + ): ConstRestatePromise { + return new ConstRestatePromise(promise, settled); + } + // --- Promise methods then( onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null ): Promise { - return this.completedPromise.then(onfulfilled, onrejected); + return this.constPromise.then(onfulfilled, onrejected); } catch( onrejected?: ((reason: any) => TResult | PromiseLike) | null ): Promise { - return this.completedPromise.catch(onrejected); + return this.constPromise.catch(onrejected); } finally(onfinally?: (() => void) | null): Promise { - return this.completedPromise.finally(onfinally); + return this.constPromise.finally(onfinally); } // --- RestatePromise methods orTimeout(): RestatePromise { - return this; // Timeout never kicks in! + if (this.settled) return this; + return ConstRestatePromise.reject(new TimeoutError()); } map(mapper: (value?: T, failure?: TerminalError) => U): RestatePromise { - return new CompletedRestatePromise( - this.completedPromise.then( + return ConstRestatePromise.fromPromise( + this.constPromise.then( (value) => mapper(value, undefined), (reason) => mapper(undefined, reason as TerminalError) - ) + ), + this.settled ); } tryCancel() {} publicPromise(): Promise { - return this.completedPromise; + return this.constPromise; } tryComplete(): Promise { @@ -478,7 +447,7 @@ export class CompletedRestatePromise extends InternalRestatePromise { return []; } - readonly [Symbol.toStringTag] = "RestateCombinatorPromise"; + readonly [Symbol.toStringTag] = "ConstRestatePromise"; } /** From 8878dcaaf8ae1f313789117f463346638f4f9fe5 Mon Sep 17 00:00:00 2001 From: slinkydeveloper Date: Thu, 9 Apr 2026 11:54:50 +0200 Subject: [PATCH 10/10] Bunch of new tests --- .../src/promise_combinators.ts | 33 +++++++++++++++++++ .../test/promise_combinators.test.ts | 16 +++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/tests/restate-e2e-services/src/promise_combinators.ts b/packages/tests/restate-e2e-services/src/promise_combinators.ts index d5051600..471af616 100644 --- a/packages/tests/restate-e2e-services/src/promise_combinators.ts +++ b/packages/tests/restate-e2e-services/src/promise_combinators.ts @@ -127,6 +127,39 @@ const promiseCombinators = restate.service({ const resolvedPromise = RestatePromise.resolve(input.resolvedValue); return RestatePromise.race([sleepPromise, resolvedPromise]); }, + + // --- orTimeout on resolved/pending --- + + resolveOrTimeout: async ( + _ctx: restate.Context, + value: string + ): Promise => { + // orTimeout on an already-resolved promise should return the value, not timeout + return RestatePromise.resolve(value).orTimeout(1); + }, + + raceEmptyOrTimeout: async ( + _ctx: restate.Context + ): Promise => { + // race([]) is forever pending, orTimeout should reject with TimeoutError + return RestatePromise.race[]>( + [] + ).orTimeout(1); + }, + + raceEmptyOrTimeoutMapped: async ( + _ctx: restate.Context + ): Promise => { + // race([]).orTimeout().map() — verify we can map the TimeoutError + return RestatePromise.race[]>([]) + .orTimeout(1) + .map((_v, err) => { + if (err instanceof restate.TimeoutError) { + return "timeout"; + } + return "unexpected"; + }); + }, }, }); diff --git a/packages/tests/restate-e2e-services/test/promise_combinators.test.ts b/packages/tests/restate-e2e-services/test/promise_combinators.test.ts index a195f3f0..14a2d6b1 100644 --- a/packages/tests/restate-e2e-services/test/promise_combinators.test.ts +++ b/packages/tests/restate-e2e-services/test/promise_combinators.test.ts @@ -114,4 +114,20 @@ describe("PromiseCombinators", () => { }); expect(result).toBe("instant"); }); + + // --- orTimeout on resolved/pending --- + + it("resolve().orTimeout() returns the value", async () => { + const result = await client.resolveOrTimeout("hello"); + expect(result).toBe("hello"); + }); + + it("race([]).orTimeout() rejects with TimeoutError", async () => { + await expect(client.raceEmptyOrTimeout()).rejects.toThrow(); + }); + + it("race([]).orTimeout().map() catches the TimeoutError", async () => { + const result = await client.raceEmptyOrTimeoutMapped(); + expect(result).toBe("timeout"); + }); });