diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f2a14..73dd94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/EXAMPLES.md b/EXAMPLES.md index de27928..6d1d9d7 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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). | diff --git a/README.md b/README.md index 3659165..486abeb 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ 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" }); @@ -36,6 +37,7 @@ export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) => 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* () { @@ -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` diff --git a/docs/guide/child-workflows.md b/docs/guide/child-workflows.md index 5c10a0d..040003b 100644 --- a/docs/guide/child-workflows.md +++ b/docs/guide/child-workflows.md @@ -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 }); diff --git a/docs/guide/continue-as-new.md b/docs/guide/continue-as-new.md index c680204..6ffdc9c 100644 --- a/docs/guide/continue-as-new.md +++ b/docs/guide/continue-as-new.md @@ -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}`; diff --git a/docs/guide/defining-workflows.md b/docs/guide/defining-workflows.md index b8351fc..f7d4991 100644 --- a/docs/guide/defining-workflows.md +++ b/docs/guide/defining-workflows.md @@ -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 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index f9157a2..c70b28e 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -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. @@ -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 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 959f69c..b755837 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -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 | @@ -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. diff --git a/docs/guide/mailboxes.md b/docs/guide/mailboxes.md index c85b39a..c6c9383 100644 --- a/docs/guide/mailboxes.md +++ b/docs/guide/mailboxes.md @@ -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(); while (true) { diff --git a/docs/guide/queryable-state.md b/docs/guide/queryable-state.md index a51f5fe..aa9808a 100644 --- a/docs/guide/queryable-state.md +++ b/docs/guide/queryable-state.md @@ -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); diff --git a/docs/index.md b/docs/index.md index 757aa13..f5a6d75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,9 +51,9 @@ 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" }); @@ -61,6 +61,7 @@ export const orderFlow = makeTemporalWorkflow(OrderFlow, (payload) => return `${paid}:approved-by:${approver}`; }), ); +export default workflowBundle(OrderFlowLive); // 3. Drive it from ordinary Node — typed success, typed failure, idempotent. const wf = yield* WorkflowClient; diff --git a/docs/reference/how-it-works.md b/docs/reference/how-it-works.md index ba8125a..24f8e39 100644 --- a/docs/reference/how-it-works.md +++ b/docs/reference/how-it-works.md @@ -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. diff --git a/docs/reference/limitations.md b/docs/reference/limitations.md index bf8cb84..3fa70a4 100644 --- a/docs/reference/limitations.md +++ b/docs/reference/limitations.md @@ -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 diff --git a/examples/order-saga/src/workflows.ts b/examples/order-saga/src/workflows.ts index 94a0794..0429a92 100644 --- a/examples/order-saga/src/workflows.ts +++ b/examples/order-saga/src/workflows.ts @@ -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" }); @@ -43,3 +43,5 @@ export const orderSaga = makeTemporalWorkflow(OrderSaga, (payload) => return `${reservation}|${receipt}|approved-by:${approver}`; }), ); + +export default workflowBundle(OrderSagaLive); diff --git a/examples/subscription/src/workflows.ts b/examples/subscription/src/workflows.ts index 4c145b4..4935cc1 100644 --- a/examples/subscription/src/workflows.ts +++ b/examples/subscription/src/workflows.ts @@ -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"; @@ -10,7 +14,7 @@ import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { callActivity, continueAsNew, - makeTemporalWorkflow, + workflowBundle, pollMailbox, setStateCell, takeMailbox, @@ -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; @@ -96,3 +100,5 @@ export const subscription = makeTemporalWorkflow(Subscription, (payload) => } }), ); + +export default workflowBundle(SubscriptionLive); diff --git a/package.json b/package.json index 5da4183..68e712f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/fixtures/batch-workflows.ts b/src/__tests__/fixtures/batch-workflows.ts index 9525920..3e0d1b0 100644 --- a/src/__tests__/fixtures/batch-workflows.ts +++ b/src/__tests__/fixtures/batch-workflows.ts @@ -1,9 +1,10 @@ -// Sliding-window batch demo — bundle entrypoint. Export names equal the -// workflow tags. Children are started discarded (ABANDON) so a -// continue-as-new never tears them down; each reports back to the -// orchestrator's stable workflow id, which survives the run change. +// Sliding-window batch demo — bundle entrypoint. Children are started +// discarded (ABANDON) so a continue-as-new never tears them down; each +// reports back to the orchestrator's stable workflow id, which survives the +// run change. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; @@ -11,7 +12,7 @@ import { proxyActivities } from "@temporalio/workflow"; import { callRawActivity, continueAsNew, - makeTemporalWorkflow, + workflowBundle, offerMailbox, pollMailbox, takeMailbox, @@ -25,7 +26,7 @@ const acts = proxyActivities<{ processRecord(index: string): Promise }>( /** How many children one run starts before continuing as new. */ const CHILDREN_PER_RUN = 2; -export const effectBatchDemo = makeTemporalWorkflow(BatchDemo, (payload) => +const BatchDemoLive = BatchDemo.toLayer((payload) => Effect.gen(function* () { const inFlight = new Set(payload.inFlight); let offset = payload.offset; @@ -68,7 +69,7 @@ export const effectBatchDemo = makeTemporalWorkflow(BatchDemo, (payload) => }), ); -export const effectRecordDemo = makeTemporalWorkflow(RecordDemo, (payload) => +const RecordDemoLive = RecordDemo.toLayer((payload) => Effect.gen(function* () { yield* Activity.make({ name: "process-record", @@ -82,3 +83,5 @@ export const effectRecordDemo = makeTemporalWorkflow(RecordDemo, (payload) => return `record:${payload.index}`; }), ); + +export default workflowBundle(Layer.mergeAll(BatchDemoLive, RecordDemoLive)); diff --git a/src/__tests__/fixtures/chain-workflows-v1.ts b/src/__tests__/fixtures/chain-workflows-v1.ts index e9a50a1..838c799 100644 --- a/src/__tests__/fixtures/chain-workflows-v1.ts +++ b/src/__tests__/fixtures/chain-workflows-v1.ts @@ -6,17 +6,19 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { ChainDemo } from "./chain-demo.js"; const acts = proxyActivities<{ greetV1(): Promise }>({ startToCloseTimeout: "10 seconds", }); -export const effectChainDemo = makeTemporalWorkflow(ChainDemo, () => +const ChainDemoLive = ChainDemo.toLayer(() => Activity.make({ name: "greet", success: Schema.String, execute: callRawActivity(() => acts.greetV1()), }).pipe(Effect.map((greeting) => `greeted:${greeting}`)), ); + +export default workflowBundle(ChainDemoLive); diff --git a/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts b/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts index c86902e..e08f8ec 100644 --- a/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts +++ b/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts @@ -6,17 +6,19 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { ChainDemo } from "./chain-demo.js"; const acts = proxyActivities<{ greetV2(): Promise }>({ startToCloseTimeout: "10 seconds", }); -export const effectChainDemo = makeTemporalWorkflow(ChainDemo, () => +const ChainDemoLive = ChainDemo.toLayer(() => Activity.make({ name: "greet-v2", success: Schema.String, execute: callRawActivity(() => acts.greetV2()), }).pipe(Effect.map((greeting) => `greeted:${greeting}`)), ); + +export default workflowBundle(ChainDemoLive); diff --git a/src/__tests__/fixtures/chain-workflows-v2.ts b/src/__tests__/fixtures/chain-workflows-v2.ts index b30b6c9..936717f 100644 --- a/src/__tests__/fixtures/chain-workflows-v2.ts +++ b/src/__tests__/fixtures/chain-workflows-v2.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import * as Versioning from "../../versioning.js"; import { ChainDemo } from "./chain-demo.js"; @@ -17,9 +17,11 @@ const acts = proxyActivities<{ greetV1(): Promise; greetV2(): Promise Promise) => Activity.make({ name, success: Schema.String, execute: callRawActivity(call) }); -export const effectChainDemo = makeTemporalWorkflow(ChainDemo, () => +const ChainDemoLive = ChainDemo.toLayer(() => Versioning.match("greeting", [ { version: "v1", run: greet("greet", () => acts.greetV1()) }, { version: "v2", run: greet("greet-v2", () => acts.greetV2()) }, ]).pipe(Effect.map((greeting) => `greeted:${greeting}`)), ); + +export default workflowBundle(ChainDemoLive); diff --git a/src/__tests__/fixtures/chain-workflows-v3.ts b/src/__tests__/fixtures/chain-workflows-v3.ts index e78f8a4..161200b 100644 --- a/src/__tests__/fixtures/chain-workflows-v3.ts +++ b/src/__tests__/fixtures/chain-workflows-v3.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import * as Versioning from "../../versioning.js"; import { ChainDemo } from "./chain-demo.js"; @@ -19,10 +19,12 @@ const acts = proxyActivities<{ const greet = (name: string, call: () => Promise) => Activity.make({ name, success: Schema.String, execute: callRawActivity(call) }); -export const effectChainDemo = makeTemporalWorkflow(ChainDemo, () => +const ChainDemoLive = ChainDemo.toLayer(() => Versioning.match("greeting", [ { version: "v1", run: greet("greet", () => acts.greetV1()) }, { version: "v2", run: greet("greet-v2", () => acts.greetV2()) }, { version: "v3", run: greet("greet-v3", () => acts.greetV3()) }, ]).pipe(Effect.map((greeting) => `greeted:${greeting}`)), ); + +export default workflowBundle(ChainDemoLive); diff --git a/src/__tests__/fixtures/child-workflows.ts b/src/__tests__/fixtures/child-workflows.ts index 4f6bfa7..78dc4ad 100644 --- a/src/__tests__/fixtures/child-workflows.ts +++ b/src/__tests__/fixtures/child-workflows.ts @@ -1,16 +1,17 @@ -// Child-workflow demo — bundle entrypoint. Parent and child are both shim -// workflows exported from ONE bundle (a child's Temporal type must live in -// the same bundle as its parent). The parent's first step is compensated so -// the cancel test can assert parent-side compensation alongside child +// Child-workflow demo — bundle entrypoint. Parent and child are both +// registered in ONE bundle (a child's Temporal type must live in the same +// bundle as its parent). The parent's first step is compensated so the +// cancel test can assert parent-side compensation alongside child // cancellation. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import * as Workflow from "effect/unstable/workflow/Workflow"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { ChildDemo, ParentDemo } from "./child-demo.js"; const acts = proxyActivities<{ @@ -21,7 +22,7 @@ const acts = proxyActivities<{ startToCloseTimeout: "10 seconds", }); -export const effectChildDemo = makeTemporalWorkflow(ChildDemo, (payload) => +const ChildDemoLive = ChildDemo.toLayer((payload) => Effect.gen(function* () { if (payload.outcome === "sleep") { yield* DurableClock.sleep({ name: "child-delay", duration: "2 minutes" }); @@ -38,7 +39,7 @@ export const effectChildDemo = makeTemporalWorkflow(ChildDemo, (payload) => }), ); -export const effectParentDemo = makeTemporalWorkflow(ParentDemo, (payload) => +const ParentDemoLive = ParentDemo.toLayer((payload) => Effect.gen(function* () { const reservation = yield* Activity.make({ name: "reserve", @@ -68,3 +69,5 @@ export const effectParentDemo = makeTemporalWorkflow(ParentDemo, (payload) => return `${reservation}|${childResult}`; }), ); + +export default workflowBundle(Layer.mergeAll(ChildDemoLive, ParentDemoLive)); diff --git a/src/__tests__/fixtures/demo-workflows.ts b/src/__tests__/fixtures/demo-workflows.ts index 4775c1f..afdc3fb 100644 --- a/src/__tests__/fixtures/demo-workflows.ts +++ b/src/__tests__/fixtures/demo-workflows.ts @@ -1,8 +1,7 @@ // Demo workflow — bundle entrypoint. One workflow exercising the core // primitives: a compensated step (reserve → release), a durable delay, an // external approval wait, a typed-failure path that triggers the -// compensation, and a long-activity path for in-flight cancellation. The -// export name equals the workflow tag. +// compensation, and a long-activity path for in-flight cancellation. import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -12,7 +11,7 @@ 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 { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { Approval, Demo } from "./demo.js"; const acts = proxyActivities<{ @@ -23,7 +22,7 @@ const acts = proxyActivities<{ startToCloseTimeout: "10 minutes", }); -export const effectDemo = makeTemporalWorkflow(Demo, (payload) => +const DemoLive = Demo.toLayer((payload) => Effect.gen(function* () { const reservation = yield* Activity.make({ name: "reserve", @@ -70,3 +69,5 @@ export const effectDemo = makeTemporalWorkflow(Demo, (payload) => return `${reservation}|approved-by:${approver}`; }), ); + +export default workflowBundle(DemoLive); diff --git a/src/__tests__/fixtures/dsl-workflows.ts b/src/__tests__/fixtures/dsl-workflows.ts index 7bae0f9..2711537 100644 --- a/src/__tests__/fixtures/dsl-workflows.ts +++ b/src/__tests__/fixtures/dsl-workflows.ts @@ -1,10 +1,10 @@ -// DSL-interpreter demo — bundle entrypoint. The export name equals the tag. +// DSL-interpreter demo — bundle entrypoint. import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { DslDemo } from "./dsl-demo.js"; const acts = proxyActivities<{ runTask(name: string): Promise }>({ @@ -18,7 +18,7 @@ const runTask = (name: string) => execute: callRawActivity(() => acts.runTask(name)), }); -export const effectDslDemo = makeTemporalWorkflow(DslDemo, (payload) => +const DslDemoLive = DslDemo.toLayer((payload) => Effect.gen(function* () { const outputs: string[] = []; for (const step of payload.steps) { @@ -35,3 +35,5 @@ export const effectDslDemo = makeTemporalWorkflow(DslDemo, (payload) => return outputs.join(">"); }), ); + +export default workflowBundle(DslDemoLive); diff --git a/src/__tests__/fixtures/lock-workflows.ts b/src/__tests__/fixtures/lock-workflows.ts index ee4fe80..3dfa78d 100644 --- a/src/__tests__/fixtures/lock-workflows.ts +++ b/src/__tests__/fixtures/lock-workflows.ts @@ -1,12 +1,13 @@ -// Mutex demo — bundle entrypoint. Export names equal the workflow tags. +// Mutex demo — bundle entrypoint. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; import { callRawActivity, - makeTemporalWorkflow, + workflowBundle, offerMailbox, takeMailbox, } from "../../engine-sandbox.js"; @@ -19,7 +20,7 @@ const acts = proxyActivities<{ startToCloseTimeout: "10 seconds", }); -export const effectLockDemo = makeTemporalWorkflow(LockDemo, (payload) => +const LockDemoLive = LockDemo.toLayer((payload) => Effect.gen(function* () { for (let token = 0; token < payload.grants; token++) { const request = yield* takeMailbox(AcquireRequests); @@ -33,7 +34,7 @@ export const effectLockDemo = makeTemporalWorkflow(LockDemo, (payload) => }), ); -export const effectContenderDemo = makeTemporalWorkflow(ContenderDemo, (payload, executionId) => +const ContenderDemoLive = ContenderDemo.toLayer((payload, executionId) => Effect.gen(function* () { yield* offerMailbox(AcquireRequests, { workflowId: payload.lockExecutionId, @@ -59,3 +60,5 @@ export const effectContenderDemo = makeTemporalWorkflow(ContenderDemo, (payload, return `done:${payload.name}`; }), ); + +export default workflowBundle(Layer.mergeAll(LockDemoLive, ContenderDemoLive)); diff --git a/src/__tests__/fixtures/loop-workflows.ts b/src/__tests__/fixtures/loop-workflows.ts index 94727e5..0269b88 100644 --- a/src/__tests__/fixtures/loop-workflows.ts +++ b/src/__tests__/fixtures/loop-workflows.ts @@ -1,6 +1,7 @@ -// Looping workflow — bundle entrypoint. The export name equals the tag. +// Looping workflow — bundle entrypoint. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; @@ -8,7 +9,7 @@ import { proxyActivities } from "@temporalio/workflow"; import { callRawActivity, continueAsNew, - makeTemporalWorkflow, + workflowBundle, setStateCell, } from "../../engine-sandbox.js"; import { CellLoopDemo, LoopDemo, LoopGate, LoopStage } from "./loop-demo.js"; @@ -17,7 +18,7 @@ const acts = proxyActivities<{ record(iteration: string): Promise }>({ startToCloseTimeout: "10 seconds", }); -export const effectLoopDemo = makeTemporalWorkflow(LoopDemo, (payload) => +const LoopDemoLive = LoopDemo.toLayer((payload) => Effect.gen(function* () { yield* Activity.make({ name: "record", @@ -32,7 +33,7 @@ export const effectLoopDemo = makeTemporalWorkflow(LoopDemo, (payload) => }), ); -export const effectCellLoopDemo = makeTemporalWorkflow(CellLoopDemo, (payload) => +const CellLoopDemoLive = CellLoopDemo.toLayer((payload) => Effect.gen(function* () { if (payload.iteration === 0) { yield* setStateCell(LoopStage, "run-0"); @@ -49,3 +50,5 @@ export const effectCellLoopDemo = makeTemporalWorkflow(CellLoopDemo, (payload) = return "cell-done"; }), ); + +export default workflowBundle(Layer.mergeAll(LoopDemoLive, CellLoopDemoLive)); diff --git a/src/__tests__/fixtures/mailbox-workflows.ts b/src/__tests__/fixtures/mailbox-workflows.ts index d8e9f57..c524750 100644 --- a/src/__tests__/fixtures/mailbox-workflows.ts +++ b/src/__tests__/fixtures/mailbox-workflows.ts @@ -1,8 +1,9 @@ -// Mailbox demo — bundle entrypoint. Export names equal the workflow tags. +// Mailbox demo — bundle entrypoint. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { makeTemporalWorkflow, setStateCell, takeMailbox } from "../../engine-sandbox.js"; +import { workflowBundle, setStateCell, takeMailbox } from "../../engine-sandbox.js"; import { DeadlineUpdates, StateDemo, @@ -11,7 +12,7 @@ import { UpdatableTimerDemo, } from "./mailbox-demo.js"; -export const effectStateDemo = makeTemporalWorkflow(StateDemo, () => +const StateDemoLive = StateDemo.toLayer(() => Effect.gen(function* () { const state = new Map(); while (true) { @@ -28,7 +29,7 @@ export const effectStateDemo = makeTemporalWorkflow(StateDemo, () => }), ); -export const effectUpdatableTimerDemo = makeTemporalWorkflow(UpdatableTimerDemo, (payload) => +const UpdatableTimerDemoLive = UpdatableTimerDemo.toLayer((payload) => Effect.gen(function* () { let deadlineMillis = payload.initialMillis; let updates = 0; @@ -49,3 +50,5 @@ export const effectUpdatableTimerDemo = makeTemporalWorkflow(UpdatableTimerDemo, } }), ); + +export default workflowBundle(Layer.mergeAll(StateDemoLive, UpdatableTimerDemoLive)); diff --git a/src/__tests__/fixtures/message-workflows.ts b/src/__tests__/fixtures/message-workflows.ts index 0a018ba..5680047 100644 --- a/src/__tests__/fixtures/message-workflows.ts +++ b/src/__tests__/fixtures/message-workflows.ts @@ -1,9 +1,10 @@ -// Message-passing demo — bundle entrypoint. The export name equals the tag. +// Message-passing demo — bundle entrypoint. import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import { makeTemporalWorkflow, setStateCell, takeUpdate } from "../../engine-sandbox.js"; +import { workflowBundle, setStateCell, takeUpdate } from "../../engine-sandbox.js"; import { Approved, CurrentLanguage, @@ -13,7 +14,7 @@ import { SUPPORTED_LANGUAGES, } from "./message-demo.js"; -export const effectMessageDemo = makeTemporalWorkflow(MessageDemo, () => +const MessageDemoLive = MessageDemo.toLayer(() => Effect.gen(function* () { let language: string = SUPPORTED_LANGUAGES[0]; yield* setStateCell(CurrentLanguage, language); @@ -44,7 +45,7 @@ export const effectMessageDemo = makeTemporalWorkflow(MessageDemo, () => /** Sends `DurableDeferred.done` to a foreign execution id through the * engine, then returns — the target being closed or unknown must be a no-op * for this sender. */ -export const effectDeferredPokeDemo = makeTemporalWorkflow(DeferredPokeDemo, (payload) => +const DeferredPokeDemoLive = DeferredPokeDemo.toLayer((payload) => Effect.gen(function* () { yield* DurableDeferred.done(Approved, { token: DurableDeferred.tokenFromExecutionId(Approved, { @@ -56,3 +57,5 @@ export const effectDeferredPokeDemo = makeTemporalWorkflow(DeferredPokeDemo, (pa return "ok"; }), ); + +export default workflowBundle(Layer.mergeAll(MessageDemoLive, DeferredPokeDemoLive)); diff --git a/src/__tests__/fixtures/nexus-workflows.ts b/src/__tests__/fixtures/nexus-workflows.ts index 19d9b01..9f4ec50 100644 --- a/src/__tests__/fixtures/nexus-workflows.ts +++ b/src/__tests__/fixtures/nexus-workflows.ts @@ -1,18 +1,19 @@ // Nexus demo — bundle entrypoint: the workflow-backed operation's target -// (`effectGreetDemo`) and the caller. Export names equal the workflow tags. +// (`GreetDemo`) and the caller. import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { createNexusServiceClient } from "@temporalio/workflow"; import { callNexusWorkflowOperation, callRawActivity, - makeTemporalWorkflow, + workflowBundle, type NexusOperationClient, } from "../../engine-sandbox.js"; import { CallerDemo, GreetDemo, helloService } from "./nexus-demo.js"; -export const effectGreetDemo = makeTemporalWorkflow(GreetDemo, (payload) => +const GreetDemoLive = GreetDemo.toLayer((payload) => Effect.gen(function* () { if (payload.name === "grinch") return yield* Effect.fail(`unwelcome:${payload.name}`); if (payload.name === "slow") { @@ -22,7 +23,7 @@ export const effectGreetDemo = makeTemporalWorkflow(GreetDemo, (payload) => }), ); -export const effectNexusCallerDemo = makeTemporalWorkflow(CallerDemo, (payload) => +const CallerDemoLive = CallerDemo.toLayer((payload) => Effect.gen(function* () { const nexusClient = createNexusServiceClient({ service: helloService, @@ -50,3 +51,5 @@ export const effectNexusCallerDemo = makeTemporalWorkflow(CallerDemo, (payload) return `${echoed.message}|${greeting}`; }), ); + +export default workflowBundle(Layer.mergeAll(GreetDemoLive, CallerDemoLive)); diff --git a/src/__tests__/fixtures/polling-workflows.ts b/src/__tests__/fixtures/polling-workflows.ts index 6d8a346..9cc349e 100644 --- a/src/__tests__/fixtures/polling-workflows.ts +++ b/src/__tests__/fixtures/polling-workflows.ts @@ -7,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { PollingDemo } from "./polling-demo.js"; const acts = proxyActivities<{ pollService(): Promise }>({ @@ -15,7 +15,7 @@ const acts = proxyActivities<{ pollService(): Promise }>({ retry: { initialInterval: "60 seconds", backoffCoefficient: 1 }, }); -export const effectPollingDemo = makeTemporalWorkflow(PollingDemo, () => +const PollingDemoLive = PollingDemo.toLayer(() => Effect.gen(function* () { const status = yield* Activity.make({ name: "poll-service", @@ -25,3 +25,5 @@ export const effectPollingDemo = makeTemporalWorkflow(PollingDemo, () => return `service:${status}`; }), ); + +export default workflowBundle(PollingDemoLive); diff --git a/src/__tests__/fixtures/registry-demo.ts b/src/__tests__/fixtures/registry-demo.ts new file mode 100644 index 0000000..3ca9397 --- /dev/null +++ b/src/__tests__/fixtures/registry-demo.ts @@ -0,0 +1,22 @@ +// Definitions shared by the registry bundle (registry-workflows.ts) and the +// test's client side. + +import * as Schema from "effect/Schema"; +import * as Workflow from "effect/unstable/workflow/Workflow"; + +export const RegistryChild = Workflow.make("registryChild", { + payload: { value: Schema.String }, + idempotencyKey: ({ value }) => value, + success: Schema.String, +}); + +export const RegistryParent = Workflow.make("registryParent", { + payload: { + requestId: Schema.String, + /** `ok` completes; `fail` returns the typed failure. */ + mode: Schema.Literals(["ok", "fail"]), + }, + idempotencyKey: ({ requestId }) => requestId, + success: Schema.String, + error: Schema.String, +}); diff --git a/src/__tests__/fixtures/registry-workflows.ts b/src/__tests__/fixtures/registry-workflows.ts new file mode 100644 index 0000000..0e5d7a3 --- /dev/null +++ b/src/__tests__/fixtures/registry-workflows.ts @@ -0,0 +1,50 @@ +// Bundle entrypoint: workflows registered with `Workflow.toLayer`, hosted +// behind the bundle's one dynamic default export. + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Activity from "effect/unstable/workflow/Activity"; +import * as DurableClock from "effect/unstable/workflow/DurableClock"; +import { proxyActivities } from "@temporalio/workflow"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; +import { RegistryChild, RegistryParent } from "./registry-demo.js"; + +const acts = proxyActivities<{ echo(value: string): Promise }>({ + startToCloseTimeout: "10 seconds", +}); + +/** A handler dependency provided by an ordinary Layer in the registration + * environment — the DI story per-workflow wrappers could not offer. */ +class ChildPrefix extends Context.Service()("registry-demo/ChildPrefix") {} + +const ChildLive = RegistryChild.toLayer((payload) => + Effect.gen(function* () { + const prefix = yield* ChildPrefix; + yield* DurableClock.sleep({ name: "child-nap", duration: "1 minute" }); + return `${prefix}:${payload.value}`; + }), +); + +const ParentLive = RegistryParent.toLayer((payload) => + Effect.gen(function* () { + if (payload.mode === "fail") return yield* Effect.fail("registry-failure"); + // A real Temporal activity through the per-call cancellable scope — + // proves the run-time SandboxRun override reaches registered handlers. + const echoed = yield* Activity.make({ + name: "echo", + success: Schema.String, + execute: callRawActivity(() => acts.echo(payload.requestId)), + }); + // A child workflow — proves the dynamic default dispatches child types. + const child = yield* RegistryChild.execute({ value: payload.requestId }); + return `parent:${echoed}|${child}`; + }), +); + +export default workflowBundle( + Layer.mergeAll(ChildLive, ParentLive).pipe( + Layer.provide(Layer.succeed(ChildPrefix, "hello")), + ), +); diff --git a/src/__tests__/fixtures/short-sleep-workflows.ts b/src/__tests__/fixtures/short-sleep-workflows.ts index 45c501d..0339bc5 100644 --- a/src/__tests__/fixtures/short-sleep-workflows.ts +++ b/src/__tests__/fixtures/short-sleep-workflows.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, makeTemporalWorkflow } from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; import { ShortSleepDemo } from "./short-sleep-demo.js"; const acts = proxyActivities<{ echo(value: string): Promise }>({ @@ -10,7 +10,7 @@ const acts = proxyActivities<{ echo(value: string): Promise }>({ // The in-memory DurableClock path (duration <= 60s threshold), sandwiched // between activities — the shape that hung in the fleet's campaign tests. -export const effectShortSleep = makeTemporalWorkflow(ShortSleepDemo, (payload) => +const ShortSleepDemoLive = ShortSleepDemo.toLayer((payload) => Effect.gen(function* () { const first = yield* callRawActivity(() => acts.echo(payload.requestId)); yield* DurableClock.sleep({ name: "short-nap", duration: "30 seconds" }); @@ -18,3 +18,5 @@ export const effectShortSleep = makeTemporalWorkflow(ShortSleepDemo, (payload) = return second; }), ); + +export default workflowBundle(ShortSleepDemoLive); diff --git a/src/__tests__/fixtures/transaction-workflows.ts b/src/__tests__/fixtures/transaction-workflows.ts index fe40d6b..85e00ff 100644 --- a/src/__tests__/fixtures/transaction-workflows.ts +++ b/src/__tests__/fixtures/transaction-workflows.ts @@ -6,10 +6,10 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { condition } from "@temporalio/workflow"; -import { makeTemporalWorkflow, takeUpdate } from "../../engine-sandbox.js"; +import { workflowBundle, takeUpdate } from "../../engine-sandbox.js"; import { GetConfirmation, TransactionDemo } from "./transaction-demo.js"; -export const effectTransactionDemo = makeTemporalWorkflow(TransactionDemo, () => +const TransactionDemoLive = TransactionDemo.toLayer(() => Effect.gen(function* () { const state = { confirmed: false }; @@ -29,3 +29,5 @@ export const effectTransactionDemo = makeTemporalWorkflow(TransactionDemo, () => return "complete:77"; }), ); + +export default workflowBundle(TransactionDemoLive); diff --git a/src/__tests__/fixtures/typed-activity-workflows.ts b/src/__tests__/fixtures/typed-activity-workflows.ts index ecdb0f5..6b4e7fc 100644 --- a/src/__tests__/fixtures/typed-activity-workflows.ts +++ b/src/__tests__/fixtures/typed-activity-workflows.ts @@ -1,10 +1,10 @@ import * as Effect from "effect/Effect"; -import { callActivity, makeTemporalWorkflow, sleepUntil } from "../../engine-sandbox.js"; +import { callActivity, workflowBundle, sleepUntil } from "../../engine-sandbox.js"; import { Reserve, TypedActivityDemo } from "./typed-activity-demo.js"; // Exercises the typed seam end-to-end: an absolute-time durable sleep, a // decoded success, and a typed failure caught by tag (never retried). -export const effectTypedActivity = makeTemporalWorkflow(TypedActivityDemo, (payload) => +const TypedActivityDemoLive = TypedActivityDemo.toLayer((payload) => Effect.gen(function* () { yield* sleepUntil({ name: "not-before", timestamp: payload.notBeforeISO }); return yield* callActivity(Reserve, { sku: payload.sku, quantity: 2 }).pipe( @@ -12,3 +12,5 @@ export const effectTypedActivity = makeTemporalWorkflow(TypedActivityDemo, (payl ); }), ); + +export default workflowBundle(TypedActivityDemoLive); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts new file mode 100644 index 0000000..e78c3b1 --- /dev/null +++ b/src/__tests__/registry.test.ts @@ -0,0 +1,42 @@ +// The workflow registry end to end: `Workflow.toLayer` registrations hosted +// by `workflowBundle`' dynamic default export, driven through the +// ordinary client engine — including child workflows dispatched through the +// same dynamic default and typed failures decoding across the wire. + +import { fileURLToPath } from "node:url"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; +import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"; +import { describe, expect, it } from "vitest"; +import { makeTemporalClientEngine } from "../engine-client.js"; +import { RegistryParent } from "./fixtures/registry-demo.js"; +import { createWorkflowTestEnv, type TestActivities } from "./utils/workflow-test-env.js"; + +const workflowsPath = fileURLToPath(new URL("./fixtures/registry-workflows.ts", import.meta.url)); + +const temporal = createWorkflowTestEnv("effect-registry"); + +const activities: TestActivities = { + echo: async (value: unknown) => `echo:${String(value)}`, +}; + +describe("the workflow registry over Temporal", { concurrent: false }, () => { + it("runs registered workflows (activity + dynamic child) and typed failures", async () => { + await temporal.withWorker({ activities, workflowsPath }, async (taskQueue) => { + const engine = makeTemporalClientEngine({ client: temporal.env.client, taskQueue }); + const run = (effect: Effect.Effect): Promise => + Effect.runPromise(Effect.provideService(effect, WorkflowEngine.WorkflowEngine, engine)); + + // Happy path: activity call + child workflow, all dispatched through + // the ONE dynamic default export. + const result = await run(RegistryParent.execute({ requestId: "reg-1", mode: "ok" })); + expect(result).toBe("parent:echo:reg-1|hello:reg-1"); + + // Typed failure decodes into the error channel. + const failed = await run( + Effect.result(RegistryParent.execute({ requestId: "reg-2", mode: "fail" })), + ); + expect(Result.isFailure(failed) && failed.failure).toBe("registry-failure"); + }); + }, 120_000); +}); diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index da6d4e9..aa8493a 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -31,7 +31,6 @@ import { import { callRawActivity, continueAsNew, - makeTemporalWorkflow, offerMailbox as offerMailboxFromWorkflow, setStateCell, takeMailbox, @@ -84,7 +83,7 @@ const _workflowChannels = () => { }; const _handlerInference = () => - makeTemporalWorkflow(Demo, (payload, executionId) => { + Demo.toLayer((payload, executionId) => { expectTypeOf(payload).toEqualTypeOf(); expectTypeOf(executionId).toEqualTypeOf(); return Effect.succeed("ok"); diff --git a/src/engine-sandbox.ts b/src/engine-sandbox.ts index 0bccd56..9681e18 100644 --- a/src/engine-sandbox.ts +++ b/src/engine-sandbox.ts @@ -1,8 +1,8 @@ /** - * Sandbox half: `makeTemporalWorkflow(workflow, handler)` turns an Effect - * workflow definition + handler into a Temporal workflow function. Export it - * from the workflow bundle under the workflow's tag — the export name is the - * Temporal workflow type the client half starts. + * Sandbox half: `workflowBundle(layer)` hosts plain + * `Workflow.toLayer` registrations behind one dynamic Temporal workflow — + * export it as the workflow bundle's DEFAULT export and every registered + * tag becomes a startable Temporal workflow type. * * The whole Effect program runs inside the workflow sandbox, on a * microtask-driven scheduler (the sandbox has no `setImmediate`, and Effect's @@ -36,6 +36,8 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import type { Scope } from "effect/Scope"; +import * as ScopeImpl from "effect/Scope"; +import * as Layer from "effect/Layer"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; @@ -835,37 +837,26 @@ const isCancellationExit = (exit: Exit.Exit): boolean => { return Cause.hasDies(cause) && Cause.squash(cause) instanceof CancelledFailure; }; -/** - * Build the Temporal workflow function for an Effect workflow definition. - * The handler receives the typed payload and execution id and may require - * only workflow-runtime services; anything effectful must reach the outside - * world through `callRawActivity`. - * - * @since 0.1.0 - * @category constructors - */ -export const makeTemporalWorkflow = < - Tag extends string, - Payload extends Workflow.AnyStructSchema, - Success extends Schema.Top, - Error extends Schema.Top, ->( - workflow: Workflow.Workflow, - handler: ( - payload: Payload["Type"], - executionId: string, - ) => Effect.Effect< - Success["Type"], - Error["Type"], - // Scope admits `Workflow.withCompensation` (which registers finalizers - // in the ambient scope `intoResult` provides and closes); SandboxRun is - // what `callRawActivity` reads. - WorkflowEngine.WorkflowEngine | WorkflowEngine.WorkflowInstance | Scope | SandboxRun - >, -): ((wirePayload: unknown) => Promise) => { - const codecs = wireCodecsFor(workflow); +/** The erased registered-handler shape: `never` payload for contravariant + * assignability, R = what the run wrapper provides. */ +type SandboxHandler = ( + payload: never, + executionId: string, +) => Effect.Effect< + unknown, + unknown, + WorkflowEngine.WorkflowEngine | WorkflowEngine.WorkflowInstance | Scope | SandboxRun +>; - return async function run(wirePayload: unknown): Promise { +/** One workflow run: decode the payload, wire the message handlers, race + * the body against cancellation, map the exit onto Temporal's outcomes. */ +const runInSandbox = async ( + workflow: Workflow.Any, + handler: SandboxHandler, + wirePayload: unknown, +): Promise => { + const codecs = wireCodecsFor(workflow); + { ensureSandboxPolyfills(); const state: RunState = { deferredExits: new Map(), @@ -913,12 +904,9 @@ export const makeTemporalWorkflow = < // A malformed payload is a caller bug: fail the RUN, not the workflow // task — a thrown decode error here would make Temporal retry the task // forever, hanging the execution instead of surfacing the defect. - let payload: Payload["Type"]; + let payload: unknown; try { - // SAFETY: `wireCodecsFor` decodes through the workflow's own - // payloadSchema, so the decoded value is exactly `Payload["Type"]` — - // the codec seam types it as unknown. - payload = codecs.decodePayload(wirePayload) as Payload["Type"]; + payload = codecs.decodePayload(wirePayload); } catch (error) { throw ApplicationFailure.create({ type: EXIT_FAILURE_TYPE, @@ -949,7 +937,9 @@ export const makeTemporalWorkflow = < Effect.andThen(Effect.failCause(Cause.interrupt())), ); - const body = Effect.raceFirst(handler(payload, executionId), cancelled); + // SAFETY: payload was decoded through this workflow's own schema, and + // the handler was registered for this workflow. + const body = Effect.raceFirst(handler(payload as never, executionId), cancelled); const program = Workflow.intoResult(body).pipe( Effect.provideService(WorkflowEngine.WorkflowEngine, engine), @@ -1005,5 +995,118 @@ export const makeTemporalWorkflow = < } return codecs.encodeExit(result.exit); }); + } +}; + +interface RegisteredWorkflow { + readonly workflow: Workflow.Any; + readonly execute: SandboxHandler; +} + +const registrationOnly = (method: string) => + Effect.die( + `TemporalRegistrationEngine.${method}: reachable only inside a workflow run — during registration only \`register\` exists`, + ); + +/** Type-level placeholder: the real per-run state is provided at run time + * (run-site context wins over registration context). */ +const registrationSandboxRun = new Proxy({} as RunState, { + get() { + throw new Error( + "SandboxRun accessed during workflow registration — sandbox operations only run inside a workflow body", + ); + }, +}); + +/** layer → registry, memoized per V8 context (reuseV8Context-safe). */ +const workflowRegistries = new Map< + Layer.Layer, + Promise> +>(); + +const buildRegistry = ( + workflows: Layer.Layer, +): Effect.Effect> => + Effect.gen(function* () { + const registry = new Map(); + const registrationEngine = WorkflowEngine.makeUnsafe({ + register: (workflow, execute) => + Effect.sync(() => { + if (registry.has(workflow._tag)) { + throw new Error( + `effect-workflow: workflow tag "${workflow._tag}" registered twice — each tag may appear in ONE toLayer within the layer passed to workflowBundle`, + ); + } + registry.set(workflow._tag, { workflow, execute }); + }), + execute: () => registrationOnly("execute") as never, + poll: () => registrationOnly("poll") as never, + interrupt: () => registrationOnly("interrupt"), + interruptUnsafe: () => registrationOnly("interruptUnsafe"), + resume: () => registrationOnly("resume"), + activityExecute: () => registrationOnly("activityExecute") as never, + deferredResult: () => registrationOnly("deferredResult") as never, + deferredDone: () => registrationOnly("deferredDone"), + scheduleClock: () => registrationOnly("scheduleClock"), + }); + // Never closed: registrations live for the V8 context, so registration + // layers must not own resources. + const scope = yield* ScopeImpl.make(); + yield* Layer.buildWithScope( + Layer.provide( + workflows, + Layer.mergeAll( + Layer.succeed(WorkflowEngine.WorkflowEngine, registrationEngine), + Layer.succeed(SandboxRunTag, registrationSandboxRun), + ), + ), + scope, + ); + return registry; + }); + +/** + * Host `Workflow.toLayer` registrations behind one dynamic Temporal + * workflow, exported as the bundle's DEFAULT export — Temporal routes every + * workflow type to it, and the registry dispatches by tag: + * + * ```ts + * // workflows.ts — the bundle entry + * export default workflowBundle( + * Layer.mergeAll( + * OrderFlow.toLayer(orderHandler), + * BillingFlow.toLayer(billingHandler), + * ), + * ); + * ``` + * + * The same authoring runs on any `WorkflowEngine` (cluster, in-memory); + * choosing Temporal is choosing this default export plus the client half's engine layer. + * + * @since 0.2.0 + * @category constructors + */ +export const workflowBundle = ( + workflows: Layer.Layer, +): ((wirePayload: unknown) => Promise) => { + return async function runDynamic(wirePayload: unknown): Promise { + ensureSandboxPolyfills(); + let registryPromise = workflowRegistries.get(workflows); + if (registryPromise === undefined) { + registryPromise = Effect.runPromise(buildRegistry(workflows), { + scheduler: sandboxScheduler, + }); + workflowRegistries.set(workflows, registryPromise); + } + const registry = await registryPromise; + const workflowType = workflowInfo().workflowType; + const entry = registry.get(workflowType); + if (entry === undefined) { + throw ApplicationFailure.create({ + nonRetryable: true, + message: `effect-workflow: no workflow registered for Temporal type "${workflowType}" — include its \`toLayer\` in the layer passed to workflowBundle`, + }); + } + return runInSandbox(entry.workflow, entry.execute, wirePayload); }; };