Skip to content

Latest commit

 

History

History
183 lines (159 loc) · 8.36 KB

File metadata and controls

183 lines (159 loc) · 8.36 KB

effect-temporal

Durable Effect workflows on Temporal: author workflows with effect/unstable/workflow — schemas, typed errors, composition — and run them on Temporal's retries, timers, signals, history, and the operational tooling around them. One schema'd definition per workflow, activity, or message channel, shared by the workflow bundle, the worker, and every client — the two sides cannot drift.

→ Documentation: effect-temporal.com — start with What is effect-temporal? and Getting started. The same pages live in docs/ (pnpm docs:dev to browse locally).

import { Effect, Exit, Schema } from "effect";
import * as Workflow from "effect/unstable/workflow/Workflow";
import { defineActivity, defineDeferred, sleep } from "@springbird/effect-temporal/definition";
import { workflowBundle } from "@springbird/effect-temporal/bundle";
import { WorkflowClient } from "@springbird/effect-temporal/client";

// Declare once — shared by the workflow bundle, the worker, and every client.
const OrderFlow = Workflow.make("orderFlow", {
  payload: { orderId: Schema.String },
  idempotencyKey: ({ orderId }) => orderId,
  success: Schema.String,
});
const Charge = defineActivity("charge", {
  payload: { orderId: Schema.String },
  success: Schema.String,
});
const ManagerApproval = defineDeferred("manager-approval", {
  success: Schema.String,
});

// The body calls the declarations directly. Its only requirement is the
// WorkflowOps service — workflowBundle provides Temporal's; the testing
// module provides an in-memory one, so the same handler runs in a plain
// unit test with no engine.
const OrderFlowLive = OrderFlow.toLayer((payload) =>
  Effect.gen(function* () {
    const paid = yield* Charge({ orderId: payload.orderId });
    yield* sleep({ name: "cooling-off", duration: "3 days" });
    const approver = yield* ManagerApproval.await;
    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.
// Client-side ops take the declaration itself.
const program = Effect.gen(function* () {
  const wf = yield* WorkflowClient;
  yield* wf.completeDeferred(ManagerApproval, workflowId, Exit.succeed("ben"));
  return yield* wf.execute(OrderFlow, { orderId: "ord_123" });
});
pnpm add @springbird/effect-temporal   # or npm / yarn / bun

Highlights

  • A real WorkflowEngine — implements Effect's durable-workflow engine contract over Temporal, for codebases that already run Temporal and do not want a second durable-execution system (Effect's own effect/unstable/cluster engine persists to its own SQL tables).
  • One package, tree-shakeable modules@springbird/effect-temporal/definition (declare capabilities once, engine-agnostic — activities, messages, state, timers, continue-as-new, children, versioning), /bundle (the one file the worker points at), /engine-sandbox (engine-level bundle), /engine-client + /client (ordinary Node), /activities (worker), /testing, /nexus, /lint. Nexus, worker, and testing peers are optional.
  • Typed end to end — payloads, results, and failures are schemas at every crossing: workflow results, activity calls, signals, queries, update responses. Typed failures land in the Effect error channel on the reading side; runs show red in the Temporal UI.
  • Entity workflows, completedefineMailbox (repeated inbound signals), defineUpdate (request/response with typed success and failure), defineState (queryable published state, readable after the run closes), continueAsNew, patch-marker versioning (version), and schema evolution (evolved) make the long-lived, observable, mutable entity expressible end to end.
  • Cancellation that composes — workflow cancel interrupts the handler fiber (finalizers and Workflow.withCompensation run, their activity calls still work), and workflow-internal interruption — Effect.timeout, a lost race — cancels the in-flight server-side call rather than abandoning it.
  • Global idempotency — the execution id (a digest of the payload's idempotency key) is the Temporal workflow id under REJECT_DUPLICATE; a repeated execute, a racing parent, or a Nexus caller attaches and gets the original result. Explicit caller-chosen workflow ids are first-class for brownfield fleets.
  • Deterministic by construction — the whole Effect program runs inside the workflow sandbox on a microtask scheduler; clocks, randomness, and timers resolve to Temporal's replay-stable primitives. The mechanism is documented in How the engine works.
  • Testing storymakeTestWorkflowOps (an in-memory WorkflowOps runtime: the same handler that runs on Temporal runs in a plain unit test with no engine), makeFakeTemporalClient (typed start/signal/termination records, loud on everything unstubbed) for service tests, and startWorkflowTestHarness over Temporal's time-skipping test server for real workflow semantics.
  • Lint the footguns — an oxlint/ESLint plugin ships in the package (@springbird/effect-temporal/lint + presets) for the authoring rules a linter can see.
  • Sample-backed — every capability mirrors a temporalio/samples-typescript sample, each validated by a test: EXAMPLES.md is the coverage matrix.

Repository layout

src/                the published modules (one file per subpath export)
src/__tests__/      the test suite — every file boots a real Temporal
                    test server (time-skipping or local dev)
src/lint.js         the oxlint/ESLint plugin
oxlint-presets/     shipped lint presets
docs/               the VitePress documentation site
examples/           runnable end-to-end demos — no Docker, no setup:
                    order-saga (the one-shot saga: typed activities,
                    compensation, approval, cancellation) and subscription
                    (the long-lived entity: updates, mailboxes,
                    continue-as-new). pnpm run build, then
                    pnpm --dir examples/<name> start
EXAMPLES.md         temporalio/samples-typescript coverage matrix

Development

pnpm install
pnpm run typecheck   # tsc, strict + exactOptionalPropertyTypes
pnpm run lint        # oxlint, dogfooding the shipped plugin
pnpm run build       # emit dist (ESM + declarations)
pnpm run test        # vitest — real Temporal test servers, ~15s warm
pnpm run docs:dev    # the docs site, locally

Run build before test on a fresh clone: the lint test consumes the shipped preset, which resolves the plugin through the package's own exports to dist/lint.js. Release history lives in CHANGELOG.md.

Versioning policy

Pre-1.0: minor bumps may break APIs. The effect peer dependency is pinned exactly (currently 4.0.0-rc.112) and on purpose — the engine implements interfaces from effect/unstable/*, whose API can move between releases. Each release states the one effect version it is built and tested against; tracking a new effect release is a new release of this package.

Releasing

CI (.github/workflows/ci.yml) runs typecheck/lint/build/test/docs on every push and PR. Publishing to npm happens on version tags:

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

The publish job runs npm publish with provenance; it needs an NPM_TOKEN repository secret.

Acknowledgements

Thanks to Warp — open-sourcing effect-mq was the push for us to open-source our own Effect wrapper around Temporal, and its codebase set the bar for what clean Effect v4 packages look like. If effect-temporal's shape feels familiar, that's why: where effect-mq is Effect-native background jobs without extra infrastructure, effect-temporal is Effect-native durable workflows for teams already running Temporal.

License

MIT