Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .claude/skills/add-config-option/SKILL.md
Original file line number Diff line number Diff line change
@@ -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<I, O>`** — 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/`.
120 changes: 120 additions & 0 deletions .claude/skills/add-e2e-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<service_name>.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<string> => {
// 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/<service_name>.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" }));
```
3 changes: 2 additions & 1 deletion .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions CLAUDE.md
41 changes: 16 additions & 25 deletions packages/libs/restate-sdk/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -705,6 +708,14 @@ export type InvocationHandle = {
export type InvocationPromise<T> = RestatePromise<T> & InvocationHandle;

export const RestatePromise = {
resolve<T>(value: T): RestatePromise<Awaited<T>> {
return new RestateCompletedPromise(Promise.resolve(value));
},

reject<T = never>(reason: TerminalError): RestatePromise<T> {
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.
Expand All @@ -717,12 +728,7 @@ export const RestatePromise = {
all<const T extends readonly RestatePromise<unknown>[]>(
values: T
): RestatePromise<{ -readonly [P in keyof T]: Awaited<T[P]> }> {
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<{
Expand All @@ -742,12 +748,7 @@ export const RestatePromise = {
race<const T extends readonly RestatePromise<unknown>[]>(
values: T
): RestatePromise<Awaited<T[number]>> {
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<Awaited<T[number]>>;
Expand All @@ -766,12 +767,7 @@ export const RestatePromise = {
any<const T extends readonly RestatePromise<unknown>[]>(
values: T
): RestatePromise<Awaited<T[number]>> {
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<Awaited<T[number]>>;
Expand All @@ -791,12 +787,7 @@ export const RestatePromise = {
): RestatePromise<{
-readonly [P in keyof T]: PromiseSettledResult<Awaited<T[P]>>;
}> {
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<{
Expand Down
35 changes: 1 addition & 34 deletions packages/libs/restate-sdk/src/context_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -647,37 +645,6 @@ export class ContextImpl
return new DurablePromiseImpl(this, name, serde);
}

// Used by static methods of RestatePromise
public static createCombinator<T extends readonly RestatePromise<unknown>[]>(
combinatorConstructor: (promises: Promise<any>[]) => Promise<any>,
promises: T
): RestatePromise<unknown> {
// 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<any>[] = [];
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<any>);
}
return new RestateCombinatorPromise(
self,
combinatorConstructor,
castedPromises
);
}

// -- Various private methods

private processNonCompletableEntry<T>(
Expand Down
Loading
Loading