Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ interfaces from `effect/unstable/*`, whose API can move between releases. Each r
of this package states the one `effect` version it is built and tested against, and
tracking a new `effect` release is a new release of this package.

## 0.2.0 (unreleased)

- BREAKING: workflow bundles are authored with `Workflow.toLayer`, hosted behind `workflowBundle(layer)` — one
dynamic default export per bundle, the same registration-driven authoring
the cluster and in-memory engines use. Handlers can require services
provided by ordinary Layers in the registration environment.
`makeTemporalWorkflow` is REMOVED: one way to author. (If per-type
`workflowDefinitionOptions` — e.g. Worker Versioning behavior — becomes a
need, the worker-level `defaultVersioningBehavior` covers the dynamic
workflow, and a per-type escape hatch can return later.)

## 0.1.1 (2026-08-26)

Initial public release. (0.1.0 was published without provenance during
Expand Down
2 changes: 1 addition & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ workflow-semantics content).

| Sample | Status | With this package |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [hello-world](https://github.com/temporalio/samples-typescript/tree/main/hello-world) | ✅ | `makeTemporalWorkflow` + `Activity.make` + `callRawActivity`. Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) (happy path). |
| [hello-world](https://github.com/temporalio/samples-typescript/tree/main/hello-world) | ✅ | `Workflow.toLayer` + `Activity.make` + `callRawActivity`. Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) (happy path). |
| [activities-examples](https://github.com/temporalio/samples-typescript/tree/main/activities-examples) | 🟢 | Activity implementations, retries, and timeouts are plain Temporal — configured on `proxyActivities` in the bundle, untouched by the shim. |
| [activities-dependency-injection](https://github.com/temporalio/samples-typescript/tree/main/activities-dependency-injection) | 🟢 | Activity-side pattern; `makeEffectWorkflowActivities` is itself an instance of it. |
| [activities-cancellation-heartbeating](https://github.com/temporalio/samples-typescript/tree/main/activities-cancellation-heartbeating) | ✅ | Workflow-side cancellation reaches in-flight activities through `callRawActivity` scopes; heartbeating is activity-side and untouched. Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) (in-flight cancellation). |
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ const OrderFlow = Workflow.make("orderFlow", {
success: Schema.String,
});

// The body is an Effect, running durably inside the Temporal sandbox:
export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) =>
// The body is an Effect, running durably inside
// the Temporal sandbox — the same authoring Effect's other engines use:
const OrderFlowLive = OrderFlow.toLayer((payload) =>
Effect.gen(function* () {
const paid = yield* callActivity(Charge, { orderId: payload.orderId });
yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" });
const approver = yield* DurableDeferred.await(ManagerApproval);
return `${paid}:approved-by:${approver}`;
}),
);
export default workflowBundle(OrderFlowLive); // the bundle's dynamic default

// Drive it from ordinary Node — typed success/error, idempotent by digest id.
const program = Effect.gen(function* () {
Expand Down Expand Up @@ -144,7 +146,7 @@ push and PR. Publishing to npm happens on version tags:

```sh
# bump "version" in package.json, then
git tag v0.1.0 && git push --tags
git tag v0.2.0 && git push --tags
```

The publish job runs `npm publish` with provenance; it needs an `NPM_TOKEN`
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/child-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Calling one workflow's `execute` inside another's body starts a Temporal **child workflow**. Both must be shim workflows exported from the **same bundle** — a child's Temporal type resolves within the bundle that runs the parent.

```ts
export const effectParentDemo = makeTemporalWorkflow(ParentDemo, (payload) =>
const ParentDemoLive = ParentDemo.toLayer((payload) =>
Effect.gen(function* () {
const reservation = yield* callActivity(Reserve, { sku: payload.sku, quantity: 1 });

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/continue-as-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Temporal caps a run's history; a workflow that loops forever — an entity, a poller, a batch cursor — must periodically **continue as new**: end the current run and atomically start a fresh one with the same workflow id and a reset history.

```ts
import { continueAsNew, makeTemporalWorkflow } from "@springbird/effect-temporal/engine-sandbox";
import { continueAsNew } from "@springbird/effect-temporal/engine-sandbox";

export const effectLoopDemo = makeTemporalWorkflow(LoopDemo, (payload) =>
const LoopDemoLive = LoopDemo.toLayer((payload) =>
Effect.gen(function* () {
yield* callActivity(Record, { iteration: payload.iteration });
if (payload.iteration >= 2) return `done:${payload.iteration}`;
Expand Down
11 changes: 8 additions & 3 deletions docs/guide/defining-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,23 @@ export const OrderFlow = Workflow.make("orderFlow", {

## The body

`makeTemporalWorkflow(definition, handler)` turns the definition plus an Effect handler into a Temporal workflow function. Export it from the bundle **under the definition's tag** — the export name is the Temporal workflow type clients start.
Register the handler with `Workflow.toLayer`, and host every registration behind the bundle's default export with `workflowBundle`:

```ts
export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload, executionId) =>
const OrderFlowLive = OrderFlow.toLayer((payload, executionId) =>
Effect.gen(function* () {
// ... activities, timers, signals — see the rest of the guide
return "done";
}),
);

// the bundle's ONE default export hosts every registered workflow
export default workflowBundle(Layer.mergeAll(OrderFlowLive /*, ... */));
```

The handler receives the decoded payload and the execution id, and may require only workflow-runtime services. Everything effectful must reach the outside world through an activity call — see [Activities](/guide/activities) and the [authoring rules](/guide/lint-rules).
This is the same registration-driven authoring Effect's cluster and in-memory engines use — the workflow code is engine-agnostic, and choosing Temporal is choosing this default export plus the client half's engine layer. Handlers can require services provided by ordinary Layers composed into the registration environment (`Layer.provide` on the merged layer).

The handler receives the decoded payload and the execution id. Everything effectful must reach the outside world through an activity call — see [Activities](/guide/activities) and the [authoring rules](/guide/lint-rules).

## One definition, three call sites

Expand Down
8 changes: 5 additions & 3 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ export const OrderFlow = Workflow.make("orderFlow", {

## 2. Author the body

The body is an Effect that runs inside the Temporal workflow sandbox. Export it from your workflow bundle **under the workflow's tag** — the export name is the Temporal workflow type.
The body is an Effect that runs inside the Temporal workflow sandbox. Workflows register themselves with `Workflow.toLayer`, and `workflowBundle` hosts every registration behind the bundle's **default export** — the same authoring that runs on Effect's cluster and in-memory engines.

```ts
// workflows.ts — the workflow bundle (Temporal's workflowsPath points here)
import { Effect } from "effect";
import * as DurableClock from "effect/unstable/workflow/DurableClock";
import { callActivity, makeTemporalWorkflow } from "@springbird/effect-temporal/engine-sandbox";
import { callActivity, workflowBundle } from "@springbird/effect-temporal/engine-sandbox";
import { OrderFlow, Reserve } from "./definitions.js";

export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) =>
const OrderFlowLive = OrderFlow.toLayer((payload) =>
Effect.gen(function* () {
// A typed activity: payload validated, result decoded, typed failure
// lands in the error channel. Retries are Temporal's, per the options.
Expand All @@ -66,6 +66,8 @@ export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) =>
return `reserved:${reservation}`;
}),
);

export default workflowBundle(OrderFlowLive); // Layer.mergeAll(...) for more
```

## 3. Implement the activities and run a worker
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The library is one npm package, `@springbird/effect-temporal`, with tree-shakeab

| Module | Runs in | What it is |
| --- | --- | --- |
| `@springbird/effect-temporal/engine-sandbox` | the workflow bundle | `makeTemporalWorkflow`, activity calls, mailbox/update/state-cell operations |
| `@springbird/effect-temporal/engine-sandbox` | the workflow bundle | `workflowBundle`, activity calls, mailbox/update/state-cell operations |
| `@springbird/effect-temporal/engine-client` | ordinary Node | the client-side engine + standalone read/signal operations |
| `@springbird/effect-temporal/client` | ordinary Node | `WorkflowClient` — the one client service |
| `@springbird/effect-temporal/typed-activity` | both | schema-typed activity definitions |
Expand All @@ -35,6 +35,7 @@ The library is one npm package, `@springbird/effect-temporal`, with tree-shakeab

Temporal's TypeScript SDK gives you durable execution with untyped seams: workflow arguments, results, signals, and failures all travel as loosely-typed payloads, and failure means catching `ApplicationFailure` and inspecting strings. effect-temporal keeps every one of those seams schema-typed:

- **Engine-agnostic authoring.** Workflows register with `Workflow.toLayer` and are hosted behind one dynamic default export (`workflowBundle`) — the identical workflow code runs on Effect's cluster engine, the in-memory test engine, or Temporal. Choosing a backend is choosing a Layer.
- **Payloads, results, and errors are schemas.** The engine validates and encodes what crosses each boundary; your workflow body and your client both see decoded, typed values — including typed *failures*, which land in the Effect error channel on the reading side instead of an exception to string-match.
- **Composition is Effect.** `Effect.raceFirst` a mailbox against a durable timer, wrap a step in `Workflow.withCompensation`, pipe a timeout onto an activity call — interruption, finalizers, and compensation compose the way the rest of your Effect code does, and cancellation reaches the server (an interrupted activity call is cancelled server-side, not abandoned).
- **One definition, both sides.** A workflow, activity, mailbox, update, or state cell is declared once and imported by the bundle, the worker, and every client. A misspelled name or drifted payload shape is a compile error, not a production incident.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/mailboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const StateUpdates = DurableMailbox.make("state-updates", {
```ts
import { setStateCell, takeMailbox } from "@springbird/effect-temporal/engine-sandbox";

export const effectStateDemo = makeTemporalWorkflow(StateDemo, () =>
const StateDemoLive = StateDemo.toLayer(() =>
Effect.gen(function* () {
const state = new Map<string, number>();
while (true) {
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/queryable-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Temporal query handlers are synchronous and read-only — they cannot run your E
Updates, a state cell, and an approval compose into the long-lived observable entity — this is the shape the package exists for:

```ts
export const effectMessageDemo = makeTemporalWorkflow(MessageDemo, () =>
const MessageDemoLive = MessageDemo.toLayer(() =>
Effect.gen(function* () {
let language: string = SUPPORTED_LANGUAGES[0];
yield* setStateCell(CurrentLanguage, language);
Expand Down
7 changes: 4 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,17 @@ const ManagerApproval = DurableDeferred.make("manager-approval", {
success: Schema.String,
});

// 2. Author the body — an Effect, running durably in the Temporal sandbox.
// (workflow bundle: engine-sandbox module)
export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) =>
// 2. Author the body — an Effect, running durably in the
// Temporal sandbox. (workflow bundle: engine-sandbox module)
const OrderFlowLive = OrderFlow.toLayer((payload) =>
Effect.gen(function* () {
const paid = yield* callActivity(Charge, { orderId: payload.orderId });
yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" });
const approver = yield* DurableDeferred.await(ManagerApproval);
return `${paid}:approved-by:${approver}`;
}),
);
export default workflowBundle(OrderFlowLive);

// 3. Drive it from ordinary Node — typed success, typed failure, idempotent.
const wf = yield* WorkflowClient;
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Effect and Temporal each manage their own execution and their own clocks. This p

`effect/unstable/workflow` defines workflow programs against an abstract `WorkflowEngine`. This package implements that engine twice:

- **`engine-sandbox`** runs *inside* the Temporal workflow sandbox. `makeTemporalWorkflow` builds the Temporal workflow function: it decodes the payload, provides the engine, runs your handler as an Effect program, and encodes the exit. Engine operations map to sandbox primitives — child starts to `startChild`, deferreds to signals + `condition()`, clocks to durable timers.
- **`engine-sandbox`** runs *inside* the Temporal workflow sandbox. `workflowBundle` builds the bundle's one dynamic workflow function from `Workflow.toLayer` registrations: per run it decodes the payload, provides the engine, runs your handler as an Effect program, and encodes the exit. Engine operations map to sandbox primitives — child starts to `startChild`, deferreds to signals + `condition()`, clocks to durable timers.
- **`engine-client`** runs in ordinary Node. Engine operations map to Temporal client calls — `execute` starts (or attaches) and awaits, `poll` describes, `interrupt` cancels.

One consequence worth knowing: **nothing ever suspends**. Effect's engine contract has a suspend/resume path for engines that park workflows; this engine blocks durably instead (a `condition()` or timer in the sandbox), so `resume` is a no-op and a `Suspended` result is a bug.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Offers to closed executions are dropped by design, and on the workflow side any

## The sandbox is still the sandbox

Temporal's determinism constraints apply to the code you write inside `makeTemporalWorkflow`: no direct I/O, no non-deterministic module state, versioned changes to in-flight code. The [authoring rules](/guide/lint-rules) and the engine remove the accidental ways to trip — they cannot remove the model.
Temporal's determinism constraints apply to the code you write inside workflow handlers: no direct I/O, no non-deterministic module state, versioned changes to in-flight code. The [authoring rules](/guide/lint-rules) and the engine remove the accidental ways to trip — they cannot remove the model.

## Polyfilled sandbox globals

Expand Down
10 changes: 6 additions & 4 deletions examples/order-saga/src/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// The workflow bundle — Temporal's workflowsPath points here. The whole body
// is an Effect running deterministically inside the workflow sandbox; every
// side effect goes through a typed activity. The export name equals the
// workflow tag.
// side effect goes through a typed activity. The workflow registers itself
// with `Workflow.toLayer`, hosted by the bundle's default export.

import { Effect } from "effect";
import * as DurableClock from "effect/unstable/workflow/DurableClock";
import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred";
import * as Workflow from "effect/unstable/workflow/Workflow";
import { callActivity, makeTemporalWorkflow, setStateCell } from "@springbird/effect-temporal/engine-sandbox";
import { callActivity, workflowBundle, setStateCell } from "@springbird/effect-temporal/engine-sandbox";
import { Charge, ManagerApproval, OrderSaga, OrderStatus, Release, Reserve } from "./definitions.js";

export const orderSaga = makeTemporalWorkflow(OrderSaga, (payload) =>
const OrderSagaLive = OrderSaga.toLayer((payload) =>
Effect.gen(function* () {
yield* setStateCell(OrderStatus, { phase: "reserving" });

Expand Down Expand Up @@ -43,3 +43,5 @@ export const orderSaga = makeTemporalWorkflow(OrderSaga, (payload) =>
return `${reservation}|${receipt}|approved-by:${approver}`;
}),
);

export default workflowBundle(OrderSagaLive);
12 changes: 9 additions & 3 deletions examples/subscription/src/workflows.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// The workflow bundle — the long-lived entity loop. Each iteration races
// the next billing timer against inbound messages (a typed plan-change
// update, a cancellation), and the run continues-as-new every few cycles so
// history never grows without bound. The export name equals the tag.
// history never grows without bound.
//
// The workflow registers itself with `Workflow.toLayer`, and
// `workflowBundle` hosts every registration behind the bundle's one
// dynamic default export.

import { Effect } from "effect";
import * as Option from "effect/Option";
Expand All @@ -10,7 +14,7 @@ import * as DurableClock from "effect/unstable/workflow/DurableClock";
import {
callActivity,
continueAsNew,
makeTemporalWorkflow,
workflowBundle,
pollMailbox,
setStateCell,
takeMailbox,
Expand All @@ -29,7 +33,7 @@ const CYCLES_PER_RUN = 2;

const MINIMUM_PLAN_CENTS = 100;

export const subscription = makeTemporalWorkflow(Subscription, (payload) =>
const SubscriptionLive = Subscription.toLayer((payload) =>
Effect.gen(function* () {
let planCents = payload.planCents;
let cyclesBilled = payload.cyclesBilled;
Expand Down Expand Up @@ -96,3 +100,5 @@ export const subscription = makeTemporalWorkflow(Subscription, (payload) =>
}
}),
);

export default workflowBundle(SubscriptionLive);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@springbird/effect-temporal",
"version": "0.1.1",
"version": "0.2.0",
"description": "Run `effect/unstable/workflow` programs (Workflow / Activity / DurableClock / DurableDeferred) on a Temporal engine, plus durable mailboxes, updates, queryable state, versioning, schedules, and Nexus operations.",
"license": "MIT",
"type": "module",
Expand Down
Loading
Loading