From a0878ee2287e1210a233d232951ff0a02b0550a5 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Thu, 10 Sep 2026 21:55:48 -0400 Subject: [PATCH] 0.4.0: close the gaps between definition and the legacy authoring surface Timers (sleep, sleepUntil), continueAsNew, and child workflows (executeChild) join the WorkflowOps seam; versioned() is the run-table form of version(); the activity type helpers move to definition and codecsFor/ACTIVITY_EXIT_TYPE to wire; every client-side op accepts the declaration directly, plus completeDeferred; the new bundle module is the one sandbox import an application has. makeTestWorkflowOps implements all of it in memory: TestClock-driven timers (not instant), recorded continue-as-new, in-process children via handleWorkflow bindings with attach-on-taken-id. The fake client gains offer/offersTo; the live harness gains offer/request/stateOf/resolve. Deprecated (removed in 0.5.0): engine-sandbox's per-primitive calls, sleepUntil, continueAsNew, UpdateRequest alias; TypedActivity.make; the mailbox/update/state-cell make constructors; Versioning.match/version. New prefer-definition lint rule (recommended preset, error) reports each with its replacement. A history recorded on 0.3.0 replays through this bundle (replay-compat.test.ts). effect pinned to 4.0.0-rc.112. --- CHANGELOG.md | 117 +++- EXAMPLES.md | 21 +- README.md | 17 +- docs/guide/activities.md | 12 + docs/guide/child-workflows.md | 16 +- docs/guide/continue-as-new.md | 17 +- docs/guide/declaring-capabilities.md | 33 +- docs/guide/getting-started.md | 10 +- docs/guide/introduction.md | 13 +- docs/guide/lint-rules.md | 15 +- docs/guide/mailboxes.md | 10 +- docs/guide/queryable-state.md | 6 +- docs/guide/testing.md | 46 +- docs/guide/timers-and-approvals.md | 62 +- docs/guide/updates.md | 6 +- docs/guide/versioning.md | 30 +- docs/index.md | 13 +- docs/reference/how-it-works.md | 2 +- examples/order-saga/package.json | 2 +- examples/order-saga/src/main.ts | 16 +- examples/order-saga/src/workflows.ts | 10 +- examples/subscription/package.json | 2 +- examples/subscription/src/main.ts | 12 +- examples/subscription/src/workflows.ts | 11 +- oxlint-presets/recommended.json | 3 +- package.json | 12 +- pnpm-lock.yaml | 68 +- src/__tests__/definition.test.ts | 261 +++++++- src/__tests__/fixtures/batch-workflows.ts | 8 +- src/__tests__/fixtures/chain-workflows-v1.ts | 3 +- .../fixtures/chain-workflows-v2-unguarded.ts | 3 +- src/__tests__/fixtures/chain-workflows-v2.ts | 3 +- src/__tests__/fixtures/chain-workflows-v3.ts | 3 +- src/__tests__/fixtures/child-workflows.ts | 3 +- src/__tests__/fixtures/definition-demo.ts | 82 +++ .../fixtures/definition-workflows.ts | 31 +- src/__tests__/fixtures/demo-workflows.ts | 3 +- src/__tests__/fixtures/dsl-workflows.ts | 3 +- .../definition-order-0.3.0.history.b64 | 1 + src/__tests__/fixtures/lock-workflows.ts | 3 +- src/__tests__/fixtures/loop-workflows.ts | 3 +- src/__tests__/fixtures/mailbox-workflows.ts | 2 +- src/__tests__/fixtures/message-workflows.ts | 2 +- src/__tests__/fixtures/nexus-workflows.ts | 8 +- src/__tests__/fixtures/polling-workflows.ts | 3 +- src/__tests__/fixtures/registry-workflows.ts | 3 +- .../fixtures/short-sleep-workflows.ts | 3 +- .../fixtures/transaction-workflows.ts | 2 +- .../fixtures/typed-activity-workflows.ts | 3 +- src/__tests__/lint.test.ts | 80 ++- src/__tests__/replay-compat.test.ts | 44 ++ src/__tests__/testing.test.ts | 16 + src/__tests__/typed-activity.test.ts | 3 +- src/activities.ts | 11 +- src/bundle.ts | 37 ++ src/client.ts | 37 +- src/definition.ts | 622 ++++++++++++++++-- src/engine-client.ts | 85 ++- src/engine-sandbox.ts | 113 +++- src/lint.js | 136 +++- src/mailbox.ts | 14 +- src/state-cell.ts | 13 +- src/testing.ts | 247 ++++++- src/typed-activity.ts | 275 +++----- src/update.ts | 17 +- src/versioning.ts | 16 + src/wire.ts | 39 ++ 67 files changed, 2227 insertions(+), 596 deletions(-) create mode 100644 src/__tests__/fixtures/histories/definition-order-0.3.0.history.b64 create mode 100644 src/__tests__/replay-compat.test.ts create mode 100644 src/bundle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8506cf..22fd770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,122 @@ 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.3.0 (unreleased) +## Unreleased / planned — 0.5.0 + +The removal PR is this checklist. Everything below was deprecated in 0.4.0 +with its replacement named in the JSDoc and reported by the +`prefer-definition` lint rule; nothing else changes. + +- REMOVE the `typed-activity` module and its package export + (`TypedActivity.make` → `defineActivity`; `PayloadOf`/`SuccessOf`/`ErrorOf`/ + `AnyTypedActivity`/`TypedActivity`/`TypedActivityOptions`/ + `DEFAULT_ACTIVITY_OPTIONS` → `definition`; `codecsFor`/`ACTIVITY_EXIT_TYPE`/ + `TypedActivityCodecs` → `wire`). +- REMOVE the `versioning` module and its package export (`match` → `versioned`, + `version` → `version` from `definition`). Move `deprecateVersion` / + `deprecatePatch` / `patched` (the Temporal-only retirement step) to `bundle`. +- REMOVE from `engine-sandbox`: `callActivity`, `takeMailbox`, `pollMailbox`, + `takeUpdate`, `setStateCell`, `sleepUntil`, `continueAsNew`, and the + `UpdateRequest` alias. The Temporal `WorkflowOps` runtime keeps + their bodies as private functions. `callRawActivity`, `offerMailbox` + (workflow → workflow), `callNexusWorkflowOperation`, `SandboxRun`, and + `workflowBundle` stay. +- REMOVE `make` from `mailbox`, `update`, `state-cell` (→ `defineMailbox`, + `defineUpdate`, `defineState`) and drop those three package exports — the + modules become internal wire homes (`MAILBOX_SIGNAL`, `WORKFLOW_UPDATE`, + `STATE_CELL_QUERY`, codecs) consumed by the engine halves and `testing`. +- DELETE the deprecation entries from `prefer-definition` once the symbols are + gone (the rule stays, empty tables are fine, so a future deprecation has a + home). +- KEEP `histories/definition-order-0.3.0` in the replay drill; record a + 0.4.0 history alongside it. + +## 0.4.0 (2026-09-10) + +Closes the gaps between the `definition` module and the legacy authoring +surface: after this release a consumer imports `definition`, `bundle`, +`activities`, `client`/`engine-client`, `testing`, and (engine-level) `wire` +— never `engine-sandbox` except for the engine-level escape hatches, +never `typed-activity`, `versioning`, or `mailbox`. + +- NEW: timers on the `WorkflowOps` seam — `sleep({ name, duration })` and + `sleepUntil({ name, timestamp })` from `definition`, requiring only + `WorkflowOps`. On Temporal they dispatch to `DurableClock.sleep` / the + existing `sleepUntil` (same timestamp rule, now the shared + `sleepUntilTarget`: zone-less and unparseable timestamps die). In + `makeTestWorkflowOps` they follow Effect's `Clock`, so a `TestClock` can + `adjust` past them — deliberately not instant, so a mailbox take racing a + grace-period timer is testable in both orders (pinned on both engines). +- NEW: `continueAsNew(workflow, payload, options?)` on the seam and in + `definition`. In memory it interrupts the handler fiber and records the + continuation (`world.continuedAsNew`, `world.continuedAsNewOf(W)`), payload + round-tripped through the workflow's payload schema (a schema-invalid + payload dies, as on the wire). +- NEW: child workflows on the seam — `executeChild(workflow, payload, + { discard? })` from `definition`. On Temporal it is upstream `execute` + under the sandbox engine (identical commands: `startChild` with the digest + id, `REQUEST_CANCEL`/`ABANDON`, attach-on-taken). `makeTestWorkflowOps` + accepts `workflows: [handleWorkflow(Child, handler)]` and runs children + in-process with schema round-tripping, discard-forks, and attach on a + taken id. +- NEW: `versioned(site, { v1: run1, v2: run2 })` — the run-table form of + `version` (key order is the chain order, oldest first), built on the same + `WorkflowOps.version`; the `versioning-on-main-fiber` lint rule recognises + it (alias-aware) alongside `version`. +- NEW: the activity type helpers live on `definition` — `PayloadOf`, + `SuccessOf`, `ErrorOf`, `AnyTypedActivity`, `TypedActivity`, + `TypedActivityOptions`, `DEFAULT_ACTIVITY_OPTIONS`. `codecsFor`, + `TypedActivityCodecs`, and `ACTIVITY_EXIT_TYPE` moved to `wire` (the + `typed-activity` module re-exports everything, deprecated). +- NEW: every client-side operation takes the declaration directly — + `offerMailbox(Priority, …)`, `executeUpdate(SetAmount, …)`, + `readStateCell(Status, …)`, `deferredState(Approval, …)` in `engine-client` + and on `WorkflowClient`; the underlying primitive (`Priority.mailbox`, …) is + still accepted (`MailboxLike` / `UpdateLike` / `StateCellLike` / + `DeferredLike`). NEW `completeDeferred(Approval, { client, workflowId, + exit })` / `wf.completeDeferred(Approval, workflowId, exit)`: the client + half of `Approval.await` — same done-signal as `DurableDeferred.done`, no + token, no `WorkflowEngine`. The workflow → workflow `offerMailbox` + (engine-sandbox) accepts the declaration too. +- NEW: `makeFakeTemporalClient` gains `offer(Priority, workflowId, payload)` + and `offersTo(Priority)` (decoded through the declaration) — no + `MAILBOX_SIGNAL` import in consumer tests. The live harness client gains + `offer` / `request` / `stateOf` / `resolve`, mirroring the in-memory world. +- NEW: the `bundle` module — `workflowBundle`'s home, "the one file the + Temporal worker points at". `engine-sandbox` still exports it; its other + exports are documented as engine-level (`callRawActivity`, workflow → + workflow `offerMailbox`, `callNexusWorkflowOperation`, `SandboxRun`) or + deprecated (below). +- NEW: `prefer-definition` lint rule, on in the `recommended` preset as an + error: reports any import of a deprecated symbol from this package's + modules (published specifier or relative path) with the replacement in the + message, so `oxlint` fails on regressions. +- NEW: `replay-compat.test.ts` — a history recorded on 0.3.0 + (`fixtures/histories/definition-order-0.3.0`) replays through the current + bundle. Wire identity is unchanged: activity types, signal/query/update + names, and patch-marker ids are byte-identical. +- DEPRECATED (removed in 0.5.0; replacement in each JSDoc): + `engine-sandbox`'s `callActivity`, `takeMailbox`, `pollMailbox`, + `takeUpdate`, `setStateCell`, `sleepUntil`, `continueAsNew`, and the + `UpdateRequest` alias (use `definition`'s + `UpdateRequest` — one parameter order); `TypedActivity.make`; + `DurableMailbox.make`, `DurableUpdate.make`, `StateCell.make`; + `Versioning.match` / `Versioning.version`; the whole `typed-activity` and + `versioning` modules. +- `defineDeferred` / `defineMailbox` / `defineUpdate` / `defineState` return + named interfaces (`DefinedDeferred`, `DefinedMailbox`, `DefinedUpdate`, + `DefinedState`) — structurally what they returned before. +- The repository authors with the new surface throughout: fixtures and + examples import `workflowBundle` from `bundle`, the examples use `sleep` + and `continueAsNew` from `definition` and drive declarations directly; + the versioning-chain and loop fixtures deliberately stay on the deprecated + calls so the replay drills keep covering them. +- `effect` peer/dev pin: `4.0.0-rc.112` (was `4.0.0-beta.101`); full suite + and both examples green against it. `@temporalio/*` stays `1.19.0`. + +Built and tested against `effect@4.0.0-rc.112` and `@temporalio/*@1.19.0`. + +## 0.3.0 (2026-09-10) - NEW: the `definition` module — declare each capability once and use it directly inside handlers: `defineActivity` (callable: `yield* Charge({ orderId })`), diff --git a/EXAMPLES.md b/EXAMPLES.md index 0c827e6..e910a41 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -35,22 +35,22 @@ workflow-semantics content). | Sample | Status | With this package | | ------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [timer-progress](https://github.com/temporalio/samples-typescript/tree/main/timer-progress) | ✅ | `DurableClock.sleep`. Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) (delay skipped by the time-skipping server). | +| [timer-progress](https://github.com/temporalio/samples-typescript/tree/main/timer-progress) | ✅ | `sleep` (definition module; `DurableClock.sleep` underneath on Temporal, an Effect `Clock` in memory). Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) (delay skipped by the time-skipping server). | | [sleep-for-days](https://github.com/temporalio/samples-typescript/tree/main/sleep-for-days) | ✅ | Same mechanism; a Temporal timer's duration is unbounded. Test: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) pins a 2-minute durable delay under time skipping — the same mechanism at any duration. | -| [timer-examples](https://github.com/temporalio/samples-typescript/tree/main/timer-examples) | ✅ | The order-timeout race is `Effect.raceFirst(activity, DurableClock.sleep)` — pinned by the `timeout-activity` fixture mode and the [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) test "cancels the server-side activity when the calling FIBER is interrupted", which asserts the timed-out activity is cancelled server-side, not abandoned. The `UpdatableTimer` half races the mailbox's `.take` against the timer. Test: [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts). | +| [timer-examples](https://github.com/temporalio/samples-typescript/tree/main/timer-examples) | ✅ | The order-timeout race is `Effect.raceFirst(activity, sleep)` — pinned by the `timeout-activity` fixture mode and the [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts) test "cancels the server-side activity when the calling FIBER is interrupted", which asserts the timed-out activity is cancelled server-side, not abandoned. The `UpdatableTimer` half races the mailbox's `.take` against the timer. Tests: [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts), and [definition.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/definition.test.ts) runs the same grace-period race on the in-memory runtime under `TestClock` in both orders AND on Temporal. | ## Workflow composition | Sample | Status | With this package | | ------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [child-workflows](https://github.com/temporalio/samples-typescript/tree/main/child-workflows) | ✅ | `MyChild.execute` in a workflow body; typed results/failures compose, cancellation cascades, discard = fire-and-forget, taken ids attach. Tests: [child.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/child.test.ts). | +| [child-workflows](https://github.com/temporalio/samples-typescript/tree/main/child-workflows) | ✅ | `executeChild(Child, payload)` (definition module; `MyChild.execute` in a body is the same engine path); typed results/failures compose, cancellation cascades, `{ discard: true }` = fire-and-forget, taken ids attach. Tests: [child.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/child.test.ts), [definition.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/definition.test.ts) (same parent handler on the in-memory runtime via `handleWorkflow` bindings and on Temporal). | | [saga](https://github.com/temporalio/samples-typescript/tree/main/saga) | ✅ | `Workflow.withCompensation`, firing on typed failure and on interrupt. Tests: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts), [child.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/child.test.ts). | -| [continue-as-new](https://github.com/temporalio/samples-typescript/tree/main/continue-as-new) | ✅ | `continueAsNew(workflow, payload)` (engine-sandbox): ends the run and starts a fresh one with the same workflow id. Test: [continue-as-new.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/continue-as-new.test.ts) — asserts the fresh history, not just the looped result. | +| [continue-as-new](https://github.com/temporalio/samples-typescript/tree/main/continue-as-new) | ✅ | `continueAsNew(workflow, payload)` (definition module): ends the run and starts a fresh one with the same workflow id. Tests: [continue-as-new.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/continue-as-new.test.ts) — asserts the fresh history, not just the looped result; [definition.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/definition.test.ts) — the in-memory runtime records the schema-checked continuation, Temporal rolls the history. | | [batch-sliding-window](https://github.com/temporalio/samples-typescript/tree/main/batch-sliding-window) | ✅ | Sliding window of discarded children reporting completion via workflow-to-workflow mailbox; the orchestrator continues-as-new mid-batch, draining reports with the mailbox's `.poll` into the carried in-flight set. Test: [batch.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/batch.test.ts). | | [dsl-interpreter](https://github.com/temporalio/samples-typescript/tree/main/dsl-interpreter) | ✅ | The payload carries a declarative program (sequential steps of single or parallel activity calls) and Effect interprets it in-workflow. Test: [dsl.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/dsl.test.ts). | | [early-return](https://github.com/temporalio/samples-typescript/tree/main/early-return) | ✅ | A forked fiber serves the confirmation `DurableUpdate` once authorization lands, while the main flow continues to the final result. Test: [early-return.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/early-return.test.ts). | | [polling](https://github.com/temporalio/samples-typescript/tree/main/polling) | ✅ | The infrequent variant: the poll interval IS the activity retry policy, so the workflow is one `callRawActivity`. Test: [polling.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/polling.test.ts). Frequent polling inside one activity is activity-side; unbounded polling adds `continueAsNew`. | -| [patching-api](https://github.com/temporalio/samples-typescript/tree/main/patching-api) | ✅ | `Versioning.match`: an ordered version chain per code site with typed version literals and union-typed channels; fresh runs take the newest case, replays their own. Tests: [versioning-chain.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/versioning-chain.test.ts) (three generations, append-safety, negative control). | +| [patching-api](https://github.com/temporalio/samples-typescript/tree/main/patching-api) | ✅ | `version(site, names)` / `versioned(site, { v1, v2 })` (definition module): an ordered version chain per code site with typed version literals and union-typed channels; fresh runs take the newest case, replays their own. Tests: [versioning-chain.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/versioning-chain.test.ts) (three generations, append-safety, negative control — on the deprecated `Versioning.match`, kept byte-identical for the replay drill), [definition.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/definition.test.ts) (`versioned` on both engines), [replay-compat.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/replay-compat.test.ts) (a history recorded on 0.3.0 replays through the current bundle). | ## Scheduling and client features @@ -108,10 +108,13 @@ are worth a smoke test once the package is adopted in a real worker. Every sample with workflow-semantics content is supported (tested) or works unchanged; the remainder is infrastructure with no package involvement. Where a sample needed a primitive the upstream Effect API does not define, -the package provides one: `DurableMailbox` (repeated signals, long-lived -state), `DurableUpdate` (request/response with typed channels), `StateCell` -(queryable state), `continueAsNew` (unbounded workflows), schedule and -Nexus bridges, and patch-marker versioning. +the package provides one, declared through the `definition` module: +`defineMailbox` (repeated signals, long-lived state), `defineUpdate` +(request/response with typed channels), `defineState` (queryable state), +`continueAsNew` (unbounded workflows), `executeChild`, `sleep`/`sleepUntil`, +`version`/`versioned` (patch-marker versioning), plus schedule and Nexus +bridges. Every one of them runs on the in-memory test runtime as well as on +Temporal. New capabilities land with a test mirroring the corresponding sample, in this package's suite. diff --git a/README.md b/README.md index e351d97..17d726a 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,10 @@ start with The same pages live in [docs/](docs/) (`pnpm docs:dev` to browse locally). ```ts -import { Effect, Schema } from "effect"; +import { Effect, Exit, Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition"; -import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +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. @@ -42,7 +41,7 @@ const ManagerApproval = defineDeferred("manager-approval", { const OrderFlowLive = OrderFlow.toLayer((payload) => Effect.gen(function* () { const paid = yield* Charge({ orderId: payload.orderId }); - yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" }); + yield* sleep({ name: "cooling-off", duration: "3 days" }); const approver = yield* ManagerApproval.await; return `${paid}:approved-by:${approver}`; }), @@ -50,8 +49,10 @@ const OrderFlowLive = OrderFlow.toLayer((payload) => 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" }); }); ``` @@ -67,7 +68,9 @@ pnpm add @springbird/effect-temporal # or npm / yarn / bun 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), `/engine-sandbox` (workflow + (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. @@ -146,7 +149,7 @@ shipped preset, which resolves the plugin through the package's own ## Versioning policy Pre-1.0: **minor bumps may break APIs**. The `effect` peer dependency is -pinned **exactly** (currently `4.0.0-beta.101`) and on purpose — the engine +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 diff --git a/docs/guide/activities.md b/docs/guide/activities.md index a5704e0..fbd28b9 100644 --- a/docs/guide/activities.md +++ b/docs/guide/activities.md @@ -81,6 +81,18 @@ Payloads are decoded (validated) before a handler runs — a payload failing its An `ActivityRunner` with a service requirement `R` lets handlers use your application services; supply the runner from your `ManagedRuntime`. +### Naming the types + +The declaration's decoded types are available as helpers from the definition module — for typing an implementation you write separately, or a service that takes a payload: + +```ts +import type { PayloadOf, SuccessOf, ErrorOf } from "@springbird/effect-temporal/definition"; + +const reserve = (payload: PayloadOf): Effect.Effect, ErrorOf> => ... +``` + +`AnyTypedActivity` is the type-erased declaration for code generic over any activity; `codecsFor` (from `@springbird/effect-temporal/wire`) builds the three channel codecs from one, for worker-side or test-side code that speaks the wire. + ## Raw calls `callRawActivity` invokes any Temporal activity proxy as an Effect: diff --git a/docs/guide/child-workflows.md b/docs/guide/child-workflows.md index 8648b1b..4c3fa52 100644 --- a/docs/guide/child-workflows.md +++ b/docs/guide/child-workflows.md @@ -1,15 +1,17 @@ # Child workflows -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. +Starting one workflow from 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 +import { executeChild } from "@springbird/effect-temporal/definition"; + const ParentDemoLive = ParentDemo.toLayer((payload) => Effect.gen(function* () { const reservation = yield* Reserve({ sku: payload.sku, quantity: 1 }); // Starts a Temporal child workflow; typed results and failures compose // into the parent like any Effect. - const child = yield* ChildDemo.execute({ + const child = yield* executeChild(ChildDemo, { requestId: `${payload.requestId}-child`, }); @@ -20,16 +22,18 @@ const ParentDemoLive = ParentDemo.toLayer((payload) => A child's typed failure lands in the parent's error channel; a child's defect dies the parent step. Catch, retry, or compensate with ordinary Effect combinators. +`executeChild` requires only `WorkflowOps`, so a phase runner typed against the seam can start children, and the [in-memory runtime](/guide/testing#the-in-memory-runtime) runs them in-process from a `handleWorkflow` binding. The upstream spelling, `ChildDemo.execute(payload)` inside a body, still works on Temporal (it is the same engine path — the recorded commands are identical) but requires `WorkflowEngine`, which only the Temporal runtime provides. + ## Lifetime: awaited vs discarded - **Awaited children** (the default) carry `REQUEST_CANCEL` parent-close policy: cancelling the parent cancels them, so their own compensation runs — even when the parent is terminated outright. -- **Discarded children** — `ChildDemo.execute(payload, { discard: true })` — outlive the parent (`ABANDON`): fire-and-forget spawns whose lifecycle is their own. +- **Discarded children** — `executeChild(ChildDemo, payload, { discard: true })` — return the child's execution id immediately and outlive the parent (`ABANDON`): fire-and-forget spawns whose lifecycle is their own. ## Idempotency stays global -A child's workflow id is its **digest execution id**, exactly as if a client had started it — the [idempotency contract](/guide/defining-workflows#idempotency-and-execution-ids) does not stop at process boundaries. When the id is already taken — another parent started it, or a completed earlier run holds it under `REJECT_DUPLICATE` — the parent **attaches** and returns that execution's result instead of failing. +A child's workflow id is its **digest execution id**, exactly as if a client had started it — the [idempotency contract](/guide/defining-workflows#idempotency-and-execution-ids) does not stop at process boundaries. When the id is already taken — another parent started it, or a completed earlier run holds it under `REJECT_DUPLICATE` — the parent **attaches** and returns that execution's result instead of failing. The in-memory runtime keeps the same contract: a second `executeChild` on a taken id attaches to the running child. -Attach works through the bridge activity (`makeEffectWorkflowActivities` — a workflow cannot await an execution it does not own), polling the foreign execution's status: +Attach on Temporal works through the bridge activity (`makeEffectWorkflowActivities` — a workflow cannot await an execution it does not own), polling the foreign execution's status: - each poll costs one activity plus one timer of history; - the interval backs off exponentially from 5s to a 60s cap; @@ -38,7 +42,7 @@ Attach works through the bridge activity (`makeEffectWorkflowActivities` — a w ```ts // Two parents race to start the same child: one starts it, the other // attaches; both see the same typed result. -const result = yield* SharedStep.execute({ dedupeKey }); +const result = yield* executeChild(SharedStep, { dedupeKey }); ``` ## Cancellation composes diff --git a/docs/guide/continue-as-new.md b/docs/guide/continue-as-new.md index d34a3c8..a00ec0b 100644 --- a/docs/guide/continue-as-new.md +++ b/docs/guide/continue-as-new.md @@ -3,7 +3,7 @@ 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 } from "@springbird/effect-temporal/engine-sandbox"; +import { continueAsNew } from "@springbird/effect-temporal/definition"; const LoopDemoLive = LoopDemo.toLayer((payload) => Effect.gen(function* () { @@ -18,7 +18,7 @@ const LoopDemoLive = LoopDemo.toLayer((payload) => ); ``` -`continueAsNew(workflow, payload)` has type `Effect` — nothing runs after it. The next run receives the payload you pass, so all carried state must be encodable through the payload schema. +`continueAsNew(workflow, payload)` has type `Effect` — nothing runs after it. The next run receives the payload you pass, so all carried state must be encodable through the payload schema; a payload that fails it dies **before** the run ends, on every engine. ## It unwinds as a throw @@ -54,3 +54,16 @@ Pass a Temporal memo for the next run when you use memos for ops tooling: ```ts yield* continueAsNew(LoopDemo, nextPayload, { memo: { cursor: "2026-08-01" } }); ``` + +## In tests + +In the [in-memory runtime](/guide/testing#the-in-memory-runtime) `continueAsNew` interrupts the handler fiber and records the continuation; the test reads it back typed: + +```ts +const exit = yield* Effect.exit(loopHandler({ requestId: "r", iteration: 0 }).pipe(Effect.provide(world.layer))); +// exit is an interruption +const next = yield* world.continuedAsNewOf(LoopDemo); +// Option.some({ requestId: "r", iteration: 1 }) +``` + +On the live harness a `wf.execute` follows the continue-as-new chain to the final run's result, as a real client does. diff --git a/docs/guide/declaring-capabilities.md b/docs/guide/declaring-capabilities.md index 2005fad..7adc6b2 100644 --- a/docs/guide/declaring-capabilities.md +++ b/docs/guide/declaring-capabilities.md @@ -27,9 +27,9 @@ One declaration is the whole contract: the workflow bundle calls it, the worker ## The one seam: `WorkflowOps` -Every in-handler operation requires exactly one service, `WorkflowOps` — the seam an engine implements (one operation per primitive kind). The handler itself imports **nothing engine-shaped**: no `engine-sandbox`, no `@temporalio/*`. +Every in-handler operation requires exactly one service, `WorkflowOps` — the seam an engine implements (one operation per primitive kind). The handler itself imports **nothing engine-shaped**: no `engine-sandbox`, no `@temporalio/*`. The only sandbox-side import an application has is `workflowBundle`, in the bundle's entry file, from `@springbird/effect-temporal/bundle`. -- **On Temporal** — `workflowBundle` provides the Temporal `WorkflowOps` automatically: activity calls become real Temporal activities, `await`/`take` block on signals in history, `set` publishes to a query, `version` records patch markers. +- **On Temporal** — `workflowBundle` provides the Temporal `WorkflowOps` automatically: activity calls become real Temporal activities, `await`/`take` block on signals in history, `set` publishes to a query, `sleep` is a durable timer, `executeChild` is `startChild`, `continueAsNew` is continue-as-new, `version` records patch markers. - **In tests** — `makeTestWorkflowOps` (the [testing module](/guide/testing#the-in-memory-runtime)) provides an in-memory `WorkflowOps`, so the *same handler function* runs in a plain unit test with no engine, no sandbox, no server. ## The surface @@ -37,13 +37,28 @@ Every in-handler operation requires exactly one service, `WorkflowOps` — the s | Declaration | Inside the handler | Outside the handler | | --- | --- | --- | | `defineActivity(name, { payload, success?, error?, options? })` | `yield* Charge(payload)` — typed success, typed error channel | implemented on the worker: `handle(Charge, impl)` + `implementActivities` | -| `defineDeferred(name, { success })` | `yield* Approval.await` | `Approval.deferred` → `DurableDeferred.done`, `wf.deferredState` | -| `defineMailbox(name, { payload })` | `yield* Priority.take` / `yield* Priority.poll` | `Priority.mailbox` → `wf.offerMailbox` | -| `defineUpdate(name, { payload, success, error })` | `yield* SetAmount.take` — respond exactly once | `SetAmount.update` → `wf.executeUpdate` | -| `defineState(name, { value })` | `yield* Status.set(value)` | `Status.cell` → `wf.readStateCell` | -| `version(site, names)` | `yield* version("pricing", ["v1", "v2"])` | — ([versioning](/guide/versioning)) | +| `defineDeferred(name, { success })` | `yield* Approval.await` | `wf.completeDeferred(Approval, id, exit)`, `wf.deferredState(Approval, id)` | +| `defineMailbox(name, { payload })` | `yield* Priority.take` / `yield* Priority.poll` | `wf.offerMailbox(Priority, id, payload)` | +| `defineUpdate(name, { payload, success, error })` | `yield* SetAmount.take` — respond exactly once | `wf.executeUpdate(SetAmount, id, payload)` | +| `defineState(name, { value })` | `yield* Status.set(value)` | `wf.readStateCell(Status, id)` | -Each declaration carries its **underlying primitive** — `Approval.deferred`, `Priority.mailbox`, `SetAmount.update`, `Status.cell`, and a defined activity *is* its `TypedActivity` — which is what the client-side surfaces (`WorkflowClient`, the standalone `engine-client` operations, `DurableDeferred.done`) take. The low-level modules (`/typed-activity`, `/mailbox`, `/update`, `/state-cell`) are those definitions; `define*` is the one-declaration surface over them. +And the operations that need no declaration — all from the same module, all requiring only `WorkflowOps`: + +| Operation | What it does | Guide | +| --- | --- | --- | +| `sleep({ name, duration })` | durable named timer | [Timers](/guide/timers-and-approvals) | +| `sleepUntil({ name, timestamp })` | durable timer to an absolute instant; no-op when past | [Timers](/guide/timers-and-approvals#sleeping-until-an-absolute-time) | +| `continueAsNew(workflow, payload, options?)` | end this run, start a fresh one with the payload | [Continue-as-new](/guide/continue-as-new) | +| `executeChild(workflow, payload, { discard? })` | start a child workflow, awaited or fire-and-forget | [Child workflows](/guide/child-workflows) | +| `version(site, names)` | patch-marker branch by name | [Versioning](/guide/versioning) | +| `versioned(site, { v1: run1, v2: run2 })` | patch-marker branch, run-table form | [Versioning](/guide/versioning#the-run-table-form-versioned) | +| `evolved(current, legacy, migrate)` | schema evolution across in-flight runs | [Versioning](/guide/versioning#schema-evolution-evolved) | + +**The declaration is the only symbol you ever name.** Every client-side surface — the `WorkflowClient` service, the standalone `engine-client` operations, the [in-memory test world](/guide/testing#the-in-memory-runtime), the fake client, the live harness — takes the declaration directly. Each declaration still carries its underlying primitive (`Approval.deferred`, `Priority.mailbox`, `SetAmount.update`, `Status.cell`; a defined activity *is* its `TypedActivity` projection) for engine-level code, and every surface accepts that too. The decoded types are named with `PayloadOf`, `SuccessOf<…>`, `ErrorOf<…>` from this module. + +::: warning Deprecated authoring surface +The pre-0.3.0 modules — `/typed-activity`, `/versioning`, the `make` constructors of `/mailbox`, `/update`, `/state-cell`, and the per-primitive calls in `/engine-sandbox` (`callActivity`, `takeMailbox`, `takeUpdate`, `setStateCell`, `sleepUntil`, `continueAsNew`) — are **deprecated in 0.4.0 and removed in 0.5.0**. Each has a replacement here; the [`prefer-definition` lint rule](/guide/lint-rules) names it at every remaining import. +::: ::: info Schemas must be context-free A declaration's schemas cross the ops seam with their service requirements erased — a schema that needs decoding or encoding services would defect at runtime. Use plain, self-contained schemas at declaration boundaries. @@ -57,5 +72,5 @@ The explicit name string — `"charge"`, `"order/approval"` — is the identity Two axes, two tools, both in the definition module: -- **Logic changes** at a code site: `version(site, names)` — patch markers under Temporal, so in-flight histories replay the code they recorded. See [Versioning](/guide/versioning). +- **Logic changes** at a code site: `version(site, names)` (branch by name) or `versioned(site, cases)` (run table) — patch markers under Temporal, so in-flight histories replay the code they recorded. See [Versioning](/guide/versioning). - **Data changes** in a declared schema: `evolved(current, legacy, migrate)` — decode tries the newest shape first, migrates legacy wire forward through a pure function, and handlers only ever see the newest Type. See [Schema evolution](/guide/versioning#schema-evolution-evolved). diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 89b7966..fcfcfbb 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -9,12 +9,12 @@ pnpm add @springbird/effect-temporal # or npm / yarn / bun `effect`, `@temporalio/client`, and `@temporalio/workflow` are peer dependencies (modern package managers install them for you). You will also want `@temporalio/worker` to run a worker and `@temporalio/testing` for the test harness — both optional peers, used only where you use them. ::: warning Effect version -effect-temporal targets **Effect v4** and pins its `effect` peer **exactly** (currently `4.0.0-beta.101`): the engine implements interfaces from `effect/unstable/*`, whose API can move between releases. Match the pinned version; each release of this package states the one `effect` version it is built and tested against. +effect-temporal targets **Effect v4** and pins its `effect` peer **exactly** (currently `4.0.0-rc.112`): the engine implements interfaces from `effect/unstable/*`, whose API can move between releases. Match the pinned version; each release of this package states the one `effect` version it is built and tested against. ::: A Temporal deployment has three kinds of process, and this package has a module for each: -- the **workflow bundle** — deterministic code Temporal replays; uses `@springbird/effect-temporal/engine-sandbox` +- the **workflow bundle** — deterministic code Temporal replays; its entry file uses `@springbird/effect-temporal/bundle`, its handlers only `@springbird/effect-temporal/definition` - the **worker** — runs the bundle and your activities; registers via `@springbird/effect-temporal/activities` - **clients** — ordinary Node processes that start and observe workflows; use `@springbird/effect-temporal/client` @@ -50,8 +50,8 @@ The body is an Effect that runs inside the Temporal workflow sandbox. Workflows ```ts // workflows.ts — the workflow bundle (Temporal's workflowsPath points here) import { Effect } from "effect"; -import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { workflowBundle } from "@springbird/effect-temporal/bundle"; +import { sleep } from "@springbird/effect-temporal/definition"; import { OrderFlow, Reserve } from "./definitions.js"; const OrderFlowLive = OrderFlow.toLayer((payload) => @@ -60,7 +60,7 @@ const OrderFlowLive = OrderFlow.toLayer((payload) => // decoded, typed failure lands in the error channel. Retries are // Temporal's, per the declaration's options. const reservation = yield* Reserve({ sku: payload.sku, quantity: 1 }); - yield* DurableClock.sleep({ name: "cooling-off", duration: "1 minute" }); + yield* sleep({ name: "cooling-off", duration: "1 minute" }); return `reserved:${reservation}`; }), ); diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 0613b8c..3db6fd8 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -14,19 +14,20 @@ const OrderFlow = Workflow.make("orderFlow", { The package is two layers: 1. **An engine.** `effect/unstable/workflow` defines durable-workflow programs against an abstract `WorkflowEngine`; this package implements that engine over Temporal. Effect ships its own engine (`effect/unstable/cluster`, persisting to its own SQL tables) — this one is for codebases that already run Temporal and do not want a second durable-execution system. -2. **An extension layer.** Durable capabilities [declared once](/guide/declaring-capabilities) with the `definition` module and called directly in handlers: `defineActivity` (typed activities), `defineDeferred` (one-shot approvals), `defineMailbox` (repeated inbound signals), `defineUpdate` (request/response with typed channels), `defineState` (queryable published state), plus `continueAsNew` (unbounded workflows), `version`/`evolved` (logic and schema evolution), schedules, and Nexus operations backed by these workflows. +2. **An extension layer.** Durable capabilities [declared once](/guide/declaring-capabilities) with the `definition` module and called directly in handlers: `defineActivity` (typed activities), `defineDeferred` (one-shot approvals), `defineMailbox` (repeated inbound signals), `defineUpdate` (request/response with typed channels), `defineState` (queryable published state), `sleep`/`sleepUntil` (durable timers), `continueAsNew` (unbounded workflows), `executeChild` (child workflows), `version`/`versioned`/`evolved` (logic and schema evolution) — plus schedules and Nexus operations backed by these workflows. The library is one npm package, `@springbird/effect-temporal`, with tree-shakeable subpath modules: | Module | Runs in | What it is | | --- | --- | --- | -| `@springbird/effect-temporal/definition` | everywhere | `define*` capability declarations, `version`, `evolved`, the `WorkflowOps` seam — engine-free | -| `@springbird/effect-temporal/engine-sandbox` | the workflow bundle | `workflowBundle` (hosts registrations, provides `WorkflowOps`), raw activity calls, `continueAsNew` | +| `@springbird/effect-temporal/definition` | everywhere | `define*` capability declarations, timers, `continueAsNew`, `executeChild`, `version`/`versioned`, `evolved`, the `WorkflowOps` seam — engine-free | +| `@springbird/effect-temporal/bundle` | the workflow bundle's entry file | `workflowBundle` — hosts registrations behind the bundle's default export, provides the Temporal `WorkflowOps` | +| `@springbird/effect-temporal/engine-sandbox` | the workflow bundle | engine-level escape hatches: raw activity proxies (`callRawActivity`), workflow → workflow offers, Nexus calls | | `@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/activities` | worker registration | activity implementation tables (`handle`, `implementActivities`) + the attach bridge | -| `@springbird/effect-temporal/typed-activity`, `/mailbox`, `/update`, `/state-cell` | everywhere | the low-level primitive definitions `define*` builds on | -| `@springbird/effect-temporal/versioning` | the workflow bundle | low-level patch-marker version chains (`Versioning.match`) | +| `@springbird/effect-temporal/wire` | engine-level | the wire contract: codecs (`codecsFor`), signal/query names, failure types | +| `@springbird/effect-temporal/typed-activity`, `/versioning`, and the `make` constructors in `/mailbox`, `/update`, `/state-cell` | — | **deprecated** (removed in 0.5.0): re-exports and aliases of the `definition` surface; the `prefer-definition` lint rule reports them | | `@springbird/effect-temporal/nexus` | worker registration | workflow-backed Nexus operations | | `@springbird/effect-temporal/testing` | tests | the in-memory `WorkflowOps` runtime, a typed fake Temporal client, a live test harness | | `@springbird/effect-temporal/lint` | your lint config | oxlint/ESLint rules for the authoring footguns | @@ -35,7 +36,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`, capabilities are [declared once](/guide/declaring-capabilities) and called directly, and every in-handler operation requires one service — `WorkflowOps`. `workflowBundle` provides Temporal's implementation; the testing module provides an in-memory one; the identical handler runs on both. Choosing a backend is choosing a Layer. +- **Engine-agnostic authoring.** Workflows register with `Workflow.toLayer`, capabilities are [declared once](/guide/declaring-capabilities) and called directly, and every in-handler operation — activities, messages, state, timers, continue-as-new, children, versioning — requires one service — `WorkflowOps`. `workflowBundle` provides Temporal's implementation; the testing module provides an in-memory one; the identical handler runs on both. 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/lint-rules.md b/docs/guide/lint-rules.md index daef3ab..1a3e5c0 100644 --- a/docs/guide/lint-rules.md +++ b/docs/guide/lint-rules.md @@ -10,7 +10,8 @@ The whole Effect program runs inside the Temporal workflow sandbox. `Activity.ma 2. **`Effect.promise` callbacks must be zero-arity** — non-zero arity makes Effect allocate an `AbortController` per call, which the sandbox does not provide. 3. **No module-level mutable state in workflow code** — under the worker's default `reuseV8Context`, module-level variables are shared across every workflow instance on a thread. Keep run state inside the handler. 4. **Never mix the halves** — a module must not import both the sandbox half (`@temporalio/workflow`, `engine-sandbox`) and the client half (`@temporalio/client`, `engine-client`): they can never share a process. -5. **Evaluate versions on the main fiber** — [version](/guide/versioning) markers evaluated inside forks or races make marker order nondeterministic. +5. **Evaluate versions on the main fiber** — [version / versioned](/guide/versioning) markers evaluated inside forks or races make marker order nondeterministic. +6. **Author with the definition module** — the pre-0.4.0 surface (`typed-activity`, `versioning`, the primitive `make` constructors, the per-primitive `engine-sandbox` calls) is deprecated and removed in 0.5.0; every remaining import is a regression waiting to break. ## Setup @@ -33,16 +34,19 @@ Or configure the rules directly: "effect-temporal/no-module-level-mutable": "error", "effect-temporal/no-mixed-halves": "error", "effect-temporal/prefer-call-temporal-activity": "warn", - "effect-temporal/versioning-on-main-fiber": "error" + "effect-temporal/versioning-on-main-fiber": "error", + "effect-temporal/prefer-definition": "error" } } ``` -Two presets ship: `recommended` (all five rules, `prefer-call-temporal-activity` as a warning) and `correctness` (only the hard-error rules). +Two presets ship: `recommended` (all six rules, `prefer-call-temporal-activity` as a warning) and `correctness` (only the hard-error sandbox rules — no migration rule). ## Scope -A file counts as workflow code when it imports `@temporalio/workflow` or the `engine-sandbox` module — the rules are inert elsewhere, so enabling them repo-wide is safe. `no-mixed-halves` applies everywhere by nature. `versioning-on-main-fiber` has one more trigger: importing `version` from the [definition module](/guide/declaring-capabilities) marks the file for that rule (alias-aware), since definition-authored handler modules deliberately import nothing engine-shaped. The other sandbox rules cannot see such modules — a handler that needs them linted can live next to its bundle entry, which imports `engine-sandbox`. +A file counts as workflow code when it imports `@temporalio/workflow`, the `bundle` module, or the `engine-sandbox` module — the rules are inert elsewhere, so enabling them repo-wide is safe. `no-mixed-halves` applies everywhere by nature. `versioning-on-main-fiber` has one more trigger: importing `version` or `versioned` from the [definition module](/guide/declaring-capabilities) marks the file for that rule (alias-aware), since definition-authored handler modules deliberately import nothing engine-shaped. The other sandbox rules cannot see such modules — a handler that needs them linted can live next to its bundle entry, which imports `bundle`. + +`prefer-definition` applies everywhere: it keys off the import source alone (the package specifier or a relative path to one of this package's modules), and its message names the replacement — `defineActivity` for `TypedActivity.make`, `versioned` for `Versioning.match`, the declaration's `.take` for `takeMailbox`, `codecsFor` from `wire`, and so on. Because it is an error in `recommended`, `oxlint` exits non-zero on any file still importing a deprecated symbol — which is what stops a migrated codebase regressing. The remaining footguns — drain mailboxes before `continueAsNew`, respond to updates before completion — are runtime-shaped and covered by runtime guards and the guide instead. @@ -52,4 +56,5 @@ The remaining footguns — drain mailboxes before `continueAsNew`, respond to up | `no-module-level-mutable` | module-level `let`/`var` in workflow code | | `no-mixed-halves` | one module importing both process halves | | `prefer-call-temporal-activity` | raw `Effect.promise` where a cancellable call belongs | -| `versioning-on-main-fiber` | `Versioning.*` inside `fork` / `race` / `all` | +| `versioning-on-main-fiber` | `version` / `versioned` / `Versioning.*` inside `fork` / `race` / `all` | +| `prefer-definition` | any import of a deprecated symbol (removed in 0.5.0), with its replacement | diff --git a/docs/guide/mailboxes.md b/docs/guide/mailboxes.md index d68061a..2fe9f72 100644 --- a/docs/guide/mailboxes.md +++ b/docs/guide/mailboxes.md @@ -41,21 +41,23 @@ The claim happens synchronously on the taking fiber after the wait, so an interr ## Offering messages -Offering sides address the declaration's underlying primitive, `StateUpdates.mailbox`. From a **client** — via the `WorkflowClient` service: +Offering sides address the **declaration itself** — the same `StateUpdates` the handler takes from. From a **client** — via the `WorkflowClient` service: ```ts const wf = yield* WorkflowClient; -yield* wf.offerMailbox(StateUpdates.mailbox, workflowId, { op: "set", key: "a", value: 1 }); +yield* wf.offerMailbox(StateUpdates, workflowId, { op: "set", key: "a", value: 1 }); ``` -From **another workflow** (workflow → workflow): +From **another workflow** (workflow → workflow — an engine-level operation, Temporal-only): ```ts import { offerMailbox } from "@springbird/effect-temporal/engine-sandbox"; -yield* offerMailbox(Reports.mailbox, { workflowId: orchestratorId, payload: report }); +yield* offerMailbox(Reports, { workflowId: orchestratorId, payload: report }); ``` +Every offering surface also accepts the underlying primitive (`StateUpdates.mailbox`) — the declaration is just the one symbol you never need to unwrap. + Offers are **fire-and-forget**: offering to a closed or unknown execution is a no-op — the receiver finishing first is a normal race, matching `DurableDeferred.done`. On the workflow side, any other delivery failure is also swallowed (logged as a worker warning, never fatal to the offering run); a mailbox offer is not a delivery guarantee. When the sender must *know* the message was handled, use an [update](/guide/updates) instead. ## Ordering and durability diff --git a/docs/guide/queryable-state.md b/docs/guide/queryable-state.md index e585270..1118c31 100644 --- a/docs/guide/queryable-state.md +++ b/docs/guide/queryable-state.md @@ -21,16 +21,16 @@ Each publish replaces the previous snapshot. Publish after every state change yo ## Reading (client side) -Readers address the declaration's underlying primitive, `CurrentLanguage.cell`: +Readers address the declaration itself (the underlying primitive, `CurrentLanguage.cell`, is accepted too): ```ts const wf = yield* WorkflowClient; -const snapshot = yield* wf.readStateCell(CurrentLanguage.cell, workflowId); +const snapshot = yield* wf.readStateCell(CurrentLanguage, workflowId); // Option.none() while the execution is unknown or the cell unpublished; // Option.some(typed value) otherwise — including after the run closed. ``` -Without the service, the standalone form is `readStateCell(CurrentLanguage.cell, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. +Without the service, the standalone form is `readStateCell(CurrentLanguage, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. ## Why snapshots, not query functions diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 24bef94..e490089 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -15,11 +15,12 @@ A handler authored against [declared capabilities](/guide/declaring-capabilities ```ts import { Effect, Fiber } from "effect"; import { handle } from "@springbird/effect-temporal/activities"; -import { makeTestWorkflowOps } from "@springbird/effect-temporal/testing"; +import { handleWorkflow, makeTestWorkflowOps } from "@springbird/effect-temporal/testing"; import { Approval, Charge, orderHandler, Priority, SetAmount, Status } from "./definitions.js"; const world = yield* makeTestWorkflowOps({ activities: [handle(Charge, () => Effect.succeed("receipt"))], + workflows: [handleWorkflow(Fulfil, fulfilHandler)], // children executeChild can run }); // The SAME handler that workflowBundle hosts on Temporal: @@ -42,10 +43,38 @@ The world's surface: - **`offer(mailbox, payload)`** — delivers one mailbox message to `.take`/`.poll`. - **`request(update, payload)`** — sends an update request and awaits the typed response the handler's `respond` produces (typed failure in the error channel). - **`stateOf(cell)`** — reads the last value the handler `.set`, as an `Option`. +- **`continuedAsNew`** / **`continuedAsNewOf(workflow)`** — `Some` once the handler called `continueAsNew`, carrying the payload the next run would decode (typed against `workflow` in the second form). -Activity calls run their bound handlers with the payload round-tripped through the declaration's schema (as the wire would); typed failures land in the error channel, everything else is a defect. A call to an activity with no binding dies loudly. `version` always answers the newest name — there is no replay in memory. +Activity calls run their bound handlers with the payload round-tripped through the declaration's schema (as the wire would); typed failures land in the error channel, everything else is a defect. A call to an activity with no binding dies loudly. `version` / `versioned` always answer the newest name — there is no replay in memory. -No sandbox, no server, no Temporal: this is the test for handler *logic* — branching, message ordering, typed refusals. Replay, durable timers, and retries stay with the harness below. +### Timers under `TestClock` + +`sleep` and `sleepUntil` follow Effect's `Clock`, so provide `TestClock.layer()` and `adjust` past them. They are deliberately **not instant**: the grace-period pattern — a mailbox take racing a timer — is testable in *both* orders: + +```ts +import { TestClock } from "effect/testing"; + +const program = Effect.gen(function* () { + const world = yield* makeTestWorkflowOps(); + + // Order 1: the cancellation lands first. + const a = yield* Effect.forkChild(graceHandler(payload).pipe(Effect.provide(world.layer))); + yield* world.offer(CancelOrder, { reason: "changed-mind" }); + expect(yield* Fiber.join(a)).toBe("cancelled:changed-mind"); + + // Order 2: nothing arrives; the timer fires when the clock passes it. + const b = yield* Effect.forkChild(graceHandler(payload).pipe(Effect.provide(world.layer))); + yield* TestClock.adjust("1 hour"); + expect(yield* Fiber.join(b)).toBe("shipped"); +}); +await Effect.runPromise(Effect.provide(program, TestClock.layer())); +``` + +### Continue-as-new and children + +`continueAsNew` interrupts the handler fiber (an interrupted exit) and records the continuation — read it with `world.continuedAsNewOf(Workflow)`; the payload was round-tripped through the workflow's payload schema, so a schema-invalid payload dies here as it would on Temporal. `executeChild` runs the child's `handleWorkflow` binding in-process with the same world: payload, success, and typed failure cross the child's schemas, a discarded child forks and returns its execution id, and a second start on a taken id attaches — the idempotency contract, in memory. + +No sandbox, no server, no Temporal: this is the test for handler *logic* — branching, message ordering, typed refusals, timer races, child composition. Replay and real retries stay with the harness below. ## The fake client @@ -84,6 +113,13 @@ const fake = makeFakeTemporalClient({ The fake covers `workflow.start`, `getHandle(...).result/signal/terminate`, and `withDeadline`. Anything else a test touches fails with a named error telling you what to configure. +Mailbox offers are typed on both sides, so a service test never names a signal constant: + +```ts +await Effect.runPromise(fake.offer(Priority, workflowId, { level: 3 })); // through the fake, as a client would +expect(fake.offersTo(Priority)).toEqual([{ workflowId, payload: { level: 3 } }]); // decoded via the declaration +``` + ## The live harness `startWorkflowTestHarness` boots a Temporal test server and hands back a typed harness. Framework-agnostic on purpose — wire it into your runner's lifecycle yourself: @@ -108,6 +144,8 @@ it("reserves and completes", async () => { }); ``` +The harness client drives declarations directly, mirroring the in-memory world's names: `wf.offer(Priority, id, payload)`, `wf.request(SetAmount, id, payload)`, `wf.stateOf(Status, id)`, `wf.resolve(Approval, id, value)`. + - **Time skipping**: durable timers — a 3-day cooling-off, a 61-second not-before — resolve instantly while a result is being awaited. `harness.currentTimeMillis()` gives you the environment's current time for building absolute timestamps. - **`mode: "local"`** runs a full dev server instead — needed for Nexus and schedules, which the time-skipping server does not support. - `harness.env` exposes the underlying `TestWorkflowEnvironment` for surfaces the harness does not model. @@ -130,7 +168,7 @@ expect(fake.starts[0].args[0]).toEqual(encodeWorkflowPayload(OrderFlow, payload) | Test | Tool | | --- | --- | -| "the handler's logic is right — branches, messages, typed refusals" | `makeTestWorkflowOps` | +| "the handler's logic is right — branches, messages, typed refusals, timer races, children" | `makeTestWorkflowOps` | | "my service starts the right workflow with the right payload" | fake client | | "duplicate submits don't double-start" | fake client + `simulateAlreadyStarted` | | "the workflow's timer/retry/compensation logic is right" | live harness | diff --git a/docs/guide/timers-and-approvals.md b/docs/guide/timers-and-approvals.md index 12c9d77..7bd5e94 100644 --- a/docs/guide/timers-and-approvals.md +++ b/docs/guide/timers-and-approvals.md @@ -1,39 +1,45 @@ # Timers & approvals -Durable time and one-shot external completion come straight from the upstream `effect/unstable/workflow` API, backed here by real Temporal timers and signals. +Durable time and one-shot external completion, declared and used through the [definition module](/guide/declaring-capabilities) — backed here by real Temporal timers and signals, and by an Effect `Clock` in the [in-memory test runtime](/guide/testing#the-in-memory-runtime). ## Durable sleep -`DurableClock.sleep` is a Temporal timer: the workflow consumes no worker resources while it waits, and the wait survives worker restarts and deploys. +`sleep` is a Temporal timer: the workflow consumes no worker resources while it waits, and the wait survives worker restarts and deploys. ```ts -import * as DurableClock from "effect/unstable/workflow/DurableClock"; +import { sleep } from "@springbird/effect-temporal/definition"; -yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" }); +yield* sleep({ name: "cooling-off", duration: "3 days" }); ``` -Clock names must be unique per sleep within a run — suffix loop iterations: +Names must be unique per sleep within a run — suffix loop iterations: ```ts -yield* DurableClock.sleep({ name: `deadline-${iteration}`, duration: "1 hour" }); +yield* sleep({ name: `deadline-${iteration}`, duration: "1 hour" }); ``` +`sleep` requires only `WorkflowOps`, so a handler with a timer runs unchanged in the in-memory runtime — where the timer follows Effect's `Clock`, and a `TestClock` advances it. It is *not* instant there: a race between a mailbox take and a grace-period timer is testable in both orders. + ### Sleeping until an absolute time -`sleepUntil` sleeps to a timestamp, and is a no-op when the moment is already past. The target is read against the sandbox's deterministic clock, so the delay is stable on replay. +`sleepUntil` sleeps to a timestamp, and is a no-op when the moment is already past. The target is read against the engine's deterministic clock, so the delay is stable on replay. ```ts -import { sleepUntil } from "@springbird/effect-temporal/engine-sandbox"; +import { sleepUntil } from "@springbird/effect-temporal/definition"; yield* sleepUntil({ name: "not-before", timestamp: payload.notBeforeISO }); ``` -The timestamp is epoch milliseconds or a date-time string that **carries its zone** — `Z` or an explicit offset. A zone-less date-time string (or an unparseable timestamp) **dies loudly**: `Date.parse` would read it in the worker's local timezone, which is nondeterministic across workers and replays. +The timestamp is epoch milliseconds or a date-time string that **carries its zone** — `Z` or an explicit offset. A zone-less date-time string (or an unparseable timestamp) **dies loudly** on every engine: `Date.parse` would read it in the worker's local timezone, which is nondeterministic across workers and replays. The rule is one function, `sleepUntilTarget`, shared by the Temporal runtime and the in-memory one. ::: tip Effect.sleep also works — durably Inside the sandbox, `Effect.sleep`, `Effect.timeout*`, and `Schedule` delays land on the sandbox's `setTimeout`, which **is** a durable Temporal timer — deterministic on replay, but each one is a history event. For waits that matter, prefer the named forms above: the name shows up in your program and your reasoning. Be deliberate about retry schedules with many short delays. ::: +::: details Upstream `DurableClock.sleep` +`sleep` dispatches to upstream `DurableClock.sleep` on Temporal, so the two are interchangeable in a bundle — but `DurableClock.sleep` requires `WorkflowEngine | WorkflowInstance`, which only the Temporal runtime provides, so a handler using it cannot run in the in-memory runtime. Prefer `sleep`. +::: + ## Approvals: defineDeferred A deferred is a one-shot typed completion an outside party resolves — the "wait for a human" primitive. @@ -50,18 +56,15 @@ export const ManagerApproval = defineDeferred("manager-approval", { const approver = yield* ManagerApproval.await; ``` -Complete it from any client — `DurableDeferred.done` rides a signal, addressed to the declaration's underlying primitive, `ManagerApproval.deferred`: +Complete it from any client, addressed to the **declaration** and the workflow id — no token, no `WorkflowEngine` in context: ```ts -yield* DurableDeferred.done(ManagerApproval.deferred, { - token: DurableDeferred.tokenFromExecutionId(ManagerApproval.deferred, { - workflow: OrderFlow, - executionId, - }), - exit: Exit.succeed("uri"), -}); +const wf = yield* WorkflowClient; +yield* wf.completeDeferred(ManagerApproval, workflowId, Exit.succeed("uri")); ``` +Without the service, the standalone form is `completeDeferred(ManagerApproval, { client, workflowId, exit })` from `@springbird/effect-temporal/engine-client`. Both ride the same done-signal upstream `DurableDeferred.done` uses (which still works, addressed to `ManagerApproval.deferred` with a token) — the wire is identical. + Completing a deferred on a closed or unknown execution is a **no-op** — an approval landing after the workflow finished is a normal race, not an error. ### Reading without touching the signal path @@ -70,15 +73,25 @@ Completing a deferred on a closed or unknown execution is a **no-op** — an app ```ts const wf = yield* WorkflowClient; -const state = yield* wf.deferredState(ManagerApproval.deferred, workflowId); +const state = yield* wf.deferredState(ManagerApproval, workflowId); // Option.none() while pending or unknown; Option.some(typed exit) once completed ``` -Without the service, the standalone form is `deferredState(ManagerApproval.deferred, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. +Without the service, the standalone form is `deferredState(ManagerApproval, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. ## Composing time with messages -Because everything is an Effect, deadline patterns are ordinary races. A timer whose deadline is updatable by messages: +Because everything is an Effect, deadline patterns are ordinary races. A grace period a cancellation can cut short: + +```ts +const winner = yield* Effect.raceFirst( + CancelOrder.take.pipe(Effect.map((m) => ({ kind: "cancelled" as const, reason: m.reason }))), + sleep({ name: "grace", duration: "1 hour" }).pipe(Effect.map(() => ({ kind: "elapsed" as const }))), +); +return winner.kind === "cancelled" ? `cancelled:${winner.reason}` : "shipped"; +``` + +And a timer whose deadline is updatable by messages: ```ts let deadlineMillis = payload.initialMillis; @@ -86,10 +99,9 @@ let updates = 0; while (true) { const winner = yield* Effect.raceFirst( DeadlineUpdates.take.pipe(Effect.map((u) => ({ kind: "update" as const, u }))), - DurableClock.sleep({ - name: `deadline-${updates}`, - duration: `${deadlineMillis} millis`, - }).pipe(Effect.map(() => ({ kind: "fired" as const }))), + sleep({ name: `deadline-${updates}`, duration: `${deadlineMillis} millis` }).pipe( + Effect.map(() => ({ kind: "fired" as const })), + ), ); if (winner.kind === "fired") return `fired-after-updates:${updates}`; deadlineMillis = winner.u.millis; @@ -97,4 +109,4 @@ while (true) { } ``` -See [Mailboxes](/guide/mailboxes) for the message half. +See [Mailboxes](/guide/mailboxes) for the message half, and [Testing](/guide/testing#the-in-memory-runtime) for driving both branches under a `TestClock`. diff --git a/docs/guide/updates.md b/docs/guide/updates.md index 3f51488..6cd66b6 100644 --- a/docs/guide/updates.md +++ b/docs/guide/updates.md @@ -40,11 +40,11 @@ Rules of the road: ## Calling (client side) -Callers address the declaration's underlying primitive, `SetLanguage.update`: +Callers address the declaration itself (the underlying primitive, `SetLanguage.update`, is accepted too): ```ts const wf = yield* WorkflowClient; -const previous = yield* wf.executeUpdate(SetLanguage.update, workflowId, { language: "french" }); +const previous = yield* wf.executeUpdate(SetLanguage, workflowId, { language: "french" }); // success channel: string (previous language) // error channel: string (typed failure from respond) ``` @@ -54,7 +54,7 @@ Without the service — the standalone form `WorkflowClient` delegates to: ```ts import { executeUpdate } from "@springbird/effect-temporal/engine-client"; -const previous = yield* executeUpdate(SetLanguage.update, { +const previous = yield* executeUpdate(SetLanguage, { client, workflowId, payload: { language: "french" }, diff --git a/docs/guide/versioning.md b/docs/guide/versioning.md index 19a5a7e..4b28746 100644 --- a/docs/guide/versioning.md +++ b/docs/guide/versioning.md @@ -25,25 +25,29 @@ The result is typed as the literal union of exactly the names given, so a `switc Evolving a site is appending a name. Adopting `version` on an existing workflow is safe. -### The lifecycle of a name - -1. **Append** `"v3"`. Deploy. Fresh runs take v3; in-flight runs keep replaying their recorded name. -2. **Retire** an old name only after every history carrying its marker has closed: remove it from the list and deploy `deprecateVersion(site, name)` (from `@springbird/effect-temporal/versioning`) in its place for one release. Replaying a *removed* version's history fails loudly rather than silently running the wrong code. +### The run-table form: `versioned` -### The low-level module: `Versioning.match` - -For a multi-way marker match with per-case effects in one expression, the `versioning` module remains the low-level surface: +Branching by hand on the returned name is error-prone at exactly the site where determinism matters. `versioned` takes the behaviors themselves — one effect per name, keyed **oldest first**: ```ts -import * as Versioning from "@springbird/effect-temporal/versioning"; +import { versioned } from "@springbird/effect-temporal/definition"; -const result = yield* Versioning.match("pricing", [ - { version: "v1", run: originalPricing }, - { version: "v2", run: revisedPricing }, -]); +const result = yield* versioned("pricing", { + v1: originalPricing, + v2: revisedPricing, +}); ``` -Result, error, and service channels are unioned across cases; marker semantics are identical to `version`. The raw primitives are exported too: `patched(id)` is the boolean guard, `deprecatePatch(id)` is Temporal's phase-two marker. Note that the `versioning` module talks to the sandbox directly, so it is Temporal-only — `version` from the definition module is the engine-agnostic form. +The **key order is the chain order**: the first key is the original, unguarded behavior; each later key is guarded by its own marker (`pricing-v2`, …). The selected case runs, and result, error, and service channels are unioned across cases. Same markers, same lifecycle, same replay semantics as `version` — it is `version` plus the lookup. The [lint rule](/guide/lint-rules) `versioning-on-main-fiber` covers both calls. + +### The lifecycle of a name + +1. **Append** `"v3"`. Deploy. Fresh runs take v3; in-flight runs keep replaying their recorded name. +2. **Retire** an old name only after every history carrying its marker has closed: remove it from the list and deploy `deprecateVersion(site, name)` in its place for one release. Replaying a *removed* version's history fails loudly rather than silently running the wrong code. (`deprecateVersion` lives in the `versioning` module today — the one non-deprecated reason to import it; it moves to `bundle` in 0.5.0.) + +::: warning `Versioning.match` is deprecated +The pre-0.4.0 `versioning` module's `match(site, [{ version, run }])` is the Temporal-only ancestor of `versioned` — same markers, same semantics, but it imports `@temporalio/workflow` and so cannot run in the in-memory runtime. It is deprecated and removed in 0.5.0; `versioned(site, { v1: run1, v2: run2 })` is the drop-in replacement, and the `prefer-definition` lint rule points at it. +::: ### Rules diff --git a/docs/index.md b/docs/index.md index f3a6f0e..cf49524 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,11 +30,10 @@ features: ## The whole idea, in one file ```ts -import { Effect, Schema } from "effect"; +import { Effect, Exit, Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition"; -import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { defineActivity, defineDeferred, sleep } from "@springbird/effect-temporal/definition"; +import { workflowBundle } from "@springbird/effect-temporal/bundle"; import { WorkflowClient } from "@springbird/effect-temporal/client"; // 1. Declare once: shared by the workflow bundle, the worker, and every client. @@ -57,15 +56,17 @@ const ManagerApproval = defineDeferred("manager-approval", { const OrderFlowLive = OrderFlow.toLayer((payload) => Effect.gen(function* () { const paid = yield* Charge({ orderId: payload.orderId }); - yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" }); + yield* sleep({ name: "cooling-off", duration: "3 days" }); const approver = yield* ManagerApproval.await; return `${paid}:approved-by:${approver}`; }), ); export default workflowBundle(OrderFlowLive); -// 3. Drive it from ordinary Node — typed success, typed failure, idempotent. +// 3. Drive it from ordinary Node — typed success, typed failure, idempotent; +// client-side ops take the declaration itself. const wf = yield* WorkflowClient; +yield* wf.completeDeferred(ManagerApproval, workflowId, Exit.succeed("ben")); const result = yield* wf.execute(OrderFlow, { orderId: "ord_123" }); ``` diff --git a/docs/reference/how-it-works.md b/docs/reference/how-it-works.md index 716831f..088ccfa 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. `workflowBundle` builds the bundle's one dynamic workflow function from `Workflow.toLayer` registrations: per run it decodes the payload, provides the engine and the Temporal `WorkflowOps` (the seam [declared capabilities](/guide/declaring-capabilities) dispatch through), 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` (exported to applications from the `bundle` module) builds the bundle's one dynamic workflow function from `Workflow.toLayer` registrations: per run it decodes the payload, provides the engine and the Temporal `WorkflowOps` (the seam [declared capabilities](/guide/declaring-capabilities) dispatch through), 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/examples/order-saga/package.json b/examples/order-saga/package.json index 0534b6c..089a306 100644 --- a/examples/order-saga/package.json +++ b/examples/order-saga/package.json @@ -13,7 +13,7 @@ "@temporalio/testing": "1.19.0", "@temporalio/worker": "1.19.0", "@temporalio/workflow": "1.19.0", - "effect": "4.0.0-beta.101", + "effect": "4.0.0-rc.112", "@springbird/effect-temporal": "workspace:*" }, "devDependencies": { diff --git a/examples/order-saga/src/main.ts b/examples/order-saga/src/main.ts index 00f26ba..3bcc513 100644 --- a/examples/order-saga/src/main.ts +++ b/examples/order-saga/src/main.ts @@ -15,7 +15,6 @@ // `new Client(...)` and a `Worker.create` against that connection. import { Effect, Exit, Layer, Option, Result } from "effect"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import type * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"; import type { Client } from "@temporalio/client"; import { TestWorkflowEnvironment } from "@temporalio/testing"; @@ -88,7 +87,7 @@ const worker = await Worker.create({ }); // Both layers: WorkflowClient for app-style calls, and the raw engine so -// Effect's own APIs (DurableDeferred.done, OrderSaga.poll) work too. +// Effect's own APIs (OrderSaga.poll, OrderSaga.executionId) work too. const layers = Layer.mergeAll( layerWorkflowClient({ client, taskQueue: "order-saga-demo" }), layerTemporalClientEngine({ client, taskQueue: "order-saga-demo" }), @@ -111,17 +110,12 @@ await worker.runUntil(async () => { // Observe progress mid-flight through the state cell (a query — it // never perturbs the run). yield* Effect.sleep("1 second"); - const status = yield* wf.readStateCell(OrderStatus.cell, workflowId); + const status = yield* wf.readStateCell(OrderStatus, workflowId); console.log(" status mid-flight:", Option.getOrElse(status, () => ({ phase: "?" }))); - // The manager approves — a signal from entirely outside the workflow. - yield* DurableDeferred.done(ManagerApproval.deferred, { - token: DurableDeferred.tokenFromExecutionId(ManagerApproval.deferred, { - workflow: OrderSaga, - executionId: workflowId, - }), - exit: Exit.succeed("ben"), - }); + // The manager approves — a signal from entirely outside the workflow, + // addressed to the declaration itself. + yield* wf.completeDeferred(ManagerApproval, workflowId, Exit.succeed("ben")); // execute on the same payload ATTACHES to the running execution and // returns its result — and a repeat execute returns the same result diff --git a/examples/order-saga/src/workflows.ts b/examples/order-saga/src/workflows.ts index 7b595bd..8b24755 100644 --- a/examples/order-saga/src/workflows.ts +++ b/examples/order-saga/src/workflows.ts @@ -2,12 +2,14 @@ // is an Effect running deterministically inside the workflow sandbox; every // side effect goes through a typed activity. The workflow registers itself // with `Workflow.toLayer`, hosted by the bundle's default export — which -// provides the `WorkflowOps` runtime the declarations require. +// provides the `WorkflowOps` runtime the declarations require. Note the +// imports: the handler needs only `definition`; the bundle entry needs only +// `bundle`. import { Effect } from "effect"; -import * as DurableClock from "effect/unstable/workflow/DurableClock"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { workflowBundle } from "@springbird/effect-temporal/bundle"; +import { sleep } from "@springbird/effect-temporal/definition"; import { Charge, ManagerApproval, OrderSaga, OrderStatus, Release, Reserve } from "./definitions.js"; const OrderSagaLive = OrderSaga.toLayer((payload) => @@ -33,7 +35,7 @@ const OrderSagaLive = OrderSaga.toLayer((payload) => // A durable timer: survives worker restarts; costs no worker resources. yield* OrderStatus.set({ phase: "cooling-off" }); - yield* DurableClock.sleep({ name: "cooling-off", duration: "2 seconds" }); + yield* sleep({ name: "cooling-off", duration: "2 seconds" }); // Block durably until a human approves (a signal from outside). yield* OrderStatus.set({ phase: "awaiting-approval" }); diff --git a/examples/subscription/package.json b/examples/subscription/package.json index cc12fa3..0c259ec 100644 --- a/examples/subscription/package.json +++ b/examples/subscription/package.json @@ -13,7 +13,7 @@ "@temporalio/testing": "1.19.0", "@temporalio/worker": "1.19.0", "@temporalio/workflow": "1.19.0", - "effect": "4.0.0-beta.101", + "effect": "4.0.0-rc.112", "@springbird/effect-temporal": "workspace:*" }, "devDependencies": { diff --git a/examples/subscription/src/main.ts b/examples/subscription/src/main.ts index 4d7f805..2c7eaf1 100644 --- a/examples/subscription/src/main.ts +++ b/examples/subscription/src/main.ts @@ -89,14 +89,14 @@ await worker.runUntil(() => )).runId; yield* Effect.sleep("1500 millis"); - const early = yield* wf.readStateCell(SubscriptionStatus.cell, workflowId); + const early = yield* wf.readStateCell(SubscriptionStatus, workflowId); console.log(" status:", Option.getOrNull(early)); console.log("\n2. change the plan through a typed update"); - const previous = yield* wf.executeUpdate(SetPlan.update, workflowId, { planCents: 1999 }); + const previous = yield* wf.executeUpdate(SetPlan, workflowId, { planCents: 1999 }); console.log(" previous plan:", previous, "¢"); const rejected = yield* Effect.result( - wf.executeUpdate(SetPlan.update, workflowId, { planCents: 50 }), + wf.executeUpdate(SetPlan, workflowId, { planCents: 50 }), ); if (Result.isFailure(rejected)) console.log(" typed rejection:", rejected.failure); @@ -109,14 +109,14 @@ await worker.runUntil(() => )).runId; } console.log(" continued-as-new:", runIdNow !== runIdAtStart, "(same workflow id, fresh history)"); - const carried = yield* wf.readStateCell(SubscriptionStatus.cell, workflowId); + const carried = yield* wf.readStateCell(SubscriptionStatus, workflowId); console.log(" carried state republished:", Option.getOrNull(carried)); console.log("\n4. cancel through the mailbox; the final status outlives the run"); - yield* wf.offerMailbox(CancelRequests.mailbox, workflowId, { reason: "user-requested" }); + yield* wf.offerMailbox(CancelRequests, workflowId, { reason: "user-requested" }); const summary = yield* wf.execute(Subscription, payload); // attaches to the chain console.log(" result:", summary); - const final = yield* wf.readStateCell(SubscriptionStatus.cell, workflowId); + const final = yield* wf.readStateCell(SubscriptionStatus, workflowId); console.log(" status after close:", Option.getOrNull(final)); }), ), diff --git a/examples/subscription/src/workflows.ts b/examples/subscription/src/workflows.ts index 24bd4ea..49092e6 100644 --- a/examples/subscription/src/workflows.ts +++ b/examples/subscription/src/workflows.ts @@ -11,8 +11,8 @@ import { Effect } from "effect"; import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; -import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { continueAsNew, workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { workflowBundle } from "@springbird/effect-temporal/bundle"; +import { continueAsNew, sleep } from "@springbird/effect-temporal/definition"; import { CancelRequests, ChargeCard, @@ -39,10 +39,9 @@ const SubscriptionLive = Subscription.toLayer((payload) => while (true) { const winner = yield* Effect.raceAll([ // The next billing cycle — a durable timer (name unique per sleep). - DurableClock.sleep({ - name: `cycle-${cyclesThisRun}`, - duration: "1 second", - }).pipe(Effect.map(() => ({ kind: "bill" as const }))), + sleep({ name: `cycle-${cyclesThisRun}`, duration: "1 second" }).pipe( + Effect.map(() => ({ kind: "bill" as const })), + ), // A plan change — answered with the PREVIOUS plan, typed both ways. SetPlan.take.pipe(Effect.map((request) => ({ kind: "plan" as const, request }))), // A cancellation — fire-and-forget from anywhere. diff --git a/oxlint-presets/recommended.json b/oxlint-presets/recommended.json index 6b13f8c..cc642bc 100644 --- a/oxlint-presets/recommended.json +++ b/oxlint-presets/recommended.json @@ -5,6 +5,7 @@ "effect-temporal/no-module-level-mutable": "error", "effect-temporal/no-mixed-halves": "error", "effect-temporal/prefer-call-temporal-activity": "warn", - "effect-temporal/versioning-on-main-fiber": "error" + "effect-temporal/versioning-on-main-fiber": "error", + "effect-temporal/prefer-definition": "error" } } diff --git a/package.json b/package.json index a001372..2ff1db2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@springbird/effect-temporal", - "version": "0.3.0", + "version": "0.4.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", @@ -22,6 +22,10 @@ "types": "./dist/activities.d.ts", "default": "./dist/activities.js" }, + "./bundle": { + "types": "./dist/bundle.d.ts", + "default": "./dist/bundle.js" + }, "./client": { "types": "./dist/client.d.ts", "default": "./dist/client.js" @@ -93,7 +97,7 @@ "@temporalio/testing": "~1.19.0", "@temporalio/worker": "~1.19.0", "@temporalio/workflow": "~1.19.0", - "effect": "4.0.0-beta.101" + "effect": "4.0.0-rc.112" }, "peerDependenciesMeta": { "@temporalio/nexus": { @@ -111,12 +115,14 @@ }, "devDependencies": { "@temporalio/client": "1.19.0", + "@temporalio/common": "1.19.0", "@temporalio/nexus": "1.19.0", + "@temporalio/proto": "1.19.0", "@temporalio/testing": "1.19.0", "@temporalio/worker": "1.19.0", "@temporalio/workflow": "1.19.0", "@types/node": "^22.19.9", - "effect": "4.0.0-beta.101", + "effect": "4.0.0-rc.112", "nexus-rpc": "^0.0.2", "oxlint": "1.77.0", "typescript": "^5.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11c74b7..87827c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,15 @@ importers: '@temporalio/client': specifier: 1.19.0 version: 1.19.0 + '@temporalio/common': + specifier: 1.19.0 + version: 1.19.0 '@temporalio/nexus': specifier: 1.19.0 version: 1.19.0 + '@temporalio/proto': + specifier: 1.19.0 + version: 1.19.0 '@temporalio/testing': specifier: 1.19.0 version: 1.19.0(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.26) @@ -31,8 +37,8 @@ importers: specifier: ^22.19.9 version: 22.20.1 effect: - specifier: 4.0.0-beta.101 - version: 4.0.0-beta.101 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 nexus-rpc: specifier: ^0.0.2 version: 0.0.2 @@ -67,8 +73,8 @@ importers: specifier: 1.19.0 version: 1.19.0 effect: - specifier: 4.0.0-beta.101 - version: 4.0.0-beta.101 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 devDependencies: '@types/node': specifier: ^22.19.9 @@ -98,8 +104,8 @@ importers: specifier: 1.19.0 version: 1.19.0 effect: - specifier: 4.0.0-beta.101 - version: 4.0.0-beta.101 + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 devDependencies: '@types/node': specifier: ^22.19.9 @@ -1593,8 +1599,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - effect@4.0.0-beta.101: - resolution: {integrity: sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==} + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} electron-to-chromium@1.5.413: resolution: {integrity: sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==} @@ -1683,9 +1689,6 @@ packages: picomatch: optional: true - find-my-way-ts@0.1.6: - resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} - focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} @@ -1738,10 +1741,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - ini@7.0.0: - resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1753,9 +1752,6 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - kubernetes-types@1.30.0: - resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} - lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -1930,9 +1926,6 @@ packages: msgpackr@2.0.5: resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} - multipasta@0.2.8: - resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2157,10 +2150,6 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - toml@4.3.0: - resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} - engines: {node: '>=20'} - tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -2214,10 +2203,6 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - uuid@14.0.2: - resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} - hasBin: true - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3674,18 +3659,10 @@ snapshots: dependencies: dequal: 2.0.3 - effect@4.0.0-beta.101: + effect@4.0.0-rc.112: dependencies: - '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 - find-my-way-ts: 0.1.6 - ini: 7.0.0 - kubernetes-types: 1.30.0 msgpackr: 2.0.5 - multipasta: 0.2.8 - toml: 4.3.0 - uuid: 14.0.2 - yaml: 2.9.0 electron-to-chromium@1.5.413: {} @@ -3796,8 +3773,6 @@ snapshots: optionalDependencies: picomatch: 4.0.7 - find-my-way-ts@0.1.6: {} - focus-trap@7.8.0: dependencies: tabbable: 6.5.0 @@ -3847,8 +3822,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ini@7.0.0: {} - is-fullwidth-code-point@3.0.0: {} jest-worker@27.5.1: @@ -3859,8 +3832,6 @@ snapshots: json-schema-traverse@1.0.0: {} - kubernetes-types@1.30.0: {} - lightningcss-android-arm64@1.33.0: optional: true @@ -4005,8 +3976,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.4 - multipasta@0.2.8: {} - nanoid@3.3.18: {} neo-async@2.6.2: {} @@ -4280,8 +4249,6 @@ snapshots: tinyrainbow@3.1.1: {} - toml@4.3.0: {} - tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -4335,8 +4302,6 @@ snapshots: uuid@11.1.1: {} - uuid@14.0.2: {} - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4515,7 +4480,8 @@ snapshots: y18n@5.0.8: {} - yaml@2.9.0: {} + yaml@2.9.0: + optional: true yargs-parser@21.1.1: {} diff --git a/src/__tests__/definition.test.ts b/src/__tests__/definition.test.ts index 09e1a2c..786b4f2 100644 --- a/src/__tests__/definition.test.ts +++ b/src/__tests__/definition.test.ts @@ -7,23 +7,46 @@ // and (b) on real Temporal via `workflowBundle`. import { fileURLToPath } from "node:url"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import { TestClock } from "effect/testing"; import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import { describe, expect, expectTypeOf, it } from "vitest"; import { handle, implementActivities, type ActivityRunner } from "../activities.js"; -import { version, type UpdateRequest, type WorkflowOps } from "../definition.js"; -import { executeUpdate, makeTemporalClientEngine, offerMailbox, readStateCell } from "../engine-client.js"; -import { makeTestWorkflowOps } from "../testing.js"; +import { + continueAsNew, + executeChild, + sleepUntil, + version, + versioned, + type PayloadOf, + type UpdateRequest, + type WorkflowOps, +} from "../definition.js"; +import { + completeDeferred, + executeUpdate, + makeTemporalClientEngine, + offerMailbox, + readStateCell, +} from "../engine-client.js"; +import { handleWorkflow, makeTestWorkflowOps } from "../testing.js"; import { Approval, + CancelOrder, CardDeclined, Charge, chargeImpl, + Dispatch, + dispatchHandler, + Fulfil, + fulfilHandler, + GraceFlow, + graceHandler, OrderFlow, orderHandler, Priority, @@ -31,6 +54,8 @@ import { reserveImpl, SetAmount, Status, + Tally, + tallyHandler, } from "./fixtures/definition-demo.js"; import { createWorkflowTestEnv } from "./utils/workflow-test-env.js"; @@ -38,6 +63,11 @@ const temporal = createWorkflowTestEnv("definition"); const bindings = [handle(Reserve, reserveImpl), handle(Charge, chargeImpl)] as const; +/** Let forked handler fibers process what was just delivered. */ +const settle = Effect.gen(function* () { + for (let i = 0; i < 10; i++) yield* Effect.yieldNow; +}); + // ── 1. The type pins ───────────────────────────────────────────────────────── const _types = () => { @@ -63,6 +93,34 @@ const _types = () => { const pricing = version("site", ["flat", "tiered"]); expectTypeOf>().toEqualTypeOf<"flat" | "tiered">(); + // versioned unions the cases' channels and needs WorkflowOps. + const mode = versioned("site", { a: Effect.succeed(1), b: Effect.fail("x") }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + + // Type helpers live on the definition module now. + expectTypeOf>().toEqualTypeOf<{ + readonly orderId: string; + readonly amountCents: number; + }>(); + + // Children: awaited → the child's channels; discarded → the execution id. + const awaited = executeChild(Fulfil, { orderId: "x" }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const discarded = executeChild(Fulfil, { orderId: "x" }, { discard: true }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error wrong child payload + executeChild(Fulfil, { orderId: 1 }); + + // continueAsNew never returns, and checks the payload against the schema. + const next = continueAsNew(Tally, { batchId: "b", count: 1 }); + expectTypeOf>().toEqualTypeOf(); + // @ts-expect-error wrong payload shape + continueAsNew(Tally, { batchId: "b" }); + // Worker binding is payload/success/error-checked from the declaration. handle(Charge, chargeImpl); // @ts-expect-error wrong success type @@ -83,9 +141,6 @@ describe("definition: one declaration, types flow, engine-agnostic", { concurren // Drive the entity exactly as a client would. `settle` lets the // handler fiber process each message before we assert on state. - const settle = Effect.gen(function* () { - for (let i = 0; i < 10; i++) yield* Effect.yieldNow; - }); const previous = yield* world.request(SetAmount, { amountCents: 2500 }); expect(previous).toBe(1000); yield* world.offer(Priority, { level: 2 }); @@ -123,15 +178,11 @@ describe("definition: one declaration, types flow, engine-agnostic", { concurren const engine = makeTemporalClientEngine({ client, taskQueue }); const run = (effect: Effect.Effect): Promise => Effect.runPromise(Effect.provideService(effect, WorkflowEngine.WorkflowEngine, engine)); - const approve = (executionId: string, approver: string) => - run( - DurableDeferred.done(Approval.deferred, { - token: DurableDeferred.tokenFromExecutionId(Approval.deferred, { - workflow: OrderFlow, - executionId, - }), - exit: Exit.succeed(approver), - }), + // Client-side ops address the DECLARATION — no `.deferred`, no token, + // no `WorkflowEngine` for the approval. + const approve = (workflowId: string, approver: string) => + Effect.runPromise( + completeDeferred(Approval, { client, workflowId, exit: Exit.succeed(approver) }), ); const payload = { orderId: "t-1" }; @@ -139,19 +190,17 @@ describe("definition: one declaration, types flow, engine-agnostic", { concurren // Same drive sequence as the memory test, through the real client ops. const previous = await Effect.runPromise( - executeUpdate(SetAmount.update, { client, workflowId, payload: { amountCents: 2500 } }), + executeUpdate(SetAmount, { client, workflowId, payload: { amountCents: 2500 } }), ); expect(previous).toBe(1000); - await Effect.runPromise( - offerMailbox(Priority.mailbox, { client, workflowId, payload: { level: 2 } }), - ); - const mid = await Effect.runPromise(readStateCell(Status.cell, { client, workflowId })); + await Effect.runPromise(offerMailbox(Priority, { client, workflowId, payload: { level: 2 } })); + const mid = await Effect.runPromise(readStateCell(Status, { client, workflowId })); expect(Option.getOrNull(mid)).toEqual({ phase: "awaiting-approval" }); await approve(workflowId, "temporal-ben"); const result = await run(OrderFlow.execute(payload)); expect(result).toBe("res-t-1|receipt-t-1-2500|p2|tiered|by:temporal-ben"); - const final = await Effect.runPromise(readStateCell(Status.cell, { client, workflowId })); + const final = await Effect.runPromise(readStateCell(Status, { client, workflowId })); expect(Option.getOrNull(final)).toEqual({ phase: "complete" }); // The typed activity failure flows into the workflow error channel. @@ -176,3 +225,171 @@ describe("definition: one declaration, types flow, engine-agnostic", { concurren }); }, 120_000); }); + +// ── 3. 0.4.0: timers, continue-as-new, children, versioned — two engines ───── + +const workflowsPath = fileURLToPath(new URL("./fixtures/definition-workflows.ts", import.meta.url)); +const plainRunner: ActivityRunner = { + run: (_name, _payload, effect) => Effect.runPromiseExit(effect), +}; + +describe("definition 0.4.0: the seam is complete", { concurrent: false }, () => { + it("in memory, a mailbox take racing a sleep resolves in EITHER order under TestClock", async () => { + const program = Effect.gen(function* () { + const world = yield* makeTestWorkflowOps(); + + // Order 1: the cancellation lands before the grace period elapses. + const cancelled = yield* Effect.forkChild( + graceHandler({ orderId: "g-1" }).pipe(Effect.provide(world.layer)), + ); + yield* settle; + yield* world.offer(CancelOrder, { reason: "changed-mind" }); + expect(yield* Fiber.join(cancelled)).toBe("cancelled:changed-mind"); + + // Order 2: nothing arrives; the timer is NOT instant — it fires only + // when the clock is advanced past it. + const elapsed = yield* Effect.forkChild( + graceHandler({ orderId: "g-2" }).pipe(Effect.provide(world.layer)), + ); + yield* settle; + yield* TestClock.adjust("59 minutes"); + yield* settle; + expect(elapsed.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("2 minutes"); + expect(yield* Fiber.join(elapsed)).toBe("shipped"); + }); + await Effect.runPromise(Effect.provide(program, TestClock.layer())); + }, 20_000); + + it("in memory, sleepUntil follows the clock and rejects zone-less timestamps", async () => { + const program = Effect.gen(function* () { + const world = yield* makeTestWorkflowOps(); + yield* TestClock.setTime(Date.parse("2026-01-01T00:00:00Z")); + const fiber = yield* Effect.forkChild( + sleepUntil({ name: "not-before", timestamp: "2026-01-01T01:00:00Z" }).pipe( + Effect.as("woke"), + Effect.provide(world.layer), + ), + ); + yield* settle; + yield* TestClock.adjust("30 minutes"); + yield* settle; + expect(fiber.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("30 minutes"); + expect(yield* Fiber.join(fiber)).toBe("woke"); + + // Already past: no-op. + expect( + yield* sleepUntil({ name: "past", timestamp: "2025-12-31T00:00:00Z" }).pipe( + Effect.as("immediate"), + Effect.provide(world.layer), + ), + ).toBe("immediate"); + + // The same rule as the Temporal runtime: zone-less date-times defect. + const zoneless = yield* Effect.exit( + sleepUntil({ name: "z", timestamp: "2026-01-01T02:00:00" }).pipe(Effect.provide(world.layer)), + ); + expect(Exit.isFailure(zoneless) && Cause.hasDies(zoneless.cause)).toBe(true); + expect(Exit.isFailure(zoneless) && String(Cause.squash(zoneless.cause))).toMatch(/no timezone/); + }); + await Effect.runPromise(Effect.provide(program, TestClock.layer())); + }, 20_000); + + it("in memory, continueAsNew interrupts the run and records the schema-checked payload", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const world = yield* makeTestWorkflowOps(); + const exit = yield* Effect.exit( + tallyHandler({ batchId: "b-1", count: 0 }).pipe(Effect.provide(world.layer)), + ); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + const next = yield* world.continuedAsNewOf(Tally); + expect(Option.getOrNull(next)).toEqual({ batchId: "b-1", count: 1 }); + expect(Option.isNone(yield* world.continuedAsNewOf(Fulfil))).toBe(true); + + // The payload round-trips through the workflow's payload schema: + // `count: Schema.Finite` rejects NaN, in memory as on the wire. + const invalid = yield* Effect.exit( + continueAsNew(Tally, { batchId: "b-2", count: Number.NaN }).pipe( + Effect.provide(world.layer), + ), + ); + expect(Exit.isFailure(invalid) && Cause.hasDies(invalid.cause)).toBe(true); + }), + ); + }, 20_000); + + it("in memory, executeChild runs the bound child handler: typed success, typed failure, discard, attach", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const world = yield* makeTestWorkflowOps({ + activities: bindings, + workflows: [handleWorkflow(Fulfil, fulfilHandler)], + }); + const run = (effect: Effect.Effect) => + Effect.provide(effect, world.layer); + + // Awaited: the child's success composes; `versioned` answers the newest key. + expect(yield* run(dispatchHandler({ orderId: "d-1", discard: false }))).toBe( + "routed|fulfilled:res-d-1", + ); + // Awaited: the child's TYPED failure lands in the parent's error channel. + const failed = yield* Effect.result(run(dispatchHandler({ orderId: "d-bad", discard: false }))); + expect(Result.isFailure(failed) && failed.failure).toBe("unfulfillable:d-bad"); + // Discarded: the digest execution id comes back; the child runs on. + const childId = yield* Fulfil.executionId({ orderId: "d-2" }); + expect(yield* run(dispatchHandler({ orderId: "d-2", discard: true }))).toBe( + `routed|started:${childId}`, + ); + // A taken id attaches to the running child instead of starting again. + expect(yield* run(executeChild(Fulfil, { orderId: "d-2" }))).toBe("fulfilled:res-d-2"); + // An unbound child dies loudly. + const unbound = yield* Effect.exit(run(executeChild(Tally, { batchId: "x", count: 0 }))); + expect(Exit.isFailure(unbound) && String(Cause.squash(unbound.cause))).toMatch( + /no binding for workflow "defTally"/, + ); + }), + ); + }, 20_000); + + it("runs the SAME 0.4.0 handlers on real Temporal", async () => { + const activities = implementActivities(plainRunner, bindings); + await temporal.withWorker({ activities, workflowsPath }, async (taskQueue) => { + const client = temporal.env.client; + const engine = makeTemporalClientEngine({ client, taskQueue }); + const run = (effect: Effect.Effect): Promise => + Effect.runPromise(Effect.provideService(effect, WorkflowEngine.WorkflowEngine, engine)); + + // sleep vs mailbox, order 1: the cancellation wins (offered before the + // result is awaited, so before time skipping advances the 1h timer). + const cancelledId = await run(GraceFlow.execute({ orderId: "tg-1" }, { discard: true })); + await Effect.runPromise( + offerMailbox(CancelOrder, { client, workflowId: cancelledId, payload: { reason: "oops" } }), + ); + expect(await run(GraceFlow.execute({ orderId: "tg-1" }))).toBe("cancelled:oops"); + + // Order 2: nothing arrives; the durable 1h timer fires (time-skipped). + expect(await run(GraceFlow.execute({ orderId: "tg-2" }))).toBe("shipped"); + + // continueAsNew from the definition module rolls the run twice. + expect(await run(Tally.execute({ batchId: "tb-1", count: 0 }))).toBe("tallied:2"); + const tallyId = await run(Tally.executionId({ batchId: "tb-1", count: 0 })); + const events = (await client.workflow.getHandle(tallyId).fetchHistory()).events ?? []; + expect(events[0]?.workflowExecutionStartedEventAttributes?.continuedExecutionRunId).toBeTruthy(); + + // executeChild: awaited success, awaited typed failure, discarded. + expect(await run(Dispatch.execute({ orderId: "td-1", discard: false }))).toBe( + "routed|fulfilled:res-td-1", + ); + const failed = await run(Effect.result(Dispatch.execute({ orderId: "td-bad", discard: false }))); + expect(Result.isFailure(failed) && failed.failure).toBe("unfulfillable:td-bad"); + const childId = await run(Fulfil.executionId({ orderId: "td-2" })); + expect(await run(Dispatch.execute({ orderId: "td-2", discard: true }))).toBe( + `routed|started:${childId}`, + ); + // The discarded child is a real execution under its digest id. + expect(await run(Fulfil.execute({ orderId: "td-2" }))).toBe("fulfilled:res-td-2"); + }); + }, 180_000); +}); diff --git a/src/__tests__/fixtures/batch-workflows.ts b/src/__tests__/fixtures/batch-workflows.ts index 93b114c..5a262e2 100644 --- a/src/__tests__/fixtures/batch-workflows.ts +++ b/src/__tests__/fixtures/batch-workflows.ts @@ -9,12 +9,8 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import { proxyActivities } from "@temporalio/workflow"; -import { - callRawActivity, - continueAsNew, - workflowBundle, - offerMailbox, -} from "../../engine-sandbox.js"; +import { callRawActivity, continueAsNew, offerMailbox } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { BatchDemo, CompletionReports, RecordDemo } from "./batch-demo.js"; const acts = proxyActivities<{ processRecord(index: string): Promise }>({ diff --git a/src/__tests__/fixtures/chain-workflows-v1.ts b/src/__tests__/fixtures/chain-workflows-v1.ts index 838c799..43e6dbc 100644 --- a/src/__tests__/fixtures/chain-workflows-v1.ts +++ b/src/__tests__/fixtures/chain-workflows-v1.ts @@ -6,7 +6,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { ChainDemo } from "./chain-demo.js"; const acts = proxyActivities<{ greetV1(): Promise }>({ diff --git a/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts b/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts index e08f8ec..b03dd09 100644 --- a/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts +++ b/src/__tests__/fixtures/chain-workflows-v2-unguarded.ts @@ -6,7 +6,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { ChainDemo } from "./chain-demo.js"; const acts = proxyActivities<{ greetV2(): Promise }>({ diff --git a/src/__tests__/fixtures/chain-workflows-v2.ts b/src/__tests__/fixtures/chain-workflows-v2.ts index 936717f..224a712 100644 --- a/src/__tests__/fixtures/chain-workflows-v2.ts +++ b/src/__tests__/fixtures/chain-workflows-v2.ts @@ -6,7 +6,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import * as Versioning from "../../versioning.js"; import { ChainDemo } from "./chain-demo.js"; diff --git a/src/__tests__/fixtures/chain-workflows-v3.ts b/src/__tests__/fixtures/chain-workflows-v3.ts index 161200b..2549690 100644 --- a/src/__tests__/fixtures/chain-workflows-v3.ts +++ b/src/__tests__/fixtures/chain-workflows-v3.ts @@ -4,7 +4,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import * as Versioning from "../../versioning.js"; import { ChainDemo } from "./chain-demo.js"; diff --git a/src/__tests__/fixtures/child-workflows.ts b/src/__tests__/fixtures/child-workflows.ts index 78dc4ad..45fa99b 100644 --- a/src/__tests__/fixtures/child-workflows.ts +++ b/src/__tests__/fixtures/child-workflows.ts @@ -11,7 +11,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { ChildDemo, ParentDemo } from "./child-demo.js"; const acts = proxyActivities<{ diff --git a/src/__tests__/fixtures/definition-demo.ts b/src/__tests__/fixtures/definition-demo.ts index c7796d4..d30df17 100644 --- a/src/__tests__/fixtures/definition-demo.ts +++ b/src/__tests__/fixtures/definition-demo.ts @@ -9,12 +9,16 @@ import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; import { + continueAsNew, defineActivity, defineDeferred, defineMailbox, defineState, defineUpdate, + executeChild, + sleep, version, + versioned, } from "../../definition.js"; export const CardDeclined = Schema.TaggedStruct("CardDeclined", { @@ -103,3 +107,81 @@ export const chargeImpl = ({ amountCents >= 10_000 ? Effect.fail({ _tag: "CardDeclined", orderId } as const) : Effect.succeed(`receipt-${orderId}-${amountCents}`); + +// ── 0.4.0: timers, continue-as-new, children, run-table versioning ────────── +// Everything below is NEW in 0.4.0 and lives beside `OrderFlow`, whose +// handler is byte-for-byte the 0.3.0 one: the recorded 0.3.0 history in +// `histories/` must keep replaying through this bundle. + +/** Grace period: a cancellation racing a durable timer — the pattern most + * real workflows have, and the one the in-memory runtime could not run + * before 0.4.0 (timers were not on the seam). */ +export const CancelOrder = defineMailbox("defGrace/cancel", { + payload: Schema.Struct({ reason: Schema.String }), +}); + +export const GraceFlow = Workflow.make("defGrace", { + payload: { orderId: Schema.String }, + idempotencyKey: ({ orderId }) => orderId, + success: Schema.String, +}); + +export const graceHandler = (_payload: { readonly orderId: string }) => + Effect.gen(function* () { + const winner = yield* Effect.raceFirst( + CancelOrder.take.pipe(Effect.map((m) => ({ kind: "cancelled" as const, reason: m.reason }))), + sleep({ name: "grace", duration: "1 hour" }).pipe( + Effect.map(() => ({ kind: "elapsed" as const })), + ), + ); + return winner.kind === "cancelled" ? `cancelled:${winner.reason}` : "shipped"; + }); + +/** Continue-as-new: a counter that rolls its history every run. */ +export const Tally = Workflow.make("defTally", { + payload: { batchId: Schema.String, count: Schema.Finite }, + idempotencyKey: ({ batchId }) => batchId, + success: Schema.String, +}); + +export const tallyHandler = (payload: { readonly batchId: string; readonly count: number }) => + Effect.gen(function* () { + if (payload.count >= 2) return `tallied:${payload.count}`; + return yield* continueAsNew(Tally, { batchId: payload.batchId, count: payload.count + 1 }); + }); + +/** A child with a typed failure, and a parent that starts it awaited or + * discarded — both authored against `WorkflowOps` alone. */ +export const Fulfil = Workflow.make("defFulfil", { + payload: { orderId: Schema.String }, + idempotencyKey: ({ orderId }) => orderId, + success: Schema.String, + error: Schema.String, +}); + +export const fulfilHandler = (payload: { readonly orderId: string }) => + payload.orderId.endsWith("-bad") + ? Effect.fail(`unfulfillable:${payload.orderId}`) + : Effect.map(Reserve({ orderId: payload.orderId }), (r) => `fulfilled:${r}`); + +export const Dispatch = Workflow.make("defDispatch", { + payload: { orderId: Schema.String, discard: Schema.Boolean }, + idempotencyKey: ({ orderId }) => orderId, + success: Schema.String, + error: Schema.String, +}); + +export const dispatchHandler = (payload: { readonly orderId: string; readonly discard: boolean }) => + Effect.gen(function* () { + // The run-table form of `version`: key order is the chain order. + const mode = yield* versioned("defDispatch/mode", { + direct: Effect.succeed("direct"), + routed: Effect.succeed("routed"), + }); + if (payload.discard) { + const childId = yield* executeChild(Fulfil, { orderId: payload.orderId }, { discard: true }); + return `${mode}|started:${childId}`; + } + const fulfilled = yield* executeChild(Fulfil, { orderId: payload.orderId }); + return `${mode}|${fulfilled}`; + }); diff --git a/src/__tests__/fixtures/definition-workflows.ts b/src/__tests__/fixtures/definition-workflows.ts index 2af61d4..ef7ed19 100644 --- a/src/__tests__/fixtures/definition-workflows.ts +++ b/src/__tests__/fixtures/definition-workflows.ts @@ -1,8 +1,29 @@ -// The Temporal bundle for the definition demo: the SAME handler the memory +// The Temporal bundle for the definition demo: the SAME handlers the memory // test runs, hosted by workflowBundle — which provides the Temporal -// `WorkflowOps` runtime the declarations require. +// `WorkflowOps` runtime the declarations require. `OrderFlow`'s handler is +// unchanged since 0.3.0 (see replay-compat.test.ts); the rest is 0.4.0. -import { workflowBundle } from "../../engine-sandbox.js"; -import { OrderFlow, orderHandler } from "./definition-demo.js"; +import * as Layer from "effect/Layer"; +import { workflowBundle } from "../../bundle.js"; +import { + Dispatch, + dispatchHandler, + Fulfil, + fulfilHandler, + GraceFlow, + graceHandler, + OrderFlow, + orderHandler, + Tally, + tallyHandler, +} from "./definition-demo.js"; -export default workflowBundle(OrderFlow.toLayer(orderHandler)); +export default workflowBundle( + Layer.mergeAll( + OrderFlow.toLayer(orderHandler), + GraceFlow.toLayer(graceHandler), + Tally.toLayer(tallyHandler), + Fulfil.toLayer(fulfilHandler), + Dispatch.toLayer(dispatchHandler), + ), +); diff --git a/src/__tests__/fixtures/demo-workflows.ts b/src/__tests__/fixtures/demo-workflows.ts index 47a0b16..a619bf7 100644 --- a/src/__tests__/fixtures/demo-workflows.ts +++ b/src/__tests__/fixtures/demo-workflows.ts @@ -10,7 +10,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { Approval, Demo } from "./demo.js"; const acts = proxyActivities<{ diff --git a/src/__tests__/fixtures/dsl-workflows.ts b/src/__tests__/fixtures/dsl-workflows.ts index 2711537..a2d950c 100644 --- a/src/__tests__/fixtures/dsl-workflows.ts +++ b/src/__tests__/fixtures/dsl-workflows.ts @@ -4,7 +4,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { DslDemo } from "./dsl-demo.js"; const acts = proxyActivities<{ runTask(name: string): Promise }>({ diff --git a/src/__tests__/fixtures/histories/definition-order-0.3.0.history.b64 b/src/__tests__/fixtures/histories/definition-order-0.3.0.history.b64 new file mode 100644 index 0000000..d1c11e0 --- /dev/null +++ b/src/__tests__/fixtures/histories/definition-order-0.3.0.history.b64 @@ -0,0 +1 @@ +Co8CCAESDAjBsY3VBhDA7NigARgBMvoBCgoKCGRlZk9yZGVyKi8KK3JlY29yZC1mZDRlYjBiYy0xM2IxLTQyNzctYTZmZC02OTM5NWY4ZmE0NDgQATI0CjIKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SGHsib3JkZXJJZCI6Imhpc3QtMC4zLjAifToGCICGsJYBQgYIgIawlgFKAggKciQ4ZWZjMzdkYS0xNWFjLTQwNzUtOTc3MS04ZjUyODE1NjFlNzV6GzMwOTY4QFVyaXMtTWFjLVN0dWRpby5sb2NhbIIBJDhlZmMzN2RhLTE1YWMtNDA3NS05NzcxLThmNTI4MTU2MWU3NZABAaoBAMoBAApLCAISDAjBsY3VBhDA7NigARgFUjcKLworcmVjb3JkLWZkNGViMGJjLTEzYjEtNDI3Ny1hNmZkLTY5Mzk1ZjhmYTQ0OBABEgIIChgBCjYIAxIMCMGxjdUGEMDs2KABGAZaIggCEhszMDk2OEBVcmlzLU1hYy1TdHVkaW8ubG9jYWwo2gIKtwEIBBIMCMGxjdUGEMDxrLwBGAdiogEIAhobMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsIlpAdGVtcG9yYWxpby93b3JrZXJAMS4xOS4wK2NkMjVmNzc5ZTU2MDg1ODk2OWI0NTFkOTA4ZDY0NjVmNDI2Yzc1ZTg3ODg0OTE3N2U5OTYwMTNhMjU1YTZmOTkyIwgDCAEIAhoTdGVtcG9yYWwtdHlwZXNjcmlwdCIGMS4xOS4wagAKwwEIBRIMCMGxjdUGEMDxrLwBGAp6rgEKATESEgoQZGVmT3JkZXIvcmVzZXJ2ZSIvCityZWNvcmQtZmQ0ZWIwYmMtMTNiMS00Mjc3LWE2ZmQtNjkzOTVmOGZhNDQ4EAEqADI0CjIKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SGHsib3JkZXJJZCI6Imhpc3QtMC4zLjAifToGCICGsJYBQgYIgIawlgFKAwjYBFIAWANiEwoCCAERAAAAAAAAAEAaAghkIAMKSwgGEgwIwbGN1QYQwPGsvAEYBVI3Ci8KK3JlY29yZC1mZDRlYjBiYy0xM2IxLTQyNzctYTZmZC02OTM5NWY4ZmE0NDgQARICCAoYAgo2CAcSDAjBsY3VBhDA8ay8ARgGWiIIBhIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsKNQGCpMBCAgSDAjBsY3VBhDAjJu/ARgHYn8IBhobMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsIlpAdGVtcG9yYWxpby93b3JrZXJAMS4xOS4wK2NkMjVmNzc5ZTU2MDg1ODk2OWI0NTFkOTA4ZDY0NjVmNDI2Yzc1ZTg3ODg0OTE3N2U5OTYwMTNhMjU1YTZmOTkyAGoACrACCAkSDAjBsY3VBhDAjJu/ARgp8gKaAgokYjc4ZjZjNmItZjYxZC00ZmRiLThmYWYtZjQ5NzVmM2RmYTdkEixiNzhmNmM2Yi1mNjFkLTRmZGItOGZhZi1mNDk3NWYzZGZhN2QvcmVxdWVzdBgGIsEBCkMKJGI3OGY2YzZiLWY2MWQtNGZkYi04ZmFmLWY0OTc1ZjNkZmE3ZBIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsEnoKABIWZWZmZWN0LXdvcmtmbG93LXVwZGF0ZRpeClwKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SQnsidXBkYXRlTmFtZSI6ImRlZk9yZGVyL3NldEFtb3VudCIsInBheWxvYWQiOnsiYW1vdW50Q2VudHMiOjI1MDB9fQo2CAoSDAjBsY3VBhDA8ay8ARgLggEhCAUSGzMwOTY4QFVyaXMtTWFjLVN0dWRpby5sb2NhbCABCmQICxIMCMGxjdUGEMCMm78BGAyKAU8KLAoqChYKCGVuY29kaW5nEgpqc29uL3BsYWluEhAicmVzLWhpc3QtMC4zLjAiEAUYCCIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsCksIDBIMCMGxjdUGEMCMm78BGAVSNwovCityZWNvcmQtZmQ0ZWIwYmMtMTNiMS00Mjc3LWE2ZmQtNjkzOTVmOGZhNDQ4EAESAggKGAEKNggNEgwIwbGN1QYQwIybvwEYBloiCAwSGzMwOTY4QFVyaXMtTWFjLVN0dWRpby5sb2NhbCiyDAqTAQgOEgwIwbGN1QYQgJrSwAEYB2J/CAwaGzMwOTY4QFVyaXMtTWFjLVN0dWRpby5sb2NhbCJaQHRlbXBvcmFsaW8vd29ya2VyQDEuMTkuMCtjZDI1Zjc3OWU1NjA4NTg5NjliNDUxZDkwOGQ2NDY1ZjQyNmM3NWU4Nzg4NDkxNzdlOTk2MDEzYTI1NWE2Zjk5MgBqAAqaAQgPEgwIwbGN1QYQgJrSwAEYK4IDhAEKQwokYjc4ZjZjNmItZjYxZC00ZmRiLThmYWYtZjQ5NzVmM2RmYTdkEhszMDk2OEBVcmlzLU1hYy1TdHVkaW8ubG9jYWwSPQo7CjkKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SH3siX3RhZyI6IlN1Y2Nlc3MiLCJ2YWx1ZSI6MTAwMH0KowEIEBIMCMGxjdUGEICjzMEBGBrSAY0BChdlZmZlY3Qtd29ya2Zsb3ctbWFpbGJveBJVClMKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SOXsibWFpbGJveE5hbWUiOiJkZWZPcmRlci9wcmlvcml0eSIsInBheWxvYWQiOnsibGV2ZWwiOjJ9fRobMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsCksIERIMCMGxjdUGEICjzMEBGAVSNwovCityZWNvcmQtZmQ0ZWIwYmMtMTNiMS00Mjc3LWE2ZmQtNjkzOTVmOGZhNDQ4EAESAggKGAEKNggSEgwIwbGN1QYQgKPMwQEYBloiCBESGzMwOTY4QFVyaXMtTWFjLVN0dWRpby5sb2NhbCiDEQqWAQgTEgwIwbGN1QYQwLn9wwEYB2KBAQgRGhszMDk2OEBVcmlzLU1hYy1TdHVkaW8ubG9jYWwiWkB0ZW1wb3JhbGlvL3dvcmtlckAxLjE5LjArY2QyNWY3NzllNTYwODU4OTY5YjQ1MWQ5MDhkNjQ2NWY0MjZjNzVlODc4ODQ5MTc3ZTk5NjAxM2EyNTVhNmY5OTICEAJqAAqCAQgUEgwIwbGN1QYQwLn9wwEYGcoBbQoKY29yZV9wYXRjaBJdCgpwYXRjaC1kYXRhEk8KTQoWCghlbmNvZGluZxIKanNvbi9wbGFpbhIzeyJpZCI6ImRlZk9yZGVyL3ByaWNpbmctdGllcmVkIiwiZGVwcmVjYXRlZCI6ZmFsc2V9GBIKfggVEgwIwbGN1QYQwLn9wwEYKOoCaQgSEmUKYwoVVGVtcG9yYWxDaGFuZ2VWZXJzaW9uEkoKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4KEwoEdHlwZRILS2V5d29yZExpc3QSG1siZGVmT3JkZXIvcHJpY2luZy10aWVyZWQiXQrBAQgWEgwIwbGN1QYQwLn9wwEYGtIBqwEKHWVmZmVjdC13b3JrZmxvdy1kZWZlcnJlZC1kb25lEm0KawoWCghlbmNvZGluZxIKanNvbi9wbGFpbhJReyJkZWZlcnJlZE5hbWUiOiJkZWZPcmRlci9hcHByb3ZhbCIsImV4aXQiOnsiX3RhZyI6IlN1Y2Nlc3MiLCJ2YWx1ZSI6InJlY29yZGVyIn19GhszMDk2OEBVcmlzLU1hYy1TdHVkaW8ubG9jYWwKSwgXEgwIwbGN1QYQwLn9wwEYBVI3Ci8KK3JlY29yZC1mZDRlYjBiYy0xM2IxLTQyNzctYTZmZC02OTM5NWY4ZmE0NDgQARICCAoYAgo2CBgSDAjBsY3VBhDAuf3DARgGWiIIFxIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsKNwWCpMBCBkSDAjBsY3VBhDAwvfEARgHYn8IFxobMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsIlpAdGVtcG9yYWxpby93b3JrZXJAMS4xOS4wK2NkMjVmNzc5ZTU2MDg1ODk2OWI0NTFkOTA4ZDY0NjVmNDI2Yzc1ZTg3ODg0OTE3N2U5OTYwMTNhMjU1YTZmOTkyAGoACtUBCBoSDAjBsY3VBhDAwvfEARgKesABCgEyEhEKD2RlZk9yZGVyL2NoYXJnZSIvCityZWNvcmQtZmQ0ZWIwYmMtMTNiMS00Mjc3LWE2ZmQtNjkzOTVmOGZhNDQ4EAEqADJHCkUKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SK3sib3JkZXJJZCI6Imhpc3QtMC4zLjAiLCJhbW91bnRDZW50cyI6MjUwMH06BgiAhrCWAUIGCICGsJYBSgMI2ARSAFgYYhMKAggBEQAAAAAAAABAGgIIZCADCjYIGxIMCMGxjdUGEMDC98QBGAuCASEIGhIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsIAEKbQgcEgwIwbGN1QYQwML3xAEYDIoBWAo1CjMKFgoIZW5jb2RpbmcSCmpzb24vcGxhaW4SGSJyZWNlaXB0LWhpc3QtMC4zLjAtMjUwMCIQGhgbIhszMDk2OEBVcmlzLU1hYy1TdHVkaW8ubG9jYWwKSwgdEgwIwbGN1QYQwML3xAEYBVI3Ci8KK3JlY29yZC1mZDRlYjBiYy0xM2IxLTQyNzctYTZmZC02OTM5NWY4ZmE0NDgQARICCAoYAQo2CB4SDAjBsY3VBhDAwvfEARgGWiIIHRIbMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsKOgbCpMBCB8SDAjBsY3VBhDAy/HFARgHYn8IHRobMzA5NjhAVXJpcy1NYWMtU3R1ZGlvLmxvY2FsIlpAdGVtcG9yYWxpby93b3JrZXJAMS4xOS4wK2NkMjVmNzc5ZTU2MDg1ODk2OWI0NTFkOTA4ZDY0NjVmNDI2Yzc1ZTg3ODg0OTE3N2U5OTYwMTNhMjU1YTZmOTkyAGoACo0BCCASDAjBsY3VBhDAy/HFARgCOnkKdQpzChYKCGVuY29kaW5nEgpqc29uL3BsYWluEll7Il90YWciOiJTdWNjZXNzIiwidmFsdWUiOiJyZXMtaGlzdC0wLjMuMHxyZWNlaXB0LWhpc3QtMC4zLjAtMjUwMHxwMnx0aWVyZWR8Ynk6cmVjb3JkZXIifRAe \ No newline at end of file diff --git a/src/__tests__/fixtures/lock-workflows.ts b/src/__tests__/fixtures/lock-workflows.ts index e9d7343..f7b62d2 100644 --- a/src/__tests__/fixtures/lock-workflows.ts +++ b/src/__tests__/fixtures/lock-workflows.ts @@ -5,7 +5,8 @@ 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, workflowBundle, offerMailbox } from "../../engine-sandbox.js"; +import { callRawActivity, offerMailbox } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { AcquireRequests, ContenderDemo, Grants, LockDemo, Releases } from "./lock-demo.js"; const acts = proxyActivities<{ diff --git a/src/__tests__/fixtures/loop-workflows.ts b/src/__tests__/fixtures/loop-workflows.ts index 020c3d7..265ea12 100644 --- a/src/__tests__/fixtures/loop-workflows.ts +++ b/src/__tests__/fixtures/loop-workflows.ts @@ -5,7 +5,8 @@ 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, continueAsNew, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity, continueAsNew } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { CellLoopDemo, LoopDemo, LoopGate, LoopStage } from "./loop-demo.js"; const acts = proxyActivities<{ record(iteration: string): Promise }>({ diff --git a/src/__tests__/fixtures/mailbox-workflows.ts b/src/__tests__/fixtures/mailbox-workflows.ts index 68ecfbe..184e3c3 100644 --- a/src/__tests__/fixtures/mailbox-workflows.ts +++ b/src/__tests__/fixtures/mailbox-workflows.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { workflowBundle } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { DeadlineUpdates, StateDemo, diff --git a/src/__tests__/fixtures/message-workflows.ts b/src/__tests__/fixtures/message-workflows.ts index bdbade9..927e08a 100644 --- a/src/__tests__/fixtures/message-workflows.ts +++ b/src/__tests__/fixtures/message-workflows.ts @@ -4,7 +4,7 @@ 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 { workflowBundle } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { Approved, CurrentLanguage, diff --git a/src/__tests__/fixtures/nexus-workflows.ts b/src/__tests__/fixtures/nexus-workflows.ts index 9f4ec50..afbad37 100644 --- a/src/__tests__/fixtures/nexus-workflows.ts +++ b/src/__tests__/fixtures/nexus-workflows.ts @@ -5,12 +5,8 @@ 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, - workflowBundle, - type NexusOperationClient, -} from "../../engine-sandbox.js"; +import { callNexusWorkflowOperation, callRawActivity, type NexusOperationClient } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { CallerDemo, GreetDemo, helloService } from "./nexus-demo.js"; const GreetDemoLive = GreetDemo.toLayer((payload) => diff --git a/src/__tests__/fixtures/polling-workflows.ts b/src/__tests__/fixtures/polling-workflows.ts index 9cc349e..7c70a2b 100644 --- a/src/__tests__/fixtures/polling-workflows.ts +++ b/src/__tests__/fixtures/polling-workflows.ts @@ -7,7 +7,8 @@ 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, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { PollingDemo } from "./polling-demo.js"; const acts = proxyActivities<{ pollService(): Promise }>({ diff --git a/src/__tests__/fixtures/registry-workflows.ts b/src/__tests__/fixtures/registry-workflows.ts index 0e5d7a3..2472da4 100644 --- a/src/__tests__/fixtures/registry-workflows.ts +++ b/src/__tests__/fixtures/registry-workflows.ts @@ -8,7 +8,8 @@ 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 { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { RegistryChild, RegistryParent } from "./registry-demo.js"; const acts = proxyActivities<{ echo(value: string): Promise }>({ diff --git a/src/__tests__/fixtures/short-sleep-workflows.ts b/src/__tests__/fixtures/short-sleep-workflows.ts index 0339bc5..739f4dc 100644 --- a/src/__tests__/fixtures/short-sleep-workflows.ts +++ b/src/__tests__/fixtures/short-sleep-workflows.ts @@ -1,7 +1,8 @@ import * as Effect from "effect/Effect"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { proxyActivities } from "@temporalio/workflow"; -import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; +import { callRawActivity } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { ShortSleepDemo } from "./short-sleep-demo.js"; const acts = proxyActivities<{ echo(value: string): Promise }>({ diff --git a/src/__tests__/fixtures/transaction-workflows.ts b/src/__tests__/fixtures/transaction-workflows.ts index b6d3199..613a617 100644 --- a/src/__tests__/fixtures/transaction-workflows.ts +++ b/src/__tests__/fixtures/transaction-workflows.ts @@ -6,7 +6,7 @@ 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 { workflowBundle } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../bundle.js"; import { GetConfirmation, TransactionDemo } from "./transaction-demo.js"; const TransactionDemoLive = TransactionDemo.toLayer(() => diff --git a/src/__tests__/fixtures/typed-activity-workflows.ts b/src/__tests__/fixtures/typed-activity-workflows.ts index cb1bc69..df179e1 100644 --- a/src/__tests__/fixtures/typed-activity-workflows.ts +++ b/src/__tests__/fixtures/typed-activity-workflows.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; -import { workflowBundle, sleepUntil } from "../../engine-sandbox.js"; +import { sleepUntil } from "../../definition.js"; +import { workflowBundle } from "../../bundle.js"; import { Reserve, TypedActivityDemo } from "./typed-activity-demo.js"; // Exercises the typed seam end-to-end: an absolute-time durable sleep, a diff --git a/src/__tests__/lint.test.ts b/src/__tests__/lint.test.ts index bd07dd3..e2782bb 100644 --- a/src/__tests__/lint.test.ts +++ b/src/__tests__/lint.test.ts @@ -40,13 +40,44 @@ export const v = version("site", ["v1", "v2"]); `; // A definition-authored handler module imports NO engine module — the -// `version` import alone must mark it for the versioning rule. +// `version` / `versioned` imports alone must mark it for the versioning rule. const DEFINITION_ONLY = ` import * as Effect from "effect/Effect"; -import { version as pickVersion } from "@springbird/effect-temporal/definition"; +import { version as pickVersion, versioned } from "@springbird/effect-temporal/definition"; export const bad = Effect.forkChild(pickVersion("site", ["v1", "v2"])); +export const badTable = Effect.raceFirst( + versioned("site", { v1: Effect.succeed(1), v2: Effect.succeed(2) }), + Effect.succeed(3), +); export const fine = pickVersion("site", ["v1", "v2"]); +export const fineTable = versioned("site", { v1: Effect.succeed(1), v2: Effect.succeed(2) }); +`; + +// The deprecated authoring surface, every shape the rule must catch: named +// imports of deprecated engine-sandbox ops, the typed-activity and +// versioning modules (named and namespace), and the primitive constructors. +const DEPRECATED_IMPORTS = ` +import { callActivity, takeMailbox, sleepUntil, type UpdateRequest } from "@springbird/effect-temporal/engine-sandbox"; +import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; +import { codecsFor, make as makeActivity } from "@springbird/effect-temporal/typed-activity"; +import * as Versioning from "@springbird/effect-temporal/versioning"; +import { make as makeMailbox, MAILBOX_SIGNAL } from "@springbird/effect-temporal/mailbox"; +import { make as makeUpdate } from "@springbird/effect-temporal/update"; +import { make as makeCell } from "../state-cell.js"; +export const all = [callActivity, takeMailbox, sleepUntil, TypedActivity, codecsFor, makeActivity, Versioning, makeMailbox, MAILBOX_SIGNAL, makeUpdate, makeCell]; +export type R = UpdateRequest; +`; + +// The 0.4.0 authoring surface — nothing here may be reported. +const MODERN = ` +import { workflowBundle } from "@springbird/effect-temporal/bundle"; +import { callRawActivity, workflowBundle as legacyBundleImport } from "@springbird/effect-temporal/engine-sandbox"; +import { defineActivity, sleep, continueAsNew, executeChild, versioned, type PayloadOf } from "@springbird/effect-temporal/definition"; +import { codecsFor, ACTIVITY_EXIT_TYPE } from "@springbird/effect-temporal/wire"; +import { MAILBOX_SIGNAL } from "some-other-lib/mailbox"; +export const all = [workflowBundle, callRawActivity, legacyBundleImport, defineActivity, sleep, continueAsNew, executeChild, versioned, codecsFor, ACTIVITY_EXIT_TYPE, MAILBOX_SIGNAL]; +export type P = PayloadOf; `; const runOxlint = (directory: string, files: string[]) => { @@ -84,9 +115,50 @@ describe("lint plugin", { concurrent: false }, () => { expect(goodFindings).toEqual([]); // The definition-only module (no engine imports, aliased `version`) is - // still covered by the versioning rule — exactly one finding, the fork. + // still covered by the versioning rule — exactly two findings: the + // forked `version` and the raced `versioned`. const definitionOutput = runOxlint(directory, [definitionOnly]); expect(definitionOutput).toContain("effect-temporal(versioning-on-main-fiber)"); - expect(definitionOutput.match(/effect-temporal\(/g)).toHaveLength(1); + expect(definitionOutput.match(/effect-temporal\(/g)).toHaveLength(2); + }, 60_000); + + it("prefer-definition reports every deprecated import with its replacement, and nothing modern", () => { + const directory = mkdtempSync(join(tmpdir(), "effect-workflow-lint-")); + const deprecated = join(directory, "deprecated.ts"); + const modern = join(directory, "modern.ts"); + writeFileSync(deprecated, DEPRECATED_IMPORTS); + writeFileSync(modern, MODERN); + + const output = runOxlint(directory, [deprecated]); + const findings = output.match(/effect-temporal\(prefer-definition\)/g) ?? []; + // callActivity, takeMailbox, sleepUntil, UpdateRequest, * as + // TypedActivity, codecsFor, make (typed-activity), * as Versioning, + // make (mailbox), MAILBOX_SIGNAL, make (update), make (state-cell) + expect(findings).toHaveLength(12); + for (const replacement of [ + "call the declared activity directly", + "`defineActivity` from `definition`", + "`codecsFor` from `wire`", + "`version` / `versioned` from `definition`", + "`defineMailbox` from `definition`", + "`defineUpdate` from `definition`", + "`defineState` from `definition`", + "`sleepUntil` from `definition`", + "`UpdateRequest` from `definition`", + "`offer` / `offersTo`", + ]) { + expect(output).toContain(replacement); + } + // oxlint exits non-zero on an error-level finding: this is what stops a + // consumer regressing onto the deprecated surface. + const config = join(directory, ".oxlintrc.json"); + const status = spawnSync("oxlint", ["--config", config, deprecated], { encoding: "utf8" }).status; + expect(status).not.toBe(0); + + const modernOutput = runOxlint(directory, [modern]); + const modernFindings = modernOutput + .split("\n") + .filter((line) => line.includes("modern.ts") && line.includes("effect-temporal(")); + expect(modernFindings).toEqual([]); }, 60_000); }); diff --git a/src/__tests__/replay-compat.test.ts b/src/__tests__/replay-compat.test.ts new file mode 100644 index 0000000..55af640 --- /dev/null +++ b/src/__tests__/replay-compat.test.ts @@ -0,0 +1,44 @@ +// ========================================================================= +// Wire compatibility across releases: a history recorded on 0.3.0 replays +// through the current bundle +// ========================================================================= +// +// `fixtures/histories/definition-order-0.3.0.history.b64` is a real +// `defOrder` run recorded with the 0.3.0 `definition-workflows` bundle +// (update, mailbox signal, patch marker, activities, deferred signal). The +// current bundle must replay it clean: activity types, signal and query +// names, update names, and patch-marker ids are byte-identical, and the +// `OrderFlow` handler still issues the same commands in the same order. +// +// Record a new fixture only when a release INTENDS a wire change — and then +// keep the old one too, so the drill covers every generation still in +// flight. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { temporal as proto } from "@temporalio/proto"; +import { bundleWorkflowCode, Worker } from "@temporalio/worker"; +import { describe, expect, it } from "vitest"; + +const workflowsPath = fileURLToPath(new URL("./fixtures/definition-workflows.ts", import.meta.url)); + +const loadHistory = (name: string) => { + const path = fileURLToPath(new URL(`./fixtures/histories/${name}.history.b64`, import.meta.url)); + return proto.api.history.v1.History.decode(Buffer.from(readFileSync(path, "utf8"), "base64")); +}; + +describe("replay compatibility", { concurrent: false }, () => { + it("replays a 0.3.0 definition-demo history through the current bundle", async () => { + const history = loadHistory("definition-order-0.3.0"); + // The fixture is the real thing: activity, update, signal, and marker + // events are all present, so a replay exercises every wire name. + const kinds = new Set(history.events.map((event) => event.eventType)); + expect(kinds.has(proto.api.enums.v1.EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED)).toBe(true); + expect(kinds.has(proto.api.enums.v1.EventType.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED)).toBe(true); + expect(kinds.has(proto.api.enums.v1.EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED)).toBe(true); + expect(kinds.has(proto.api.enums.v1.EventType.EVENT_TYPE_MARKER_RECORDED)).toBe(true); + + const workflowBundle = await bundleWorkflowCode({ workflowsPath }); + await expect(Worker.runReplayHistory({ workflowBundle }, history)).resolves.toBeUndefined(); + }, 120_000); +}); diff --git a/src/__tests__/testing.test.ts b/src/__tests__/testing.test.ts index 52eb47e..453f4c5 100644 --- a/src/__tests__/testing.test.ts +++ b/src/__tests__/testing.test.ts @@ -9,6 +9,7 @@ import { makeWorkflowClient } from "../client.js"; import { decodeWorkflowResult, makeFakeTemporalClient, simulateAlreadyStarted } from "../testing.js"; import { wireCodecsFor } from "../wire.js"; import { Demo } from "./fixtures/demo.js"; +import { Priority } from "./fixtures/definition-demo.js"; const payload = { requestId: "fake-1", mode: "approve" } as const; @@ -93,4 +94,19 @@ describe("decodeWorkflowResult", () => { expect(() => decodeWorkflowResult(Demo, wire)).toThrow(/boom/); }); + + it("offers to a declared mailbox through the fake and reads the offers back typed", async () => { + const fake = makeFakeTemporalClient(); + await Effect.runPromise(fake.offer(Priority, "wf-5", { level: 3 })); + await Effect.runPromise(fake.offer(Priority.mailbox, "wf-6", { level: 1 })); + + // No wire constants needed on either side: the offers are decoded + // through the declaration's own schema. + expect(fake.offersTo(Priority)).toEqual([ + { workflowId: "wf-5", payload: { level: 3 } }, + { workflowId: "wf-6", payload: { level: 1 } }, + ]); + // ...and they are ordinary recorded signals underneath. + expect(fake.signals.map((signal) => signal.workflowId)).toEqual(["wf-5", "wf-6"]); + }); }); diff --git a/src/__tests__/typed-activity.test.ts b/src/__tests__/typed-activity.test.ts index fac73c2..4c8002a 100644 --- a/src/__tests__/typed-activity.test.ts +++ b/src/__tests__/typed-activity.test.ts @@ -7,7 +7,8 @@ import { ApplicationFailure, WorkflowFailedError } from "@temporalio/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { handle, implementActivities, type ActivityRunner } from "../activities.js"; import { startWorkflowTestHarness, type WorkflowTestHarness } from "../testing.js"; -import { codecsFor, type ErrorOf, type PayloadOf, type SuccessOf } from "../typed-activity.js"; +import { type ErrorOf, type PayloadOf, type SuccessOf } from "../definition.js"; +import { codecsFor } from "../wire.js"; import { Reserve, TypedActivityDemo } from "./fixtures/typed-activity-demo.js"; let harness: WorkflowTestHarness; diff --git a/src/activities.ts b/src/activities.ts index bb7598e..bf34faf 100644 --- a/src/activities.ts +++ b/src/activities.ts @@ -20,15 +20,8 @@ import type * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { ApplicationFailure, WorkflowNotFoundError, type Client } from "@temporalio/client"; import { classifyThrown } from "./thrown.js"; -import { - ACTIVITY_EXIT_TYPE, - codecsFor, - type AnyTypedActivity, - type ErrorOf, - type PayloadOf, - type SuccessOf, -} from "./typed-activity.js"; -import type { EffectWorkflowBridgeResult } from "./wire.js"; +import type { AnyTypedActivity, ErrorOf, PayloadOf, SuccessOf } from "./definition.js"; +import { ACTIVITY_EXIT_TYPE, codecsFor, type EffectWorkflowBridgeResult } from "./wire.js"; const classifyFailure = (error: unknown): EffectWorkflowBridgeResult => { const thrown = classifyThrown(error); diff --git a/src/bundle.ts b/src/bundle.ts new file mode 100644 index 0000000..ed179da --- /dev/null +++ b/src/bundle.ts @@ -0,0 +1,37 @@ +/** + * The one file the Temporal worker points at. A workflow bundle's entry + * module imports `workflowBundle` from here, passes it the merged + * `Workflow.toLayer` registrations, and exports the result as its DEFAULT + * export — every registered tag becomes a startable Temporal workflow type, + * and every hosted handler receives the Temporal `WorkflowOps` runtime the + * `definition` module's declarations dispatch through: + * + * ```ts + * // workflows.ts — the bundle entry (Worker.create({ workflowsPath: ... })) + * import * as Layer from "effect/Layer"; + * import { workflowBundle } from "@springbird/effect-temporal/bundle"; + * import { OrderFlow, orderHandler } from "./definitions.js"; + * + * export default workflowBundle(OrderFlow.toLayer(orderHandler)); + * ``` + * + * This is the whole public sandbox-side surface for applications authored + * with the `definition` module: handlers import `definition` (and their own + * modules), the bundle entry imports this. Everything else in the sandbox + * half (`engine-sandbox`) is engine machinery — raw activity proxies, the + * Nexus caller, workflow-to-workflow offers — for code that deliberately + * steps below the declaration surface. + * + * @since 0.4.0 + */ + +export { + /** + * Host `Workflow.toLayer` registrations behind one dynamic Temporal + * workflow. See the module doc. + * + * @since 0.4.0 + * @category constructors + */ + workflowBundle, +} from "./engine-sandbox.js"; diff --git a/src/client.ts b/src/client.ts index 1a24130..94246c4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -25,7 +25,6 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import type * as Schema from "effect/Schema"; -import type * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import type * as Workflow from "effect/unstable/workflow/Workflow"; import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"; import { @@ -35,6 +34,7 @@ import { type ScheduleSpec, } from "@temporalio/client"; import { + completeDeferred, createWorkflowSchedule, deferredState, executeUpdate, @@ -43,10 +43,8 @@ import { readStateCell, ScheduleAlreadyExistsError, } from "./engine-client.js"; -import type { DurableMailbox } from "./mailbox.js"; -import type { StateCell } from "./state-cell.js"; +import type { DeferredLike, MailboxLike, StateCellLike, UpdateLike } from "./definition.js"; import { classifyThrown } from "./thrown.js"; -import type { DurableUpdate } from "./update.js"; import { wireCodecsFor } from "./wire.js"; export { @@ -155,31 +153,43 @@ export interface WorkflowClientShape { workflowId: string, options?: { readonly reason?: string; readonly deadlineMillis?: number }, ) => Effect.Effect; - /** Offer to a running workflow's mailbox; closed/unknown is a no-op. */ + /** Offer to a running workflow's mailbox; closed/unknown is a no-op. + * Takes the declaration (`Priority`) or its underlying primitive. */ readonly offerMailbox: ( - mailbox: DurableMailbox, + mailbox: MailboxLike, workflowId: string, payload: S["Type"], ) => Effect.Effect; /** Send an update request and receive the workflow's typed response; - * unknown executions and runs that end unanswered are defects. */ + * unknown executions and runs that end unanswered are defects. Takes the + * declaration (`SetAmount`) or its underlying primitive. */ readonly executeUpdate:

( - update: DurableUpdate, + update: UpdateLike, workflowId: string, payload: P["Type"], ) => Effect.Effect; /** Read the latest published snapshot of a state cell — `None` while the - * execution is unknown or the cell unpublished, including after close. */ + * execution is unknown or the cell unpublished, including after close. + * Takes the declaration (`Status`) or its underlying primitive. */ readonly readStateCell: ( - cell: StateCell, + cell: StateCellLike, workflowId: string, ) => Effect.Effect>; /** Read a deferred's state via query — `None` while pending or unknown, - * `Some(typed exit)` once completed; never perturbs the signal path. */ + * `Some(typed exit)` once completed; never perturbs the signal path. + * Takes the declaration (`Approval`) or its underlying primitive. */ readonly deferredState: ( - deferred: DurableDeferred.DurableDeferred, + deferred: DeferredLike, workflowId: string, ) => Effect.Effect>>; + /** Complete a workflow's deferred by workflow id — the client half of + * `Approval.await`. Closed/unknown executions are a no-op. Takes the + * declaration or its underlying primitive. @since 0.4.0 */ + readonly completeDeferred: ( + deferred: DeferredLike, + workflowId: string, + exit: Exit.Exit, + ) => Effect.Effect; /** Create a Temporal schedule firing the workflow with a fixed payload; * an existing schedule under the id fails typed. */ readonly createSchedule: < @@ -461,6 +471,9 @@ export const makeWorkflowClient = (config: WorkflowClientConfig): WorkflowClient deferredState: (deferred, workflowId) => deferredState(deferred, { client: config.client, workflowId }), + completeDeferred: (deferred, workflowId, exit) => + completeDeferred(deferred, { client: config.client, workflowId, exit }), + createSchedule: (options) => createWorkflowSchedule({ client: config.client, diff --git a/src/definition.ts b/src/definition.ts index 593556f..19cbfce 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -13,39 +13,171 @@ * const OrderLive = OrderFlow.toLayer((payload) => * Effect.gen(function* () { * const receipt = yield* Charge({ orderId: payload.orderId }); + * yield* sleep({ name: "cooling-off", duration: "3 days" }); * const approver = yield* Approval.await; * return `${receipt}:by:${approver}`; * }), * ); * ``` * - * Every operation requires only the `WorkflowOps` service — the one seam an - * engine implements. `workflowBundle` provides the Temporal runtime; the + * Every operation — activity calls, message channels, state, timers + * (`sleep`, `sleepUntil`), `continueAsNew`, child workflows + * (`executeChild`), and versioning (`version`, `versioned`) — requires only + * the `WorkflowOps` service — the one seam an engine implements. The + * `bundle` module's `workflowBundle` provides the Temporal runtime; the * `testing` module provides an in-memory one, so the same handler runs on * real Temporal or in a plain unit test. Declarations are temporal-free and * carry the wire identity explicitly (their `name`), so refactoring code * never changes the wire. * + * This module must never import `@temporalio/*` or the sandbox half: it is + * the portable surface. + * * @since 0.3.0 */ import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; +import type * as Exit from "effect/Exit"; +import type * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaGetter from "effect/SchemaGetter"; import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; +import type * as Workflow from "effect/unstable/workflow/Workflow"; import * as DurableMailbox from "./mailbox.js"; import * as DurableUpdate from "./update.js"; import * as StateCell from "./state-cell.js"; -import * as TypedActivity from "./typed-activity.js"; + +// ─── Activity declarations (the types) ─────────────────────────────────────── + +/** + * Applied when a declaration declares no options of its own. + * + * @since 0.4.0 + * @category models + */ +export const DEFAULT_ACTIVITY_OPTIONS = { + startToCloseTimeout: "10 minutes", + retry: { maximumAttempts: 3 }, +} as const; + +/** + * The Temporal activity options a declaration carries — the subset of + * `proxyActivities` options a declared activity pins at declaration time. + * + * @since 0.4.0 + * @category models + */ +export interface TypedActivityOptions { + readonly startToCloseTimeout: string | number; + readonly retry?: { + readonly maximumAttempts?: number; + readonly nonRetryableErrorTypes?: string[]; + }; +} + +/** + * The serializable projection of a declared activity: name, the three + * channel schemas, and the Temporal options every call site honors. This is + * what crosses to the worker (`handle` / `implementActivities`) and what an + * engine's `WorkflowOps.activity` receives. + * + * @since 0.4.0 + * @category models + */ +export interface TypedActivity< + Name extends string, + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> { + readonly name: Name; + readonly payloadSchema: Payload; + readonly successSchema: Success; + readonly errorSchema: Error; + readonly options: TypedActivityOptions; +} + +/** + * Type-erased activity declaration, for APIs that operate on any activity. + * + * @since 0.4.0 + * @category models + */ +export type AnyTypedActivity = TypedActivity; + +/** + * Extracts a declared activity's decoded payload type: + * `PayloadOf`. + * + * @since 0.4.0 + * @category models + */ +export type PayloadOf = + A extends TypedActivity ? P["Type"] : never; +/** + * Extracts a declared activity's decoded success type. + * + * @since 0.4.0 + * @category models + */ +export type SuccessOf = + A extends TypedActivity ? S["Type"] : never; +/** + * Extracts a declared activity's decoded error type. + * + * @since 0.4.0 + * @category models + */ +export type ErrorOf = + A extends TypedActivity ? E["Type"] : never; + +/** + * Build the serializable projection of an activity declaration. Shared by + * `defineActivity` and the deprecated `TypedActivity.make`. + * + * @internal + */ +export const makeTypedActivity = < + const Name extends string, + Payload extends Schema.Struct.Fields | Schema.Top, + Success extends Schema.Top = Schema.Void, + Error extends Schema.Top = Schema.Never, +>( + name: Name, + definition: { + readonly payload: Payload; + readonly success?: Success; + readonly error?: Error; + readonly options?: TypedActivityOptions; + }, +): TypedActivity< + Name, + Payload extends Schema.Struct.Fields ? Schema.Struct : Payload, + Success, + Error +> => ({ + name, + // SAFETY: the branch mirrors the conditional return type — a schema stays + // itself, struct fields become `Schema.Struct(fields)` — but TypeScript + // cannot resolve the conditional over the unbound `Payload`. + payloadSchema: (Schema.isSchema(definition.payload) + ? definition.payload + : Schema.Struct(definition.payload as Schema.Struct.Fields)) as never, + // SAFETY: when the option is omitted the type parameter takes its default + // (`Schema.Void` / `Schema.Never`), which is exactly the fallback value. + successSchema: (definition.success ?? Schema.Void) as Success, + errorSchema: (definition.error ?? Schema.Never) as Error, + options: definition.options ?? DEFAULT_ACTIVITY_OPTIONS, +}); // ─── The ops seam ──────────────────────────────────────────────────────────── /** * A taken update request: the decoded payload and its one-shot typed - * response channel. + * response channel. Type parameters are `` — the + * same order as `defineUpdate`'s schemas. * * @since 0.3.0 * @category models @@ -55,6 +187,42 @@ export interface UpdateRequest { readonly respond: (exit: Exit.Exit) => Effect.Effect; } +/** + * Options for `sleep`: a name unique per sleep within a run, and a duration. + * + * @since 0.4.0 + * @category models + */ +export interface SleepOptions { + readonly name: string; + readonly duration: Duration.Input; +} + +/** + * Options for `sleepUntil`: a name and an absolute target. + * + * @since 0.4.0 + * @category models + */ +export interface SleepUntilOptions { + readonly name: string; + /** Epoch milliseconds, or a date-time string CARRYING ITS ZONE (`Z` or an + * explicit offset; date-only forms are UTC per ECMAScript). Zone-less + * date-times are rejected: `Date.parse` reads them in the worker's local + * timezone, which is nondeterministic across workers and replays. */ + readonly timestamp: number | string; +} + +/** + * Options for `continueAsNew`. + * + * @since 0.4.0 + * @category models + */ +export interface ContinueAsNewOptions { + readonly memo?: Record; +} + /** * What an engine implements to host declared capabilities: one operation * per primitive kind, dispatching on the declaration instances. The typed @@ -68,16 +236,11 @@ export interface UpdateRequest { * @category models */ export interface WorkflowOpsRuntime { - readonly activity: ( - activity: TypedActivity.AnyTypedActivity, - payload: unknown, - ) => Effect.Effect; + readonly activity: (activity: AnyTypedActivity, payload: unknown) => Effect.Effect; readonly deferredAwait: ( deferred: DurableDeferred.DurableDeferred, ) => Effect.Effect; - readonly mailboxTake: ( - mailbox: DurableMailbox.DurableMailbox, - ) => Effect.Effect; + readonly mailboxTake: (mailbox: DurableMailbox.DurableMailbox) => Effect.Effect; readonly mailboxPoll: ( mailbox: DurableMailbox.DurableMailbox, ) => Effect.Effect>; @@ -89,6 +252,28 @@ export interface WorkflowOpsRuntime { site: string, names: Names, ) => Effect.Effect; + /** A durable named timer. @since 0.4.0 */ + readonly sleep: (options: SleepOptions) => Effect.Effect; + /** A durable timer to an absolute instant (no-op when already past); + * engines share the timestamp rule of `sleepUntilTarget`. @since 0.4.0 */ + readonly sleepUntil: (options: SleepUntilOptions) => Effect.Effect; + /** End this run and start a fresh one of `workflow` with `payload` (the + * DECODED payload — engines encode it through the workflow's own + * schema). @since 0.4.0 */ + readonly continueAsNew: ( + workflow: Workflow.Any, + payload: unknown, + options?: ContinueAsNewOptions, + ) => Effect.Effect; + /** Start a child workflow with the DECODED payload: awaited (`discard: + * false`) the child's typed exit lands in the channels; discarded the + * child's execution id is returned and it outlives the parent. + * @since 0.4.0 */ + readonly executeChild: ( + workflow: Workflow.Any, + payload: unknown, + options: { readonly discard: boolean }, + ) => Effect.Effect; } /** @@ -103,12 +288,15 @@ export class WorkflowOps extends Context.Service(f: (runtime: WorkflowOpsRuntime) => Effect.Effect) => + Effect.flatMap(WorkflowOps, f); + // ─── Activities ────────────────────────────────────────────────────────────── /** * A declared activity: callable with its typed payload inside any workflow - * handler, and carrying the underlying `TypedActivity` for worker binding - * (`implementActivities` + `handle`) and wire identity. + * handler, and carrying the underlying `TypedActivity` projection for + * worker binding (`implementActivities` + `handle`) and wire identity. * * @since 0.3.0 * @category models @@ -118,7 +306,7 @@ export interface DefinedActivity< Payload extends Schema.Top, Success extends Schema.Top, Error extends Schema.Top, -> extends TypedActivity.TypedActivity { +> extends TypedActivity { (payload: Payload["Type"]): Effect.Effect; } @@ -140,7 +328,7 @@ export const defineActivity = < readonly payload: Payload; readonly success?: Success; readonly error?: Error; - readonly options?: TypedActivity.TypedActivityOptions; + readonly options?: TypedActivityOptions; }, ): DefinedActivity< Name, @@ -148,11 +336,8 @@ export const defineActivity = < Success, Error > => { - // The generic bounds, defaults, and Struct.Fields conditional above - // mirror TypedActivity.make — keep the two in sync. - const activity = TypedActivity.make(name, decl); - const call = (payload: unknown) => - Effect.flatMap(WorkflowOps, (runtime) => runtime.activity(activity, payload)); + const activity = makeTypedActivity(name, decl); + const call = (payload: unknown) => withOps((runtime) => runtime.activity(activity, payload)); // defineProperties, not Object.assign: a function's own `name` is // non-writable (assignment throws in strict mode) but configurable. // SAFETY: the callable narrows the runtime's unknown seam to the schemas @@ -162,10 +347,23 @@ export const defineActivity = < // ─── Message channels and state ────────────────────────────────────────────── +/** + * A declared deferred: `await` inside a handler; the underlying `deferred` + * is what the client half addresses (`wf.completeDeferred`, + * `wf.deferredState` accept the declaration directly). + * + * @since 0.4.0 + * @category models + */ +export interface DefinedDeferred { + readonly name: string; + readonly deferred: DurableDeferred.DurableDeferred; + readonly await: Effect.Effect; +} + /** * A one-shot typed completion an outside party resolves. `await` blocks - * durably inside a handler; the underlying `deferred` drives the client - * side (`DurableDeferred.done`, `deferredState`). + * durably inside a handler. * * @since 0.3.0 * @category constructors @@ -173,20 +371,37 @@ export const defineActivity = < export const defineDeferred = ( name: string, decl: { readonly success: Success }, -) => { - const deferred = DurableDeferred.make(name, { success: decl.success }); +): DefinedDeferred => { + const deferred = DurableDeferred.make(name, { success: decl.success }); return { name, deferred, - await: Effect.flatMap(WorkflowOps, (runtime) => - runtime.deferredAwait(deferred), - ) as Effect.Effect, - } as const; + // SAFETY: the seam hands back the value the engine decoded through this + // very deferred's success schema. + await: withOps((runtime) => runtime.deferredAwait(deferred)) as Effect.Effect< + Success["Type"], + never, + WorkflowOps + >, + }; }; /** - * A durable inbound message queue. `take`/`poll` consume inside a handler; - * the underlying `mailbox` drives the client side (`offerMailbox`). + * A declared mailbox: `take`/`poll` inside a handler; client-side offers + * (`wf.offerMailbox`, `offerMailbox`) accept the declaration directly. + * + * @since 0.4.0 + * @category models + */ +export interface DefinedMailbox { + readonly name: string; + readonly mailbox: DurableMailbox.DurableMailbox; + readonly take: Effect.Effect; + readonly poll: Effect.Effect, never, WorkflowOps>; +} + +/** + * A durable inbound message queue. `take`/`poll` consume inside a handler. * * @since 0.3.0 * @category constructors @@ -194,30 +409,50 @@ export const defineDeferred = ( export const defineMailbox = ( name: string, decl: { readonly payload: Payload }, -) => { +): DefinedMailbox => { const mailbox = DurableMailbox.make(name, { payload: decl.payload }); - const withRuntime = (f: (runtime: WorkflowOpsRuntime) => Effect.Effect) => - Effect.flatMap(WorkflowOps, f); return { name, mailbox, - take: withRuntime((runtime) => runtime.mailboxTake(mailbox)) as Effect.Effect< + // SAFETY: the seam decodes through this mailbox's own payload schema. + take: withOps((runtime) => runtime.mailboxTake(mailbox)) as Effect.Effect< Payload["Type"], never, WorkflowOps >, - poll: withRuntime((runtime) => runtime.mailboxPoll(mailbox)) as Effect.Effect< + poll: withOps((runtime) => runtime.mailboxPoll(mailbox)) as Effect.Effect< Option.Option, never, WorkflowOps >, - } as const; + }; }; +/** + * A declared update: `take` inside a handler (respond exactly once); + * client-side requests (`wf.executeUpdate`, `executeUpdate`) accept the + * declaration directly. + * + * @since 0.4.0 + * @category models + */ +export interface DefinedUpdate< + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> { + readonly name: string; + readonly update: DurableUpdate.DurableUpdate; + readonly take: Effect.Effect< + UpdateRequest, + never, + WorkflowOps + >; +} + /** * Request/response into a running workflow with typed channels. `take` - * consumes requests inside a handler (respond exactly once); the underlying - * `update` drives the client side (`executeUpdate`). + * consumes requests inside a handler (respond exactly once). * * @since 0.3.0 * @category constructors @@ -229,20 +464,36 @@ export const defineUpdate = < >( name: string, decl: { readonly payload: Payload; readonly success: Success; readonly error: Error }, -) => { +): DefinedUpdate => { const update = DurableUpdate.make(name, decl); return { name, update, - take: Effect.flatMap(WorkflowOps, (runtime) => - runtime.updateTake(update), - ) as Effect.Effect, never, WorkflowOps>, - } as const; + // SAFETY: the seam decodes the payload and encodes the response through + // this update's own schemas. + take: withOps((runtime) => runtime.updateTake(update)) as Effect.Effect< + UpdateRequest, + never, + WorkflowOps + >, + }; }; /** - * Observable workflow state. `set` publishes inside a handler; the - * underlying `cell` drives the client side (`readStateCell`). + * A declared state cell: `set` inside a handler; client-side reads + * (`wf.readStateCell`, `readStateCell`) accept the declaration directly. + * + * @since 0.4.0 + * @category models + */ +export interface DefinedState { + readonly name: string; + readonly cell: StateCell.StateCell; + readonly set: (value: Value["Type"]) => Effect.Effect; +} + +/** + * Observable workflow state. `set` publishes inside a handler. * * @since 0.3.0 * @category constructors @@ -250,29 +501,294 @@ export const defineUpdate = < export const defineState = ( name: string, decl: { readonly value: Value }, -) => { +): DefinedState => { const cell = StateCell.make(name, { value: decl.value }); return { name, cell, - set: (value: Value["Type"]): Effect.Effect => - Effect.flatMap(WorkflowOps, (runtime) => runtime.stateSet(cell, value)), - } as const; + set: (value) => withOps((runtime) => runtime.stateSet(cell, value)), + }; }; +// ─── Addressing declarations from the client half ──────────────────────────── + +/** + * A mailbox as either its declaration or its underlying primitive — what + * every client-side offer accepts. + * + * @since 0.4.0 + * @category models + */ +export type MailboxLike = + | DurableMailbox.DurableMailbox + | { readonly mailbox: DurableMailbox.DurableMailbox }; + +/** + * An update as either its declaration or its underlying primitive. + * + * @since 0.4.0 + * @category models + */ +export type UpdateLike

= + | DurableUpdate.DurableUpdate + | { readonly update: DurableUpdate.DurableUpdate }; + +/** + * A state cell as either its declaration or its underlying primitive. + * + * @since 0.4.0 + * @category models + */ +export type StateCellLike = + | StateCell.StateCell + | { readonly cell: StateCell.StateCell }; + +/** + * A deferred as either its declaration or its underlying primitive. + * + * @since 0.4.0 + * @category models + */ +export type DeferredLike = + | DurableDeferred.DurableDeferred + | { readonly deferred: DurableDeferred.DurableDeferred }; + +/** + * The primitive behind a mailbox-like value. + * + * @since 0.4.0 + * @category utils + */ +export const toMailbox = ( + mailbox: MailboxLike, +): DurableMailbox.DurableMailbox => ("mailbox" in mailbox ? mailbox.mailbox : mailbox); + +/** + * The primitive behind an update-like value. + * + * @since 0.4.0 + * @category utils + */ +export const toUpdate =

( + update: UpdateLike, +): DurableUpdate.DurableUpdate => ("update" in update ? update.update : update); + +/** + * The primitive behind a state-cell-like value. + * + * @since 0.4.0 + * @category utils + */ +export const toStateCell = (cell: StateCellLike): StateCell.StateCell => + "cell" in cell ? cell.cell : cell; + +/** + * The primitive behind a deferred-like value. + * + * @since 0.4.0 + * @category utils + */ +export const toDeferred = ( + deferred: DeferredLike, +): DurableDeferred.DurableDeferred => + "deferred" in deferred ? deferred.deferred : deferred; + +// ─── Timers ────────────────────────────────────────────────────────────────── + +/** + * Sleep durably for `duration`. Names must be unique per sleep within a run + * (suffix loop iterations). On Temporal this is a real timer — no worker + * resources while waiting, survives restarts; in the in-memory runtime it + * follows Effect's `Clock`, so a `TestClock` can `adjust` past it. + * + * @since 0.4.0 + * @category timers + */ +export const sleep = (options: SleepOptions): Effect.Effect => + withOps((runtime) => runtime.sleep(options)); + +/** + * Sleep durably until an absolute time, no-op when it is already past. The + * target is read against the engine's deterministic clock, so the delay is + * stable on replay. Zone-less date-time strings and unparseable timestamps + * die loudly (see `sleepUntilTarget`). + * + * @since 0.4.0 + * @category timers + */ +export const sleepUntil = (options: SleepUntilOptions): Effect.Effect => + withOps((runtime) => runtime.sleepUntil(options)); + +/** + * The one timestamp rule every engine's `sleepUntil` applies: epoch millis + * pass through; a date-time string must carry its zone (`Z` or an explicit + * offset — date-only forms are UTC per ECMAScript); anything unparseable is + * a defect. Zone-less strings would parse in the worker's LOCAL timezone, + * which differs across workers and replays. + * + * @since 0.4.0 + * @category timers + */ +export const sleepUntilTarget = (options: SleepUntilOptions): Effect.Effect => + Effect.suspend(() => { + if ( + typeof options.timestamp === "string" && + options.timestamp.includes("T") && + !/(?:Z|[+-]\d{2}:?\d{2})$/.test(options.timestamp) + ) { + return Effect.die( + `sleepUntil "${options.name}": date-time string "${options.timestamp}" has no timezone — zone-less strings parse in the worker's LOCAL timezone, which differs across workers and replays. Add "Z" or an explicit offset, or pass epoch millis.`, + ); + } + const target = + typeof options.timestamp === "number" ? options.timestamp : Date.parse(options.timestamp); + if (Number.isNaN(target)) { + return Effect.die( + `sleepUntil "${options.name}": unparseable timestamp "${String(options.timestamp)}"`, + ); + } + return Effect.succeed(target); + }); + +// ─── Run composition ───────────────────────────────────────────────────────── + +/** + * End this run and atomically start a fresh one of the SAME workflow with + * `payload`, keeping the workflow id while resetting history — Temporal's + * continue-as-new, for unbounded workflows. Type `Effect`: nothing + * runs after it. Like the native API it unwinds the run — finalizers and + * `Workflow.withCompensation` steps fire on the way out — so call it at + * iteration boundaries, outside compensation regions, after draining + * mailboxes (buffered messages do not carry into the new run). + * + * In the in-memory runtime the handler fiber is interrupted and the world + * records the continuation (`world.continuedAsNew`), payload round-tripped + * through the workflow's schema. + * + * @since 0.4.0 + * @category run + */ +export const continueAsNew = < + Tag extends string, + Payload extends Workflow.AnyStructSchema, + Success extends Schema.Top, + Error extends Schema.Top, +>( + workflow: Workflow.Workflow, + payload: Payload["Type"], + options?: ContinueAsNewOptions, +): Effect.Effect => + withOps((runtime) => runtime.continueAsNew(workflow, payload, options)); + +/** + * Options for `executeChild`. + * + * @since 0.4.0 + * @category models + */ +export interface ExecuteChildOptions { + /** `true`: start the child and return its execution id without awaiting; + * the child outlives the parent (ABANDON). Default: await the child's + * typed result (REQUEST_CANCEL on parent close). */ + readonly discard?: Discard; +} + +/** + * Start a child workflow from a handler — the engine-agnostic form of + * `Child.execute(payload)` inside a workflow body. Both must be hosted by + * the same bundle. The child's workflow id is its digest execution id, so + * the idempotency contract stays global: an id already taken attaches and + * returns that execution's result. Typed results and failures compose into + * the parent's channels; `{ discard: true }` returns the execution id. + * + * @since 0.4.0 + * @category run + */ +export const executeChild = < + Tag extends string, + Payload extends Workflow.AnyStructSchema, + Success extends Schema.Top, + Error extends Schema.Top, + const Discard extends boolean = false, +>( + workflow: Workflow.Workflow, + payload: Payload["Type"], + options?: ExecuteChildOptions, +): Effect.Effect< + Discard extends true ? string : Success["Type"], + Discard extends true ? never : Error["Type"], + WorkflowOps +> => + // SAFETY: the seam returns the child's exit decoded through the child's + // own success/error schemas (or its execution id when discarded). + withOps((runtime) => + runtime.executeChild(workflow, payload, { discard: options?.discard === true }), + ) as never; + +// ─── Versioning ────────────────────────────────────────────────────────────── + /** * Patch-marker version selection at a code site (see the versioning * guide): the newest name on fresh executions, the recorded name on * replays. Engines without replay always answer the newest. * * @since 0.3.0 - * @category combinators + * @category versioning */ export const version = ( site: string, names: Names, ): Effect.Effect => - Effect.flatMap(WorkflowOps, (runtime) => runtime.version(site, names)); + withOps((runtime) => runtime.version(site, names)); + +/** + * The run-table form of `version`: one effect per version name, keyed + * OLDEST FIRST — the key order IS the chain order (the first key is the + * original, unguarded behavior; each later key is guarded by its own + * marker `${site}-${name}`). The selected case runs; result, error, and + * service channels are unioned across cases. + * + * ```ts + * const greeting = yield* versioned("greeting", { + * v1: greetV1, + * v2: greetV2, + * }); + * ``` + * + * Evaluate at a deterministic point on the main workflow fiber, never + * inside forks or races (the `versioning-on-main-fiber` lint rule checks + * this call too). + * + * @since 0.4.0 + * @category versioning + */ +export const versioned = < + const Cases extends { readonly [name: string]: Effect.Effect }, +>( + site: string, + cases: Cases, +): Effect.Effect< + Effect.Success, + Effect.Error, + Effect.Services | WorkflowOps +> => { + const names = Object.keys(cases); + const first = names[0]; + if (first === undefined) { + return Effect.die(`versioned "${site}": at least one case is required`); + } + // SAFETY: `version` answers one of exactly these keys, so the lookup + // always hits, and the matched case's channels are covered by the union + // over `Cases[keyof Cases]`. + return Effect.flatMap( + version(site, [first, ...names.slice(1)]), + (name) => cases[name]!, + ) as Effect.Effect< + Effect.Success, + Effect.Error, + Effect.Services | WorkflowOps + >; +}; // ─── Schema evolution ──────────────────────────────────────────────────────── diff --git a/src/engine-client.ts b/src/engine-client.ts index 824d272..d737817 100644 --- a/src/engine-client.ts +++ b/src/engine-client.ts @@ -24,7 +24,6 @@ import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; import * as WorkflowEngine from "effect/unstable/workflow/WorkflowEngine"; import { @@ -44,15 +43,20 @@ import { encodeDeferredExit, wireCodecsFor, } from "./wire.js"; -import { MAILBOX_SIGNAL, mailboxCodec, type DurableMailbox } from "./mailbox.js"; +import { MAILBOX_SIGNAL, mailboxCodec } from "./mailbox.js"; import { classifyThrown } from "./thrown.js"; -import { STATE_CELL_QUERY, stateCellCodec, type StateCell } from "./state-cell.js"; +import { STATE_CELL_QUERY, stateCellCodec } from "./state-cell.js"; +import { updateCodec, WORKFLOW_UPDATE, type WorkflowUpdatePayload } from "./update.js"; import { - updateCodec, - WORKFLOW_UPDATE, - type DurableUpdate, - type WorkflowUpdatePayload, -} from "./update.js"; + toDeferred, + toMailbox, + toStateCell, + toUpdate, + type DeferredLike, + type MailboxLike, + type StateCellLike, + type UpdateLike, +} from "./definition.js"; /** * What the client engine is configured with: the Temporal client and the @@ -275,19 +279,21 @@ export const createWorkflowSchedule = < /** * Offer a message to a running workflow's mailbox. Offering to a closed or * unknown execution is a no-op — the workflow finishing first is a normal - * race, matching `DurableDeferred.done`. + * race, matching `DurableDeferred.done`. Accepts the declaration + * (`Priority`) or its underlying primitive (`Priority.mailbox`). * * @since 0.1.0 * @category client */ export const offerMailbox = ( - mailbox: DurableMailbox, + mailboxLike: MailboxLike, options: { readonly client: Client; readonly workflowId: string; readonly payload: S["Type"]; }, ): Effect.Effect => { + const mailbox = toMailbox(mailboxLike); const wire = mailboxCodec(mailbox).encode(options.payload); return Effect.promise(async () => { try { @@ -305,19 +311,21 @@ export const offerMailbox = ( * Send an update request to a running workflow and receive its typed * response: the workflow's `respond` exit lands in this effect's * success/error channels. Unlike mailbox offers, an update expects an - * answer, so an unknown execution is a defect rather than a no-op. + * answer, so an unknown execution is a defect rather than a no-op. Accepts + * the declaration (`SetAmount`) or its underlying primitive. * * @since 0.1.0 * @category client */ export const executeUpdate =

( - update: DurableUpdate, + updateLike: UpdateLike, options: { readonly client: Client; readonly workflowId: string; readonly payload: P["Type"]; }, ): Effect.Effect => { + const update = toUpdate(updateLike); const codecs = updateCodec(update); const wire = codecs.encodePayload(options.payload); return Effect.flatMap( @@ -355,19 +363,21 @@ export const executeUpdate =

( - cell: StateCell, + cellLike: StateCellLike, options: { readonly client: Client; readonly workflowId: string; }, -): Effect.Effect> => - Effect.promise(async () => { +): Effect.Effect> => { + const cell = toStateCell(cellLike); + return Effect.promise(async () => { let wire: unknown; try { wire = await options.client.workflow @@ -380,23 +390,26 @@ export const readStateCell = ( if (wire === null || wire === undefined) return Option.none(); return Option.some(stateCellCodec(cell).decode(wire)); }); +}; /** * Read a deferred's current state from outside the workflow: `None` while * the execution is unknown or the deferred unresolved, `Some(typed exit)` * once completed. Backed by a Temporal query, so it never perturbs the - * signal path. + * signal path. Accepts the declaration (`Approval`) or its underlying + * primitive. * * @since 0.1.0 * @category client */ export const deferredState = ( - deferred: DurableDeferred.DurableDeferred, + deferredLike: DeferredLike, options: { readonly client: Client; readonly workflowId: string; }, ): Effect.Effect>> => { + const deferred = toDeferred(deferredLike); const decodeExit = Schema.decodeSync(asJsonCodec(deferred.exitSchema)); return Effect.promise(async () => { const handle = options.client.workflow.getHandle(options.workflowId); @@ -418,6 +431,42 @@ export const deferredState = ( + deferredLike: DeferredLike, + options: { + readonly client: Client; + readonly workflowId: string; + readonly exit: Exit.Exit; + }, +): Effect.Effect => { + const deferred = toDeferred(deferredLike); + // The leaves first (the deferred's own exit schema — what makeUnsafe's + // `deferredDone` produces), then the structure (the signal crossing). + const leaves = Schema.encodeSync(asJsonCodec(deferred.exitSchema))(options.exit); + const wire = encodeDeferredExit(leaves as Exit.Exit); + return Effect.promise(async () => { + try { + await options.client.workflow.getHandle(options.workflowId).signal(DEFERRED_DONE_SIGNAL, { + deferredName: deferred.name, + exit: wire, + }); + } catch (error) { + if (!(error instanceof WorkflowNotFoundError)) throw error; + } + }); +}; + /** * Layer form, for composition into an app runtime. * diff --git a/src/engine-sandbox.ts b/src/engine-sandbox.ts index 78b2c4e..a227fff 100644 --- a/src/engine-sandbox.ts +++ b/src/engine-sandbox.ts @@ -1,8 +1,24 @@ /** - * 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. + * Sandbox half — engine machinery. Applications import `workflowBundle` + * from the `bundle` module (the one file the Temporal worker points at) and + * author handlers with the `definition` module; nothing else here is part of + * the declaration surface: + * + * - **Engine-level, kept:** `callRawActivity` (invoke a foreign Temporal + * activity proxy cancellably), `offerMailbox` (workflow → workflow), + * `callNexusWorkflowOperation`, `SandboxRun`. These have no + * engine-agnostic counterpart and are for code that deliberately steps + * below the declaration surface. + * - **Deprecated, removed in 0.5.0:** the per-primitive calls + * (`callActivity`, `takeMailbox`, `pollMailbox`, `takeUpdate`, + * `setStateCell`), `sleepUntil`, `continueAsNew`, and the `UpdateRequest` + * alias — each has a `definition` replacement named in its JSDoc. They + * remain the machinery the Temporal `WorkflowOps` runtime dispatches into. + * + * `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 @@ -67,7 +83,9 @@ import { workflowInfo, } from "@temporalio/workflow"; import { + ACTIVITY_EXIT_TYPE, asJsonCodec, + codecsFor, DEFERRED_DONE_SIGNAL, DEFERRED_STATE_QUERY, decodeDeferredExit, @@ -81,14 +99,6 @@ import { import * as Clock from "effect/Clock"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; import { ensureSandboxPolyfills } from "./sandbox-polyfills.js"; -import { - ACTIVITY_EXIT_TYPE, - codecsFor, - type AnyTypedActivity, - type ErrorOf, - type PayloadOf, - type SuccessOf, -} from "./typed-activity.js"; import { MAILBOX_SIGNAL, mailboxCodec, @@ -98,7 +108,14 @@ import { import { STATE_CELL_QUERY, stateCellCodec, type StateCell } from "./state-cell.js"; import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import { + sleepUntilTarget, + toMailbox, WorkflowOps, + type MailboxLike, + type AnyTypedActivity, + type ErrorOf, + type PayloadOf, + type SuccessOf, type UpdateRequest as DefUpdateRequest, type WorkflowOpsRuntime, } from "./definition.js"; @@ -195,6 +212,10 @@ const SandboxRunTag = Context.Service( * activity is not cancelled on interrupt — it runs to completion * server-side, abandoned by the closing run. * + * **Engine-level.** Kept (not deprecated): the escape hatch for activities + * you do not own the declaration of. Your own activities are + * `defineActivity` calls. + * * @since 0.1.0 * @category workflow */ @@ -290,8 +311,11 @@ export const callActivity = ( /** * Sleep durably until an absolute time, no-op when it is already past. The * target is read against the sandbox's deterministic clock, so the delay is - * stable on replay. + * stable on replay. The timestamp rule is `sleepUntilTarget` (definition): + * zone-less date-time strings and unparseable timestamps die loudly. * + * @deprecated Use `sleepUntil` from `definition` — same timer, same rule, + * runs on any engine. Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -305,22 +329,7 @@ export const sleepUntil = (options: { }) => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - if ( - typeof options.timestamp === "string" && - options.timestamp.includes("T") && - !/(?:Z|[+-]\d{2}:?\d{2})$/.test(options.timestamp) - ) { - return yield* Effect.die( - `sleepUntil "${options.name}": date-time string "${options.timestamp}" has no timezone — zone-less strings parse in the worker's LOCAL timezone, which differs across workers and replays. Add "Z" or an explicit offset, or pass epoch millis.`, - ); - } - const target = - typeof options.timestamp === "number" ? options.timestamp : Date.parse(options.timestamp); - if (Number.isNaN(target)) { - return yield* Effect.die( - `sleepUntil "${options.name}": unparseable timestamp "${String(options.timestamp)}"`, - ); - } + const target = yield* sleepUntilTarget(options); const delay = target - now; if (delay > 0) { yield* DurableClock.sleep({ name: options.name, duration: Duration.millis(delay) }); @@ -337,10 +346,12 @@ const updateBuffer = (run: RunState, name: string): PendingUpdate[] => { }; /** - * A taken update request: the decoded payload and the one-shot typed - * response channel the caller is blocked on. Respond before the run ends — - * an unanswered update fails when the workflow completes. + * A taken update request — the LEGACY spelling with type parameters in + * `` order. * + * @deprecated Use `UpdateRequest` from + * `definition` (Type-level parameters, in declaration order). Removed in + * 0.5.0. * @since 0.1.0 * @category models */ @@ -354,6 +365,8 @@ export type UpdateRequest = DefUp * Durably await the next `executeUpdate` request for `update`, in delivery * order. The claim is synchronous on the taking fiber, like `takeMailbox`. * + * @deprecated Use the declaration's `SetAmount.take` (`defineUpdate` from + * `definition`). Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -413,6 +426,8 @@ const mailboxBuffer = (run: RunState, name: string): unknown[] => { * The claim happens synchronously on the taking fiber after the wait, so an * interrupted take never steals a message from a later one. * + * @deprecated Use the declaration's `Priority.take` (`defineMailbox` from + * `definition`). Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -451,6 +466,8 @@ export const takeMailbox = ( * canonical use is draining reports into carried state before * `continueAsNew`, since buffered messages do not survive the run change. * + * @deprecated Use the declaration's `Priority.poll` (`defineMailbox` from + * `definition`). Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -479,14 +496,19 @@ export const pollMailbox = ( * Offer a message to ANOTHER workflow's mailbox (workflow → workflow). * Offering to a closed or unknown execution is a no-op — the receiver * finishing first is a normal race, matching `DurableDeferred.done`. + * Accepts the declaration (`Reports`) or its underlying primitive. + * + * **Engine-level.** Kept (not deprecated): it has no engine-agnostic + * counterpart yet, and is Temporal-only by nature. * * @since 0.1.0 * @category workflow */ export const offerMailbox = ( - mailbox: DurableMailbox, + mailboxLike: MailboxLike, options: { readonly workflowId: string; readonly payload: S["Type"] }, ): Effect.Effect => { + const mailbox = toMailbox(mailboxLike); const wire = mailboxCodec(mailbox).encode(options.payload); return Effect.promise(async () => { try { @@ -509,6 +531,8 @@ export const offerMailbox = ( * outside via `readStateCell` (engine-client), including after the run * closes. * + * @deprecated Use the declaration's `Status.set(value)` (`defineState` from + * `definition`). Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -532,6 +556,8 @@ export const setStateCell = ( * messages buffered but not yet taken do not carry into the new run — drain * before continuing. * + * @deprecated Use `continueAsNew` from `definition` — same semantics on + * Temporal, observable in the in-memory runtime. Removed in 0.5.0. * @since 0.1.0 * @category workflow */ @@ -544,6 +570,15 @@ export const continueAsNew = < workflow: Workflow.Workflow, payload: Payload["Type"], options?: { readonly memo?: Record }, +): Effect.Effect => continueAsNewDecoded(workflow, payload, options); + +/** The engine-level continue-as-new: encode the DECODED payload through the + * workflow's own codec (a schema-invalid payload dies here, as it would for + * a client start) and hand the wire to Temporal. */ +const continueAsNewDecoded = ( + workflow: Workflow.Any, + payload: unknown, + options?: { readonly memo?: Record }, ): Effect.Effect => { const wire = wireCodecsFor(workflow).encodePayload(payload); if (options?.memo !== undefined) { @@ -581,6 +616,9 @@ export interface NexusOperationClient { * Operations NOT backed by a shim workflow are plain workflow-API promises: * call them with `callRawActivity` directly. * + * **Engine-level.** Kept (not deprecated): Nexus has no engine-agnostic + * counterpart yet. + * * @since 0.1.0 * @category workflow */ @@ -1101,6 +1139,15 @@ const temporalWorkflowOps: WorkflowOpsRuntime = { updateTake: (update) => eraseR(takeUpdate(update)), stateSet: (cell, value) => eraseR(setStateCell(cell, value)), version: (site, names) => Versioning.version(site, names), + sleep: (options) => eraseR(DurableClock.sleep(options)), + sleepUntil: (options) => eraseR(sleepUntil(options)), + continueAsNew: (workflow, payload, options) => continueAsNewDecoded(workflow, payload, options), + // The child path IS upstream `execute` under the sandbox engine — + // `startChild` with the digest execution id, REQUEST_CANCEL/ABANDON by + // `discard`, attach-on-taken — so the recorded commands are identical to + // a handler calling `Child.execute(payload)` directly. + executeChild: (workflow, payload, { discard }) => + eraseR((workflow as Workflow.AnyWithProps).execute(payload, { discard })), }; /** diff --git a/src/lint.js b/src/lint.js index 66a1267..71024e1 100644 --- a/src/lint.js +++ b/src/lint.js @@ -8,9 +8,63 @@ // "rules": { "effect-temporal/zero-arity-effect-promise": "error", ... } } const isSandboxSource = (source) => - source === "@temporalio/workflow" || source.endsWith("/engine-sandbox"); + source === "@temporalio/workflow" || + source.endsWith("/engine-sandbox") || + source.endsWith("/engine-sandbox.js") || + source.endsWith("/bundle") || + source.endsWith("/bundle.js"); const isClientSource = (source) => - source === "@temporalio/client" || source.endsWith("/engine-client"); + source === "@temporalio/client" || + source.endsWith("/engine-client") || + source.endsWith("/engine-client.js"); + +/** Which package module an import source names, ignoring the `.js` suffix + * and whether it is the published specifier or a relative path. */ +const moduleOf = (source) => { + if (typeof source !== "string") return undefined; + // Only THIS package's modules — by published specifier or relative path — + // so an unrelated `some-lib/update` is never mistaken for ours. + const match = + /(?:^|\/)(?:effect-temporal|\.|\.\.)\/(engine-sandbox|typed-activity|versioning|mailbox|update|state-cell|definition)(?:\.js)?$/.exec( + source, + ); + return match?.[1]; +}; + +/** The deprecated authoring surface (removed in 0.5.0) and what replaces + * each symbol. `*` covers the module's namespace import and any named + * import not listed individually. */ +const DEPRECATED = { + "engine-sandbox": { + callActivity: "call the declared activity directly (`yield* Charge(payload)`, from `defineActivity` in `definition`)", + takeMailbox: "the declaration's `.take` (`defineMailbox` in `definition`)", + pollMailbox: "the declaration's `.poll` (`defineMailbox` in `definition`)", + takeUpdate: "the declaration's `.take` (`defineUpdate` in `definition`)", + setStateCell: "the declaration's `.set` (`defineState` in `definition`)", + sleepUntil: "`sleepUntil` from `definition`", + continueAsNew: "`continueAsNew` from `definition`", + UpdateRequest: "`UpdateRequest` from `definition`", + }, + "typed-activity": { + make: "`defineActivity` from `definition`", + codecsFor: "`codecsFor` from `wire`", + ACTIVITY_EXIT_TYPE: "`ACTIVITY_EXIT_TYPE` from `wire`", + TypedActivityCodecs: "`TypedActivityCodecs` from `wire`", + "*": "the same name from `definition` (`defineActivity`, `PayloadOf`, `SuccessOf`, `ErrorOf`, `AnyTypedActivity`, ...)", + }, + versioning: { + match: "`versioned(site, { v1: run1, v2: run2 })` from `definition`", + version: "`version(site, names)` from `definition`", + "*": "`version` / `versioned` from `definition`", + }, + mailbox: { + make: "`defineMailbox` from `definition`", + MAILBOX_SIGNAL: "the fake client's `offer` / `offersTo` (testing) — no wire constant needed", + "*": "`defineMailbox` from `definition`", + }, + update: { make: "`defineUpdate` from `definition`" }, + "state-cell": { make: "`defineState` from `definition`" }, +}; const isEffectCall = (node, names) => node.callee.type === "MemberExpression" && @@ -176,20 +230,22 @@ const rules = { ...sandboxRule((_context, state) => { const FORKING = ["forkChild", "forkDetach", "raceFirst", "race", "raceAll", "all"]; let forkDepth = 0; - let definedVersionLocal = null; + // Local names of the definition module's `version` / `versioned` + // (alias-aware); the bare names when not imported explicitly. + const definedVersionLocals = new Set(); return { // Definition-authored handler modules import no engine module on - // purpose — importing `version` from the definition module is what - // marks the file as workflow code for THIS rule (alias-aware). + // purpose — importing `version` or `versioned` from the definition + // module is what marks the file as workflow code for THIS rule. ImportDeclaration(node) { - const source = node.source.value; - if ( - typeof source === "string" && - (source.endsWith("/definition") || source.endsWith("/definition.js")) - ) { + if (moduleOf(node.source.value) === "definition") { for (const specifier of node.specifiers ?? []) { - if (specifier.type === "ImportSpecifier" && specifier.imported?.name === "version") { - definedVersionLocal = specifier.local.name; + if ( + specifier.type === "ImportSpecifier" && + (specifier.imported?.name === "version" || + specifier.imported?.name === "versioned") + ) { + definedVersionLocals.add(specifier.local.name); state.sandbox = true; } } @@ -204,10 +260,13 @@ const rules = { node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && node.callee.object.name === "Versioning"; - // The definition module's bare `version(site, names)` call. + // The definition module's bare `version(site, names)` / + // `versioned(site, cases)` calls. const isDefinedVersion = node.callee.type === "Identifier" && - node.callee.name === (definedVersionLocal ?? "version"); + (definedVersionLocals.size > 0 + ? definedVersionLocals.has(node.callee.name) + : node.callee.name === "version" || node.callee.name === "versioned"); if (forkDepth > 0 && (isVersioningMember || isDefinedVersion)) { state.reports.push({ node, messageId: "fiber" }); } @@ -218,9 +277,56 @@ const rules = { }; }), }, + + "prefer-definition": { + meta: { + type: "problem", + docs: { + description: + "Report imports of the deprecated authoring surface (engine-sandbox per-primitive " + + "calls, typed-activity, versioning, the mailbox/update/state-cell constructors) — " + + "each has a `definition`, `bundle`, or `wire` replacement, and the deprecated " + + "symbols are removed in 0.5.0.", + }, + messages: { + deprecated: "`{{name}}` from `{{module}}` is deprecated (removed in 0.5.0) — use {{replacement}}.", + }, + schema: [], + }, + create(context) { + return { + ImportDeclaration(node) { + const module = moduleOf(node.source.value); + const table = module === undefined ? undefined : DEPRECATED[module]; + if (table === undefined) return; + for (const specifier of node.specifiers ?? []) { + if (specifier.type === "ImportNamespaceSpecifier") { + if (table["*"] !== undefined) { + context.report({ + node: specifier, + messageId: "deprecated", + data: { name: `* as ${specifier.local.name}`, module, replacement: table["*"] }, + }); + } + continue; + } + if (specifier.type !== "ImportSpecifier") continue; + const imported = specifier.imported?.name ?? specifier.imported?.value; + const replacement = table[imported] ?? table["*"]; + if (replacement === undefined) continue; + context.report({ + node: specifier, + messageId: "deprecated", + data: { name: imported, module, replacement }, + }); + } + }, + }; + }, + }, }; export default { - meta: { name: "effect-temporal", version: "0.1.0" }, + meta: { name: "effect-temporal", version: "0.4.0" }, rules, }; diff --git a/src/mailbox.ts b/src/mailbox.ts index 79262bd..6089b9f 100644 --- a/src/mailbox.ts +++ b/src/mailbox.ts @@ -4,10 +4,10 @@ * signal (recorded in history, so consumption is deterministic on replay) * and buffer until the workflow takes them. * - * This module holds the shared definition and codec; the operations live - * with their process: `takeMailbox` / `offerMailbox` (workflow → workflow) - * in `engine-sandbox`, `offerMailbox` (client → workflow) in - * `engine-client`. + * This module holds the shared wire contract (signal name, definition + * shape, codec) the engine halves consume. Applications declare mailboxes + * with `defineMailbox` from the `definition` module and never import this + * one; the `make` constructor here is deprecated. * * @since 0.1.0 */ @@ -48,9 +48,11 @@ export interface DurableMailbox { } /** - * Declare a mailbox: a name (unique within the workflows that use it) and - * the payload schema. Shared by the workflow body and every offering side. + * Declare a mailbox primitive (name + payload schema). * + * @deprecated Use `defineMailbox` from `definition` — its `.take`/`.poll` + * run on any engine and every client-side offer accepts the declaration + * directly. Removed in 0.5.0. * @since 0.1.0 * @category constructors */ diff --git a/src/state-cell.ts b/src/state-cell.ts index 1e59238..4507430 100644 --- a/src/state-cell.ts +++ b/src/state-cell.ts @@ -6,9 +6,10 @@ * * Temporal query handlers are synchronous and read-only, which is why the * abstraction is a published snapshot rather than an on-demand computation. - * This module holds the shared definition and codec; the operations live - * with their process: `setStateCell` in `engine-sandbox`, `readStateCell` - * in `engine-client`. + * This module holds the shared wire contract (query name, definition shape, + * codec) the engine halves consume. Applications declare cells with + * `defineState` from the `definition` module and never import this one; the + * `make` constructor here is deprecated. * * @since 0.1.0 */ @@ -38,9 +39,11 @@ export interface StateCell { } /** - * Declare a state cell: a name (unique within the workflows that use it) - * and the value schema. Shared by the workflow body and every reading side. + * Declare a state-cell primitive (name + value schema). * + * @deprecated Use `defineState` from `definition` — its `.set` runs on any + * engine and every client-side read accepts the declaration directly. + * Removed in 0.5.0. * @since 0.1.0 * @category constructors */ diff --git a/src/testing.ts b/src/testing.ts index 9b9bc6f..16f2435 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -15,7 +15,9 @@ */ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -26,10 +28,21 @@ import type * as Workflow from "effect/unstable/workflow/Workflow"; import type { ActivityRunner, BoundActivity } from "./activities.js"; import { makeWorkflowClient, type WorkflowStartOptions } from "./client.js"; import { WorkflowExecutionAlreadyStartedError, type Client } from "@temporalio/client"; -import type { PayloadOf, SuccessOf } from "./client.js"; -import { WorkflowOps, type UpdateRequest, type WorkflowOpsRuntime } from "./definition.js"; -import { codecsFor } from "./typed-activity.js"; -import { wireCodecsFor, wireValueCodec } from "./wire.js"; +import type { ErrorOf, PayloadOf, SuccessOf } from "./client.js"; +import { + sleepUntilTarget, + toMailbox, + WorkflowOps, + type DeferredLike, + type MailboxLike, + type StateCellLike, + type UpdateLike, + type UpdateRequest, + type WorkflowOpsRuntime, +} from "./definition.js"; +import { offerMailbox } from "./engine-client.js"; +import { MAILBOX_SIGNAL, mailboxCodec, type MailboxSignalPayload } from "./mailbox.js"; +import { codecsFor, wireCodecsFor, wireValueCodec } from "./wire.js"; /** * One recorded `workflow.start` call, as the fake captured it. @@ -95,6 +108,18 @@ export interface FakeTemporalClientOptions { readonly result?: (start: RecordedWorkflowStart) => unknown; } +/** + * One mailbox offer the fake captured, decoded through the mailbox's own + * payload schema. + * + * @since 0.4.0 + * @category models + */ +export interface RecordedMailboxOffer

{ + readonly workflowId: string; + readonly payload: P; +} + /** * The fake: a `Client`-shaped double plus the typed records of everything * that was started, signalled, or terminated through it. @@ -109,6 +134,21 @@ export interface FakeTemporalClient { readonly starts: ReadonlyArray; readonly signals: ReadonlyArray; readonly terminations: ReadonlyArray; + /** Offer to a declared mailbox THROUGH the fake, exactly as a client + * would — lands in `signals` and `offersTo`. Takes the declaration + * (`Priority`) or its underlying primitive; no wire constants needed. + * @since 0.4.0 */ + readonly offer: ( + mailbox: MailboxLike, + workflowId: string, + payload: S["Type"], + ) => Effect.Effect; + /** The offers recorded for one declared mailbox, payloads decoded + * through its schema — assert on domain values, not signal args. + * @since 0.4.0 */ + readonly offersTo: ( + mailbox: MailboxLike, + ) => ReadonlyArray>; } /** @@ -244,7 +284,26 @@ export const makeFakeTemporalClient = ( }, ) as unknown as Client; - return { client, starts, signals, terminations }; + const fake: FakeTemporalClient = { + client, + starts, + signals, + terminations, + offer: (mailbox, workflowId, payload) => offerMailbox(mailbox, { client, workflowId, payload }), + offersTo: (mailboxLike) => { + const mailbox = toMailbox(mailboxLike); + const decode = mailboxCodec(mailbox).decode; + return signals.flatMap((signal) => { + if (signal.signalName !== MAILBOX_SIGNAL) return []; + // SAFETY: every MAILBOX_SIGNAL the fake records was produced by + // `offerMailbox`, whose one argument is this payload shape. + const carried = signal.args[0] as MailboxSignalPayload | undefined; + if (carried === undefined || carried.mailboxName !== mailbox.name) return []; + return [{ workflowId: signal.workflowId, payload: decode(carried.payload) }]; + }); + }, + }; + return fake; }; /** @@ -333,6 +392,30 @@ export interface HarnessClient { payload: P["Type"], options?: WorkflowStartOptions, ): Promise; + /** Offer to a running workflow's declared mailbox. @since 0.4.0 */ + offer( + mailbox: MailboxLike, + workflowId: string, + payload: S["Type"], + ): Promise; + /** Send a declared update and await its typed response (typed failures + * reject). @since 0.4.0 */ + request

( + update: UpdateLike, + workflowId: string, + payload: P["Type"], + ): Promise; + /** Read a declared state cell's latest snapshot. @since 0.4.0 */ + stateOf( + cell: StateCellLike, + workflowId: string, + ): Promise>; + /** Complete a declared deferred with a success value. @since 0.4.0 */ + resolve( + deferred: DeferredLike, + workflowId: string, + value: Success["Type"], + ): Promise; readonly raw: Client; } @@ -404,6 +487,13 @@ export const startWorkflowTestHarness = async ( execute: (workflow, payload, opts) => Effect.runPromise(wf.execute(workflow, payload, opts)), start: (workflow, payload, opts) => Effect.runPromise(wf.start(workflow, payload, opts)), + offer: (mailbox, workflowId, payload) => + Effect.runPromise(wf.offerMailbox(mailbox, workflowId, payload)), + request: (update, workflowId, payload) => + Effect.runPromise(wf.executeUpdate(update, workflowId, payload)), + stateOf: (cell, workflowId) => Effect.runPromise(wf.readStateCell(cell, workflowId)), + resolve: (deferred, workflowId, value) => + Effect.runPromise(wf.completeDeferred(deferred, workflowId, Exit.succeed(value))), raw: env.client, }; return worker.runUntil(() => body(client, taskQueue)); @@ -413,6 +503,62 @@ export const startWorkflowTestHarness = async ( // ─── In-memory WorkflowOps ─────────────────────────────────────────────────── +/** + * One workflow definition paired with its handler, for the in-memory + * runtime to run as a CHILD when a handler calls `executeChild` — the + * workflow-side twin of `BoundActivity`. Produced by `handleWorkflow`. + * + * @since 0.4.0 + * @category models + */ +export interface BoundWorkflow { + readonly workflow: Workflow.Any; + readonly execute: ( + payload: never, + executionId: string, + ) => Effect.Effect; +} + +/** + * Bind a workflow definition to the handler the in-memory runtime should + * run when a parent calls `executeChild(workflow, payload)` — the same + * handler function `workflow.toLayer` hosts on Temporal: + * + * ```ts + * makeTestWorkflowOps({ workflows: [handleWorkflow(Child, childHandler)] }) + * ``` + * + * @since 0.4.0 + * @category constructors + */ +export const handleWorkflow = ( + workflow: W, + handler: ( + payload: PayloadOf, + executionId: string, + ) => Effect.Effect, ErrorOf, WorkflowOps>, +): BoundWorkflow => ({ + workflow, + // SAFETY: the runtime decodes the payload through this workflow's own + // codec before calling execute, so the value really is PayloadOf; the + // channels widen to unknown for the erased record. + execute: (payload, executionId) => handler(payload as PayloadOf, executionId), +}); + +/** + * What the in-memory runtime recorded when a handler called + * `continueAsNew`: the workflow and the payload the next run WOULD receive, + * round-tripped through the workflow's payload schema. + * + * @since 0.4.0 + * @category models + */ +export interface ContinuedAsNew { + readonly workflow: Workflow.Any; + readonly payload: unknown; + readonly memo: Record | undefined; +} + /** * A running in-memory world for handler unit tests: provide `layer` to a * workflow handler and drive its declared channels from the outside — @@ -424,6 +570,15 @@ export const startWorkflowTestHarness = async ( export interface TestWorkflowOps { /** Provides `WorkflowOps` backed by this world. */ readonly layer: Layer.Layer; + /** `Some` once a handler in this world called `continueAsNew` (its fiber + * was interrupted at that point); the payload is what the next run would + * decode. @since 0.4.0 */ + readonly continuedAsNew: Effect.Effect>; + /** `continuedAsNew`, typed against one workflow: `Some(payload)` when the + * recorded continuation is for `workflow`. @since 0.4.0 */ + readonly continuedAsNewOf: ( + workflow: W, + ) => Effect.Effect>>; /** Resolve a declared deferred, waking any handler blocked on `.await`. */ readonly resolve: ( deferred: { readonly deferred: object; readonly await: Effect.Effect }, @@ -472,14 +627,34 @@ export interface TestWorkflowOps { * one update dies (as the engine does), and `version` always answers the * newest name — there is no replay in memory. * + * Timers (`sleep`, `sleepUntil`) follow Effect's `Clock`: provide + * `TestClock.layer()` and `adjust` past them — they are NOT instant, so a + * mailbox-take racing a grace-period timer is testable in both orders. + * `continueAsNew` interrupts the handler fiber and records the continuation + * (`continuedAsNew`). `executeChild` runs the child's `handleWorkflow` + * binding in-process (payload, success, and error round-tripped through + * the child's schemas; a taken execution id attaches, as on Temporal); + * `{ discard: true }` forks it and returns the execution id. + * * @since 0.3.0 * @category constructors */ export const makeTestWorkflowOps = (options?: { readonly activities?: ReadonlyArray>; + /** Child handlers `executeChild` can run. @since 0.4.0 */ + readonly workflows?: ReadonlyArray; }): Effect.Effect => Effect.sync(() => { const bindings = new Map((options?.activities ?? []).map((b) => [b.activity.name, b])); + const workflowBindings = new Map( + (options?.workflows ?? []).map((b) => [b.workflow._tag, b]), + ); + /** execution id → the child run's exit, so a second start of a taken + * id attaches (the idempotency contract, in memory). */ + const childRuns = new Map>>(); + let continued: Option.Option = Option.none(); + // Assigned below; the child op needs the layer that hosts it. + let layer: Layer.Layer; const runner: ActivityRunner = { run: (_name, _payload, effect) => Effect.runPromiseExit(effect), }; @@ -567,10 +742,70 @@ export const makeTestWorkflowOps = (options?: { stateSet: (cell, value) => Effect.sync(() => void cells.set(cell, roundTrip(cell.valueSchema, value))), version: (_site, names) => Effect.succeed(names[names.length - 1] ?? names[0]), + sleep: (options) => Effect.sleep(options.duration), + sleepUntil: (options) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const target = yield* sleepUntilTarget(options); + const delay = target - now; + if (delay > 0) yield* Effect.sleep(Duration.millis(delay)); + }), + continueAsNew: (workflow, payload, options) => + Effect.suspend(() => { + const codecs = wireCodecsFor(workflow); + continued = Option.some({ + workflow, + payload: codecs.decodePayload(codecs.encodePayload(payload)), + memo: options?.memo, + }); + return Effect.interrupt; + }), + executeChild: (workflow, payload, { discard }) => + Effect.gen(function* () { + const binding = workflowBindings.get(workflow._tag); + if (binding === undefined) { + return yield* Effect.die( + `makeTestWorkflowOps: no binding for workflow "${workflow._tag}" — pass \`handleWorkflow(${workflow._tag}, handler)\` in \`workflows\``, + ); + } + const codecs = wireCodecsFor(workflow); + const decoded = codecs.decodePayload(codecs.encodePayload(payload)); + const executionId = yield* workflow.executionId(decoded); + let run = childRuns.get(executionId); + if (run === undefined) { + const created = Deferred.makeUnsafe>(); + childRuns.set(executionId, created); + run = created; + // The child's exit crosses the same wire the parent would read + // on Temporal: success/error round-tripped through ITS schemas. + // SAFETY: `decoded` came through this workflow's own codec. + const child = binding.execute(decoded as never, executionId).pipe( + Effect.provide(layer), + Effect.exit, + Effect.map((exit) => codecs.decodeExit(codecs.encodeExit(exit))), + Effect.flatMap((exit) => Deferred.done(created, Exit.succeed(exit))), + ); + yield* Effect.forkDetach(child); + } + if (discard) return executionId; + return yield* Effect.flatten(Deferred.await(run)); + }), }; + layer = Layer.succeed(WorkflowOps, runtime); const world: TestWorkflowOps = { - layer: Layer.succeed(WorkflowOps, runtime), + layer, + continuedAsNew: Effect.sync(() => continued), + continuedAsNewOf: (workflow) => + Effect.sync(() => + Option.flatMap(continued, (entry) => + // SAFETY: the recorded payload was decoded through THIS + // workflow's payload codec when the tags match. + entry.workflow._tag === workflow._tag + ? Option.some(entry.payload as never) + : Option.none(), + ), + ), resolve: (wrapper, value) => Effect.asVoid(Deferred.done(deferredFor(wrapper.deferred), Exit.succeed(value))), offer: (wrapper, payload) => diff --git a/src/typed-activity.ts b/src/typed-activity.ts index 76fdf14..07d2a04 100644 --- a/src/typed-activity.ts +++ b/src/typed-activity.ts @@ -1,192 +1,109 @@ /** - * Schema-typed activity definitions: ONE declaration shared by the workflow - * that calls an activity and the worker that implements it, so the two sides - * cannot drift and the wire is validated at both boundaries. The definition - * is temporal-free — it loads in the sandbox bundle and in client processes. - * - * ```ts - * // definitions module - * export const Reserve = TypedActivity.make("reserve", { - * payload: { requestId: Schema.String }, - * success: Schema.String, - * error: Schema.TaggedStruct("OutOfStock", { sku: Schema.String }), - * options: { startToCloseTimeout: "5 minutes", retry: { maximumAttempts: 3 } }, - * }); - * - * // workflow (engine-sandbox's `callActivity`) - * const reservation = yield* callActivity(Reserve, { requestId }); - * - * // worker (`implementActivities` over an `ActivityRunner` — the seam - * // where your app's runtime, spans, and error reporting plug in) - * ``` - * - * Failure semantics: a TYPED failure (matching the `error` schema) is a - * domain outcome — the worker throws it as a NON-RETRYABLE ApplicationFailure - * carrying the encoded value, and the caller decodes it into the Effect error - * channel. Everything else (defects, infra errors) stays a retryable activity - * failure and surfaces to the workflow as a defect once retries exhaust. - * + * **Deprecated module** — superseded by the `definition` module in 0.3.0 and + * scheduled for removal in 0.5.0. Everything here is a re-export or a thin + * alias so existing imports keep compiling while you migrate: + * + * - `TypedActivity.make(name, decl)` → `defineActivity(name, decl)` from + * `@springbird/effect-temporal/definition` (a declared activity IS its + * `TypedActivity` projection, and is callable inside handlers). + * - the type helpers `PayloadOf`, `SuccessOf`, `ErrorOf`, `AnyTypedActivity`, + * `TypedActivity`, `TypedActivityOptions` → the same names from + * `@springbird/effect-temporal/definition`. + * - `codecsFor`, `ACTIVITY_EXIT_TYPE` → the same names from + * `@springbird/effect-temporal/wire`. + * + * The `prefer-definition` lint rule reports every import from this module. + * + * @deprecated Import from `definition` (types, `defineActivity`) or `wire` + * (`codecsFor`, `ACTIVITY_EXIT_TYPE`) instead. Removed in 0.5.0. * @since 0.1.0 */ -import * as Schema from "effect/Schema"; -import { wireValueCodec, type WireValueCodec } from "./wire.js"; - -/** - * `ApplicationFailure.type` carrying a typed activity failure in `details[0]`. - * - * @since 0.1.0 - * @category wire - */ -export const ACTIVITY_EXIT_TYPE = "EffectActivityExit"; - -/** - * Applied when a definition declares no options of its own. - * - * @since 0.1.0 - * @category models - */ -export const DEFAULT_ACTIVITY_OPTIONS = { - startToCloseTimeout: "10 minutes", - retry: { maximumAttempts: 3 }, -} as const; - -/** - * The Temporal activity options a definition carries — the subset of - * `proxyActivities` options a typed activity pins at declaration time. - * - * @since 0.1.0 - * @category models - */ -export interface TypedActivityOptions { - readonly startToCloseTimeout: string | number; - readonly retry?: { - readonly maximumAttempts?: number; - readonly nonRetryableErrorTypes?: string[]; - }; -} - -/** - * A typed activity definition: name, the three channel schemas, and the - * Temporal options every call site honors. - * - * @since 0.1.0 - * @category models - */ -export interface TypedActivity< - Name extends string, - Payload extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, -> { - readonly name: Name; - readonly payloadSchema: Payload; - readonly successSchema: Success; - readonly errorSchema: Error; - readonly options: TypedActivityOptions; -} +import { defineActivity, makeTypedActivity } from "./definition.js"; -/** - * Type-erased activity definition, for APIs that operate on any activity. - * - * @since 0.1.0 - * @category models - */ -export type AnyTypedActivity = TypedActivity; +export { + /** + * @deprecated Import `ACTIVITY_EXIT_TYPE` from `wire`. Removed in 0.5.0. + * @since 0.1.0 + * @category wire + */ + ACTIVITY_EXIT_TYPE, + /** + * @deprecated Import `codecsFor` from `wire`. Removed in 0.5.0. + * @since 0.1.0 + * @category codecs + */ + codecsFor, + /** + * @deprecated Import `TypedActivityCodecs` from `wire`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type TypedActivityCodecs, +} from "./wire.js"; -/** - * Extracts a definition's decoded payload type. - * - * @since 0.1.0 - * @category models - */ -export type PayloadOf = - A extends TypedActivity ? P["Type"] : never; -/** - * Extracts a definition's decoded success type. - * - * @since 0.1.0 - * @category models - */ -export type SuccessOf = - A extends TypedActivity ? S["Type"] : never; -/** - * Extracts a definition's decoded error type. - * - * @since 0.1.0 - * @category models - */ -export type ErrorOf = - A extends TypedActivity ? E["Type"] : never; +export { + /** + * @deprecated Import `DEFAULT_ACTIVITY_OPTIONS` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + DEFAULT_ACTIVITY_OPTIONS, + /** + * @deprecated Import `AnyTypedActivity` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type AnyTypedActivity, + /** + * @deprecated Import `ErrorOf` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type ErrorOf, + /** + * @deprecated Import `PayloadOf` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type PayloadOf, + /** + * @deprecated Import `SuccessOf` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type SuccessOf, + /** + * @deprecated Import `TypedActivity` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type TypedActivity, + /** + * @deprecated Import `TypedActivityOptions` from `definition`. Removed in 0.5.0. + * @since 0.1.0 + * @category models + */ + type TypedActivityOptions, +} from "./definition.js"; /** - * Declare a typed activity. `payload` accepts struct fields (wrapped in - * `Schema.Struct` for you) or a full schema; `success` defaults to - * `Schema.Void`, `error` to `Schema.Never`, and `options` to - * `DEFAULT_ACTIVITY_OPTIONS`. + * Declare a typed activity — the pre-0.3.0 spelling of `defineActivity`. + * Returns exactly what `defineActivity` returns (the projection plus the + * in-handler callable), so migrating is renaming the import. * + * @deprecated Use `defineActivity` from `definition`. Removed in 0.5.0. * @since 0.1.0 * @category constructors */ -export const make = < - const Name extends string, - Payload extends Schema.Struct.Fields | Schema.Top, - Success extends Schema.Top = Schema.Void, - Error extends Schema.Top = Schema.Never, ->( - name: Name, - definition: { - readonly payload: Payload; - readonly success?: Success; - readonly error?: Error; - readonly options?: TypedActivityOptions; - }, -): TypedActivity< - Name, - Payload extends Schema.Struct.Fields ? Schema.Struct : Payload, - Success, - Error -> => ({ - name, - // SAFETY: the branch mirrors the conditional return type — a schema stays - // itself, struct fields become `Schema.Struct(fields)` — but TypeScript - // cannot resolve the conditional over the unbound `Payload`. - payloadSchema: (Schema.isSchema(definition.payload) - ? definition.payload - : Schema.Struct(definition.payload as Schema.Struct.Fields)) as never, - // SAFETY: when the option is omitted the type parameter takes its default - // (`Schema.Void` / `Schema.Never`), which is exactly the fallback value. - successSchema: (definition.success ?? Schema.Void) as Success, - errorSchema: (definition.error ?? Schema.Never) as Error, - options: definition.options ?? DEFAULT_ACTIVITY_OPTIONS, -}); - -/** - * The wire codecs for a definition's three channels. - * - * @since 0.1.0 - * @category models - */ -export interface TypedActivityCodecs { - readonly payload: WireValueCodec>; - readonly success: WireValueCodec>; - readonly error: WireValueCodec>; -} +export const make: typeof defineActivity = defineActivity; -/** - * Build the wire codecs for an activity's payload, success, and error - * channels from the definition's own schemas — the shared encoding used by - * `callActivity` on the workflow side and `implementActivities` on the - * worker side. - * - * @since 0.1.0 - * @category codecs - */ -// SAFETY: each codec is built from the definition's own schema, so the -// schema's Type is exactly what PayloadOf/SuccessOf/ErrorOf extract; the -// casts only restore what the type-erased `AnyTypedActivity` bound loses. -export const codecsFor = (activity: A): TypedActivityCodecs => ({ - payload: wireValueCodec(activity.payloadSchema) as WireValueCodec>, - success: wireValueCodec(activity.successSchema) as WireValueCodec>, - error: wireValueCodec(activity.errorSchema) as WireValueCodec>, -}); +export { + /** + * The projection-only constructor, for engine-level code that needs a + * `TypedActivity` without the callable. + * + * @internal + */ + makeTypedActivity, +}; diff --git a/src/update.ts b/src/update.ts index 03dbe5b..2bb8cd1 100644 --- a/src/update.ts +++ b/src/update.ts @@ -1,10 +1,12 @@ /** * Request/response into a running workflow — Temporal updates with typed * channels: the caller gets the handler's typed success or typed failure - * back, unlike a fire-and-forget mailbox message. The workflow body takes - * requests with `takeUpdate` (engine-sandbox) and answers each through its - * `respond`; clients call `executeUpdate` (engine-client) and receive the - * response in the Effect error/success channels. + * back, unlike a fire-and-forget mailbox message. This module holds the + * shared wire contract (update name, definition shape, codec) the engine + * halves consume. Applications declare updates with `defineUpdate` from the + * `definition` module (`.take` in the handler, `wf.executeUpdate` from + * clients) and never import this one; the `make` constructor here is + * deprecated. * * The response always travels as a wire-encoded Exit in the update RESULT * (the update itself never fails), so typed failures round-trip without a @@ -52,10 +54,11 @@ export interface DurableUpdate

{ * (ordered oldest first). Checked newest-first, so a fresh execution * records only the newest marker. * + * @deprecated Use `version` from `definition` (engine-agnostic; the + * Temporal runtime dispatches here). Removed in 0.5.0. * @since 0.1.0 * @category combinators */ @@ -73,6 +87,8 @@ export const version = ( * patch branch, with the result, error, and service channels unioned across * cases. * + * @deprecated Use `versioned(site, { v1: run1, v2: run2 })` from + * `definition` — same markers, engine-agnostic. Removed in 0.5.0. * @since 0.1.0 * @category combinators */ diff --git a/src/wire.ts b/src/wire.ts index 8b3c22c..2632cb7 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -21,6 +21,7 @@ import type * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import type * as Workflow from "effect/unstable/workflow/Workflow"; +import type { AnyTypedActivity, ErrorOf, PayloadOf, SuccessOf } from "./definition.js"; /** * Signal by which `deferredDone` reaches a running workflow. @@ -49,6 +50,14 @@ export const DEFERRED_STATE_QUERY = "effect-workflow-deferred-state"; */ export const EXIT_FAILURE_TYPE = "EffectWorkflowExit"; +/** + * `ApplicationFailure.type` carrying a typed activity failure in `details[0]`. + * + * @since 0.4.0 + * @category wire + */ +export const ACTIVITY_EXIT_TYPE = "EffectActivityExit"; + /** * The attach bridge's activity name — see `activities.ts`. * @@ -238,3 +247,33 @@ export const wireCodecsFor = (workflow: Workflow.Any): WorkflowWireCodecs => { decodeExit: exit.decode, }; }; + +/** + * The wire codecs for a declared activity's three channels. + * + * @since 0.4.0 + * @category models + */ +export interface TypedActivityCodecs { + readonly payload: WireValueCodec>; + readonly success: WireValueCodec>; + readonly error: WireValueCodec>; +} + +/** + * Build the wire codecs for a declared activity's payload, success, and + * error channels from the declaration's own schemas — the shared encoding + * used by the workflow-side call, `implementActivities` on the worker side, + * and the in-memory test runtime. + * + * @since 0.4.0 + * @category codecs + */ +// SAFETY: each codec is built from the declaration's own schema, so the +// schema's Type is exactly what PayloadOf/SuccessOf/ErrorOf extract; the +// casts only restore what the type-erased `AnyTypedActivity` bound loses. +export const codecsFor = (activity: A): TypedActivityCodecs => ({ + payload: wireValueCodec(activity.payloadSchema) as WireValueCodec>, + success: wireValueCodec(activity.successSchema) as WireValueCodec>, + error: wireValueCodec(activity.errorSchema) as WireValueCodec>, +});