From 1cd2e0a2c3c0a9d0f588b7f1c1670c8f2424e36b Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Thu, 27 Aug 2026 10:30:35 -0400 Subject: [PATCH 1/4] prototype: one declaration, types flow, no engine leak (VERDICT: yes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Question: can one defineWorkflow declaration (workflow + activities + messages + state) make types flow to handler, worker, and client, with the handler engine-agnostic — answering the TypedActivity/DurableDeferred leak? Verdict: YES. The same handler function object runs on (a) a plain in-memory Effect runtime with no engine anywhere and (b) real Temporal via the existing engine, driven identically; payload/success/typed-error inference is pinned end to end; implement() is completeness-checked. Design notes: the single seam is OpsRuntime (six untyped operations the typed ops toolkit dispatches through); primitives are materialized from the declaration (names namespaced by tag); handler R = never. Prototype casts live in makeOps and the temporal runtime — a production version would type the seam the way SandboxHandler was typed. Throwaway. Not for merge; the validated decision informs the 0.3.0 API conversation and the upstream schema'd-activity proposal. --- src/__tests__/prototype/def.ts | 233 ++++++++++++++++++ .../prototype/one-declaration.test.ts | 212 ++++++++++++++++ src/__tests__/prototype/order-workflows.ts | 8 + src/__tests__/prototype/order.ts | 83 +++++++ src/__tests__/prototype/temporal-runtime.ts | 78 ++++++ 5 files changed, 614 insertions(+) create mode 100644 src/__tests__/prototype/def.ts create mode 100644 src/__tests__/prototype/one-declaration.test.ts create mode 100644 src/__tests__/prototype/order-workflows.ts create mode 100644 src/__tests__/prototype/order.ts create mode 100644 src/__tests__/prototype/temporal-runtime.ts diff --git a/src/__tests__/prototype/def.ts b/src/__tests__/prototype/def.ts new file mode 100644 index 0000000..dd8cfaf --- /dev/null +++ b/src/__tests__/prototype/def.ts @@ -0,0 +1,233 @@ +// PROTOTYPE — throwaway design spike, NOT production code. +// +// Question: can ONE declaration (workflow + activities + messages + state) +// make the types flow to the handler, the worker implementation, and the +// client — with the handler fully engine-agnostic? This is the answer to +// the "TypedActivity / DurableDeferred leak": the workflow body should +// import nothing from engine-sandbox; every capability arrives as a typed +// `ops` toolkit derived from the single declaration, and the ENGINE decides +// how each op executes. + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Workflow from "effect/unstable/workflow/Workflow"; +import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; +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"; + +// ── Declaration shapes ─────────────────────────────────────────────────────── + +export interface ActivityDecl { + readonly payload: Schema.Top; + readonly success?: Schema.Top; + readonly error?: Schema.Top; + readonly options?: TypedActivity.TypedActivityOptions; +} + +export type MessageDecl = + | { readonly deferred: Schema.Top } + | { readonly mailbox: Schema.Top } + | { readonly update: { readonly payload: Schema.Top; readonly success: Schema.Top; readonly error: Schema.Top } }; + +type SchemaType = S extends Schema.Top ? S["Type"] : Fallback; + +// ── The typed ops toolkit the handler receives ─────────────────────────────── + +export interface UpdateRequestOf { + readonly payload: P; + readonly respond: (exit: Exit.Exit) => Effect.Effect; +} + +export type OpsOf< + A extends Record, + M extends Record, + St extends Record, +> = { + readonly activity: { + readonly [K in keyof A]: ( + payload: A[K]["payload"]["Type"], + ) => Effect.Effect, SchemaType>; + }; + readonly message: { + readonly [K in keyof M]: M[K] extends { readonly deferred: infer S extends Schema.Top } + ? { readonly await: Effect.Effect } + : M[K] extends { readonly mailbox: infer P extends Schema.Top } + ? { + readonly take: Effect.Effect; + readonly poll: Effect.Effect>; + } + : M[K] extends { + readonly update: { + readonly payload: infer P extends Schema.Top; + readonly success: infer S extends Schema.Top; + readonly error: infer E extends Schema.Top; + }; + } + ? { readonly take: Effect.Effect> } + : never; + }; + readonly state: { + readonly [K in keyof St]: { readonly set: (value: St[K]["Type"]) => Effect.Effect }; + }; +}; + +// ── The one engine seam: an untyped runtime the typed ops dispatch through ── +// This is what each engine implements. The Temporal one wraps the existing +// engine-sandbox machinery; the memory one is plain queues and deferreds. + +export interface OpsRuntime { + readonly activity: ( + activity: TypedActivity.AnyTypedActivity, + payload: unknown, + ) => Effect.Effect; + readonly deferredAwait: (name: string) => Effect.Effect; + readonly mailboxTake: (name: string) => Effect.Effect; + readonly mailboxPoll: (name: string) => Effect.Effect>; + readonly updateTake: ( + name: string, + ) => Effect.Effect>; + readonly stateSet: (name: string, value: unknown) => Effect.Effect; +} + +// ── Worker implementation typing: completeness-checked from the declaration ─ + +export type ImplementationsOf> = { + readonly [K in keyof A]: ( + payload: A[K]["payload"]["Type"], + ) => Effect.Effect, SchemaType>; +}; + +// ── defineWorkflow ─────────────────────────────────────────────────────────── + +export const defineWorkflow = < + const Tag extends string, + const P extends Schema.Struct.Fields, + S extends Schema.Top, + E extends Schema.Top, + const A extends Record, + const M extends Record, + const St extends Record, +>( + tag: Tag, + decl: { + readonly payload: P; + readonly idempotencyKey: (payload: Schema.Struct

["Type"]) => string; + readonly success?: S; + readonly error?: E; + readonly activities?: A; + readonly messages?: M; + readonly state?: St; + }, +) => { + const workflow = Workflow.make(tag, { + payload: decl.payload, + idempotencyKey: decl.idempotencyKey, + ...(decl.success === undefined ? {} : { success: decl.success }), + ...(decl.error === undefined ? {} : { error: decl.error }), + }); + + // Materialize the existing primitives once, from the declaration. Names + // are namespaced by tag so two definitions never collide. + const activities = Object.fromEntries( + Object.entries(decl.activities ?? {}).map(([key, a]) => [ + key, + TypedActivity.make(`${tag}/${key}`, { + payload: a.payload, + ...(a.success === undefined ? {} : { success: a.success }), + ...(a.error === undefined ? {} : { error: a.error }), + ...(a.options === undefined ? {} : { options: a.options }), + }), + ]), + ) as Record; + + const messages = decl.messages ?? ({} as M); + const messageName = (key: string) => `${tag}/${key}`; + const deferreds = Object.fromEntries( + Object.entries(messages) + .filter(([, m]) => "deferred" in m) + .map(([key, m]) => [ + key, + DurableDeferred.make(messageName(key), { success: (m as { deferred: Schema.Top }).deferred }), + ]), + ); + const mailboxes = Object.fromEntries( + Object.entries(messages) + .filter(([, m]) => "mailbox" in m) + .map(([key, m]) => [ + key, + DurableMailbox.make(messageName(key), { payload: (m as { mailbox: Schema.Top }).mailbox }), + ]), + ); + const updates = Object.fromEntries( + Object.entries(messages) + .filter(([, m]) => "update" in m) + .map(([key, m]) => { + const u = (m as { update: { payload: Schema.Top; success: Schema.Top; error: Schema.Top } }).update; + return [key, DurableUpdate.make(messageName(key), { payload: u.payload, success: u.success, error: u.error })]; + }), + ); + const cells = Object.fromEntries( + Object.entries(decl.state ?? {}).map(([key, value]) => [ + key, + StateCell.make(messageName(key), { value }), + ]), + ); + + /** Build the TYPED ops toolkit over an untyped runtime — the single place + * the unknown-seam casts live. */ + const makeOps = (runtime: OpsRuntime): OpsOf => { + const activity = Object.fromEntries( + Object.entries(activities).map(([key, a]) => [ + key, + (payload: unknown) => runtime.activity(a, payload), + ]), + ); + const message = Object.fromEntries( + Object.keys(messages).map((key) => { + const name = messageName(key); + const m = messages[key]!; + if ("deferred" in m) return [key, { await: runtime.deferredAwait(name) }]; + if ("mailbox" in m) + return [key, { take: runtime.mailboxTake(name), poll: runtime.mailboxPoll(name) }]; + return [key, { take: runtime.updateTake(name) }]; + }), + ); + const state = Object.fromEntries( + Object.keys(cells).map((key) => [ + key, + { set: (value: unknown) => runtime.stateSet(messageName(key), value) }, + ]), + ); + // SAFETY (prototype): the runtime dispatches by the primitives built + // from the same declaration the Ops type is derived from. + return { activity, message, state } as unknown as OpsOf; + }; + + type Payload = Schema.Struct

["Type"]; + type Success = SchemaType; + type Err = SchemaType; + + return { + tag, + workflow, + activities, + deferreds, + mailboxes, + updates, + cells, + makeOps, + /** Bind the engine-agnostic handler. The handler's R is `never`: it can + * touch the outside world only through the typed ops. */ + handler: ( + body: (payload: Payload, ops: OpsOf) => Effect.Effect, + ) => ({ definition: { tag, workflow, activities, deferreds, mailboxes, updates, cells, makeOps }, body }), + /** Worker-side implementations, completeness-checked from the declaration. */ + implement: (impls: ImplementationsOf): ImplementationsOf => impls, + }; +}; + +export type AnyDefined = ReturnType; diff --git a/src/__tests__/prototype/one-declaration.test.ts b/src/__tests__/prototype/one-declaration.test.ts new file mode 100644 index 0000000..9d32315 --- /dev/null +++ b/src/__tests__/prototype/one-declaration.test.ts @@ -0,0 +1,212 @@ +// PROTOTYPE — throwaway. The proof for the single-declaration design: +// +// 1. TYPES FLOW: payloads, successes, and typed errors infer end-to-end +// from the one declaration (pinned below with expectTypeOf). +// 2. NO LEAK: the SAME handler function object runs (a) on a plain +// in-memory runtime with zero engine anywhere, and (b) on real +// Temporal via the existing engine — the definition and handler are +// engine-agnostic. + +import { fileURLToPath } from "node:url"; +import * as Deferred from "effect/Deferred"; +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 Queue from "effect/Queue"; +import * as Result from "effect/Result"; +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, type BoundActivity } from "../../activities.js"; +import { executeUpdate, makeTemporalClientEngine, offerMailbox, readStateCell } from "../../engine-client.js"; +import { createWorkflowTestEnv } from "../utils/workflow-test-env.js"; +import type { OpsRuntime, UpdateRequestOf } from "./def.js"; +import { CardDeclined, Order, orderBound, orderImpls } from "./order.js"; + +const temporal = createWorkflowTestEnv("proto-one-decl"); + +// ── 1. The type pins ───────────────────────────────────────────────────────── + +const _types = () => { + const bound = Order.handler((payload, ops) => { + expectTypeOf(payload).toEqualTypeOf<{ readonly orderId: string }>(); + // Activities: payload in, success out, typed error channel. + const charge = ops.activity.charge({ orderId: payload.orderId, amountCents: 1 }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + // Messages: deferred success, mailbox payload, update request typing. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<{ + readonly level: number; + }>(); + expectTypeOf>().toEqualTypeOf< + UpdateRequestOf<{ readonly amountCents: number }, number, string> + >(); + return Effect.succeed("ok"); + }); + void bound; + + // Worker implementations are completeness-checked from the declaration. + Order.implement({ + reserve: () => Effect.succeed("r"), + // @ts-expect-error wrong success type + charge: () => Effect.succeed(42), + }); + // @ts-expect-error missing implementation for `charge` + Order.implement({ reserve: () => Effect.succeed("r") }); +}; +void _types; + +// ── 2. The in-memory runtime: no engine, plain Effect ──────────────────────── + +const makeMemoryWorld = Effect.gen(function* () { + // The prototype hardcodes the declaration's channel names — throwaway. + const approval = yield* Deferred.make(); + const priority = yield* Queue.unbounded(); + const setAmount = yield* Queue.unbounded>(); + const state = new Map(); + + const runtime: OpsRuntime = { + activity: (activity, payload) => { + const impl = (orderImpls as unknown as Record Effect.Effect>)[ + activity.name.split("/")[1]! + ]!; + return impl(payload as never); + }, + deferredAwait: () => Deferred.await(approval), + mailboxTake: () => Queue.take(priority), + mailboxPoll: () => Queue.poll(priority), + updateTake: () => Queue.take(setAmount), + stateSet: (name, value) => Effect.sync(() => void state.set(name, value)), + }; + + return { + runtime, + approve: (value: unknown) => Deferred.done(approval, Exit.succeed(value)), + offer: (value: unknown) => Queue.offer(priority, value), + update: (payload: unknown) => + Effect.gen(function* () { + const reply = yield* Deferred.make(); + yield* Queue.offer(setAmount, { + payload, + respond: (exit: Exit.Exit) => + Deferred.done(reply, exit as Exit.Exit).pipe(Effect.asVoid), + }); + return yield* Deferred.await(reply); + }), + readState: (name: string) => state.get(name), + }; +}); + +describe("prototype: one declaration, types flow, no engine leak", { concurrent: false }, () => { + it("runs the SAME handler on a plain in-memory runtime (no engine at all)", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const world = yield* makeMemoryWorld; + const ops = Order.makeOps(world.runtime); + + const fiber = yield* Effect.forkChild(orderBound.body({ orderId: "m-1" }, ops)); + + // 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.update({ amountCents: 2500 }); + expect(previous).toBe(1000); + yield* world.offer({ level: 2 }); + yield* settle; + expect(world.readState("protoOrder/status")).toEqual({ phase: "awaiting-approval" }); + yield* world.approve("memory-ben"); + + const result = yield* Fiber.join(fiber); + expect(result).toBe("res-m-1|receipt-m-1-2500|p2|by:memory-ben"); + expect(world.readState("protoOrder/status")).toEqual({ phase: "complete" }); + }), + ); + }, 20_000); + + it("runs the SAME handler on real Temporal through the existing engine", async () => { + const workflowsPath = fileURLToPath(new URL("./order-workflows.ts", import.meta.url)); + const runner: ActivityRunner = { + run: (_name, _payload, effect) => Effect.runPromiseExit(effect), + }; + const activities = implementActivities( + runner, + Object.entries(Order.activities).map(([key, activity]) => + handle(activity, (orderImpls as never as Record)[key]!), + ) as ReadonlyArray>, + ); + + 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)); + const approve = (executionId: string, approver: string) => + run( + DurableDeferred.done(Order.deferreds["approval"]!, { + token: DurableDeferred.tokenFromExecutionId(Order.deferreds["approval"]!, { + workflow: Order.workflow, + executionId, + }), + exit: Exit.succeed(approver), + // The fromEntries-built deferred record erases services — prototype. + }) as Effect.Effect, + ); + + const payload = { orderId: "t-1" }; + const workflowId = await run(Order.workflow.execute(payload, { discard: true })); + + // Same drive sequence as the memory test, through the real client ops. + const previous = await Effect.runPromise( + executeUpdate(Order.updates["setAmount"]!, { + client, + workflowId, + payload: { amountCents: 2500 }, + }), + ); + expect(previous).toBe(1000); + await Effect.runPromise( + offerMailbox(Order.mailboxes["priority"]!, { client, workflowId, payload: { level: 2 } }), + ); + const mid = await Effect.runPromise( + readStateCell(Order.cells["status"]!, { client, workflowId }), + ); + expect(Option.getOrNull(mid)).toEqual({ phase: "awaiting-approval" }); + await approve(workflowId, "temporal-ben"); + + const result = await run(Order.workflow.execute(payload)); + expect(result).toBe("res-t-1|receipt-t-1-2500|p2|by:temporal-ben"); + const final = await Effect.runPromise( + readStateCell(Order.cells["status"]!, { client, workflowId }), + ); + expect(Option.getOrNull(final)).toEqual({ phase: "complete" }); + + // The typed activity failure flows into the workflow error channel. + const declinePayload = { orderId: "t-declined" }; + const declineId = await run(Order.workflow.execute(declinePayload, { discard: true })); + await Effect.runPromise( + executeUpdate(Order.updates["setAmount"]!, { + client, + workflowId: declineId, + payload: { amountCents: 10_000 }, + }), + ); + await Effect.runPromise( + offerMailbox(Order.mailboxes["priority"]!, { + client, + workflowId: declineId, + payload: { level: 1 }, + }), + ); + await approve(declineId, "x"); + const declined = await run(Effect.result(Order.workflow.execute(declinePayload))); + expect(Result.isFailure(declined) && declined.failure).toEqual({ + _tag: "CardDeclined", + orderId: "t-declined", + }); + }); + }, 120_000); +}); diff --git a/src/__tests__/prototype/order-workflows.ts b/src/__tests__/prototype/order-workflows.ts new file mode 100644 index 0000000..b7f047d --- /dev/null +++ b/src/__tests__/prototype/order-workflows.ts @@ -0,0 +1,8 @@ +// PROTOTYPE — throwaway. The Temporal bundle for the prototype definition: +// the SAME bound handler the memory test runs, hosted on the real engine. + +import { workflowBundle } from "../../engine-sandbox.js"; +import { toTemporalLayer } from "./temporal-runtime.js"; +import { orderBound } from "./order.js"; + +export default workflowBundle(toTemporalLayer(orderBound)); diff --git a/src/__tests__/prototype/order.ts b/src/__tests__/prototype/order.ts new file mode 100644 index 0000000..afe6784 --- /dev/null +++ b/src/__tests__/prototype/order.ts @@ -0,0 +1,83 @@ +// PROTOTYPE — throwaway. THE single declaration, and the one +// engine-agnostic handler bound to it. Note what this module imports: +// upstream Effect only, plus the prototype's def module. No engine-sandbox, +// no Temporal, no per-primitive make calls — the leak under test. + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { defineWorkflow } from "./def.js"; + +export const CardDeclined = Schema.TaggedStruct("CardDeclined", { + orderId: Schema.String, +}); + +export const Order = defineWorkflow("protoOrder", { + payload: { orderId: Schema.String }, + idempotencyKey: ({ orderId }) => orderId, + success: Schema.String, + error: CardDeclined, + activities: { + reserve: { payload: Schema.Struct({ orderId: Schema.String }), success: Schema.String }, + charge: { + payload: Schema.Struct({ orderId: Schema.String, amountCents: Schema.Finite }), + success: Schema.String, + error: CardDeclined, + }, + }, + messages: { + approval: { deferred: Schema.String }, + priority: { mailbox: Schema.Struct({ level: Schema.Finite }) }, + setAmount: { + update: { + payload: Schema.Struct({ amountCents: Schema.Finite }), + success: Schema.Finite, // the previous amount + error: Schema.String, // "amount-too-low" + }, + }, + }, + state: { + status: Schema.Struct({ phase: Schema.String }), + }, +}); + +/** The handler: every capability arrives through `ops`, fully typed from + * the declaration above; its R channel is `never`. */ +export const orderBound = Order.handler((payload, ops) => + Effect.gen(function* () { + yield* ops.state.status.set({ phase: "reserving" }); + const reservation = yield* ops.activity.reserve({ orderId: payload.orderId }); + + // A typed update: respond with the PREVIOUS amount, or a typed refusal. + yield* ops.state.status.set({ phase: "pricing" }); + let amountCents = 1000; + const request = yield* ops.message.setAmount.take; + if (request.payload.amountCents < 100) { + yield* request.respond(Exit.fail("amount-too-low")); + } else { + yield* request.respond(Exit.succeed(amountCents)); + amountCents = request.payload.amountCents; + } + + // A mailbox message and a one-shot approval. + const priority = yield* ops.message.priority.take; + yield* ops.state.status.set({ phase: "awaiting-approval" }); + const approver = yield* ops.message.approval.await; + + // A typed activity failure flows straight into the workflow error channel. + const receipt = yield* ops.activity.charge({ orderId: payload.orderId, amountCents }); + + yield* ops.state.status.set({ phase: "complete" }); + return `${reservation}|${receipt}|p${priority.level}|by:${approver}`; + }), +); + +/** Worker-side activity implementations — completeness-checked against the + * declaration; engine decides where they run. */ +export const orderImpls = Order.implement({ + reserve: ({ orderId }) => Effect.succeed(`res-${orderId}`), + charge: ({ orderId, amountCents }) => + amountCents >= 10_000 + ? Effect.fail({ _tag: "CardDeclined", orderId } as const) + : Effect.succeed(`receipt-${orderId}-${amountCents}`), +}); diff --git a/src/__tests__/prototype/temporal-runtime.ts b/src/__tests__/prototype/temporal-runtime.ts new file mode 100644 index 0000000..7fe0784 --- /dev/null +++ b/src/__tests__/prototype/temporal-runtime.ts @@ -0,0 +1,78 @@ +// PROTOTYPE — throwaway. The Temporal implementation of the ops seam: +// every op dispatches into the EXISTING engine-sandbox machinery, so the +// prototype rides the proven engine underneath. Sandbox-only module. + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; +import { + callActivity, + pollMailbox, + setStateCell, + takeMailbox, + takeUpdate, +} from "../../engine-sandbox.js"; +import type { DurableMailbox } from "../../mailbox.js"; +import type { DurableUpdate } from "../../update.js"; +import type { StateCell } from "../../state-cell.js"; +import type * as Schema from "effect/Schema"; +import type { OpsRuntime } from "./def.js"; + +interface DefinitionLike { + readonly workflow: { + readonly toLayer: (execute: (payload: any, executionId: string) => Effect.Effect) => Layer.Layer; + }; + readonly deferreds: Record>; + readonly mailboxes: Record>; + readonly updates: Record>; + readonly cells: Record>; + readonly makeOps: (runtime: OpsRuntime) => any; +} + +const byName = (record: Record) => + new Map(Object.values(record).map((item) => [item.name, item])); + +const temporalRuntime = (def: DefinitionLike): OpsRuntime => { + const deferreds = new Map(Object.entries(def.deferreds).map(([k, d]) => [d.name ?? k, d])); + const mailboxes = byName(def.mailboxes); + const updates = byName(def.updates); + const cells = byName(def.cells); + const missing = (kind: string, name: string) => + Effect.die(`prototype: no ${kind} named "${name}" in this definition`); + // SAFETY (prototype): these ops require SandboxRun / the engine at the + // type level; the per-run wrapper provides them. The seam types R=never + // so handlers stay engine-agnostic. + return { + activity: (activity, payload) => callActivity(activity, payload as never) as never, + deferredAwait: (name) => { + const d = deferreds.get(name); + return d ? (DurableDeferred.await(d) as never) : missing("deferred", name); + }, + mailboxTake: (name) => { + const m = mailboxes.get(name); + return m ? (takeMailbox(m) as never) : missing("mailbox", name); + }, + mailboxPoll: (name) => { + const m = mailboxes.get(name); + return m ? (pollMailbox(m) as never) : missing("mailbox", name); + }, + updateTake: (name) => { + const u = updates.get(name); + return u ? (takeUpdate(u) as never) : missing("update", name); + }, + stateSet: (name, value) => { + const c = cells.get(name); + return c ? (setStateCell(c, value) as never) : missing("cell", name); + }, + }; +}; + +/** Turn a bound handler into an upstream `toLayer` registration whose body + * receives Temporal-backed typed ops — ready for `workflowBundle`. */ +export const toTemporalLayer = (bound: { + readonly definition: DefinitionLike; + readonly body: (payload: any, ops: any) => Effect.Effect; +}): Layer.Layer => { + const ops = bound.definition.makeOps(temporalRuntime(bound.definition)); + return bound.definition.workflow.toLayer((payload) => bound.body(payload, ops)); +}; From 1e913c62a2bd291906fa772ac1bb394b2acee902 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Thu, 27 Aug 2026 10:37:20 -0400 Subject: [PATCH 2/4] =?UTF-8?q?prototype:=20evolved()=20=E2=80=94=20schema?= =?UTF-8?q?=20evolution=20across=20in-flight=20versions=20(VERDICT:=20yes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Because every boundary is schema-encoded JSON decoded deterministically on replay, data versioning reduces to: the current schema must decode the wire old code wrote. evolved(current, legacy, migrate) is the declaration-level answer — newest-first union, pure forward migrations, one newest Type for handlers, legacy shapes never re-encoded. Proven through the real wire codec including the V1-history-decodes-under-V2-code case. --- .../prototype/schema-evolution.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/__tests__/prototype/schema-evolution.test.ts diff --git a/src/__tests__/prototype/schema-evolution.test.ts b/src/__tests__/prototype/schema-evolution.test.ts new file mode 100644 index 0000000..ee5d754 --- /dev/null +++ b/src/__tests__/prototype/schema-evolution.test.ts @@ -0,0 +1,77 @@ +// PROTOTYPE — throwaway. The data half of versioning: can a declaration's +// schema EVOLVE (add/change fields) while old runs are in flight? +// +// Every boundary in the engine is schema-encoded JSON, and every decode +// happens deterministically on replay — so the whole problem reduces to: +// the CURRENT schema must decode the wire that OLD code wrote. `evolved` +// makes that a declaration-level concern: newest schema first, legacy +// schemas behind pure migrations, one Type coming out — so handler types +// only ever see the newest shape. + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { wireValueCodec } from "../../wire.js"; + +/** Newest-first schema evolution: decode tries `current`, then each legacy + * schema migrated forward by a PURE function (pure = deterministic on + * replay). Encoding always writes the newest shape. */ +const evolved = ( + current: Current, + legacy: Legacy, + migrate: (value: Legacy["Type"]) => Current["Type"], +) => + Schema.Union([ + current, + legacy.pipe( + Schema.decodeTo(current, { + decode: SchemaGetter.transform(migrate), + encode: SchemaGetter.forbidden(() => "legacy shapes are never written"), + }), + ), + ]); + +// V1 shipped without `priority`; V2 adds it. In-flight runs hold V1 wire in +// their histories (start events, activity results, buffered signals). +const OrderV1 = Schema.Struct({ orderId: Schema.String }); +const OrderV2 = Schema.Struct({ orderId: Schema.String, priority: Schema.Finite }); +const OrderPayload = evolved(OrderV2, OrderV1, (v1) => ({ ...v1, priority: 0 })); + +describe("prototype: schema evolution across in-flight versions", () => { + it("decodes V1 wire (old histories) and V2 wire to ONE newest Type", () => { + const codec = wireValueCodec(OrderPayload); + + // What old code wrote into history before the deploy: + const v1Wire = wireValueCodec(OrderV1).encode({ orderId: "a" }); + expect(codec.decode(v1Wire)).toEqual({ orderId: "a", priority: 0 }); + + // What new code writes and reads: + const v2Wire = codec.encode({ orderId: "b", priority: 3 }); + expect(codec.decode(v2Wire)).toEqual({ orderId: "b", priority: 3 }); + + // Encoding never produces the legacy shape. + expect(v2Wire).toEqual({ orderId: "b", priority: 3 }); + + // The handler-facing Type is ONLY the newest shape. + expectTypeOf<(typeof OrderPayload)["Type"]>().toEqualTypeOf< + { readonly orderId: string; readonly priority: number } + >(); + }); + + it("rejects wire that matches NO version, instead of guessing", () => { + const codec = wireValueCodec(OrderPayload); + expect(() => codec.decode({ nonsense: true })).toThrow(); + }); + + it("migrations may not be effectful by accident", async () => { + // A migration is a plain function — if someone smuggles a failing + // transform in, decode fails loudly rather than silently corrupting. + const Bad = evolved(OrderV2, OrderV1, () => { + throw new Error("impure migration"); + }); + const v1Wire = wireValueCodec(OrderV1).encode({ orderId: "x" }); + expect(() => wireValueCodec(Bad).decode(v1Wire)).toThrow(); + await Effect.runPromise(Effect.void); // keep vitest's async shape happy + }); +}); From c3a072e7dce193380f3dd0fc2cacd9da221ee75e Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Thu, 27 Aug 2026 11:05:20 -0400 Subject: [PATCH 3/4] 0.3.0: declare once, call directly - the definition module Declarations are now callable inside handlers: yield* Charge({ orderId }), Approval.await, Status.set(...). Every primitive requires only the WorkflowOps service, the one seam an engine implements, so handlers import nothing engine-shaped. workflowBundle provides the Temporal runtime; makeTestWorkflowOps (testing) provides an in-memory one, so the same handler runs on real Temporal or in a plain unit test. Also in the definition module: version(site, names) for patch-marker logic branches and evolved(current, legacy, migrate) for newest-first schema evolution with pure migrations. Fixtures, examples, and docs authored with define* throughout. Client-side driving addresses the declaration's underlying primitive (.update, .mailbox, .cell, .deferred). Lint: the fork/race versioning rule now also catches the bare version() call. --- CHANGELOG.md | 26 +- EXAMPLES.md | 10 +- README.md | 48 +-- docs/.vitepress/config.ts | 1 + docs/guide/activities.md | 50 +-- docs/guide/cancellation.md | 6 +- docs/guide/child-workflows.md | 2 +- docs/guide/continue-as-new.md | 6 +- docs/guide/declaring-capabilities.md | 57 ++++ docs/guide/defining-workflows.md | 2 +- docs/guide/getting-started.md | 21 +- docs/guide/introduction.md | 16 +- docs/guide/lint-rules.md | 2 +- docs/guide/mailboxes.md | 24 +- docs/guide/queryable-state.md | 24 +- docs/guide/testing.md | 47 ++- docs/guide/timers-and-approvals.md | 22 +- docs/guide/updates.md | 24 +- docs/guide/versioning.md | 76 ++++- docs/index.md | 24 +- docs/reference/how-it-works.md | 2 +- examples/order-saga/src/definitions.ts | 18 +- examples/order-saga/src/main.ts | 6 +- examples/order-saga/src/workflows.ts | 24 +- examples/subscription/src/definitions.ts | 18 +- examples/subscription/src/main.ts | 12 +- examples/subscription/src/workflows.ts | 31 +- package.json | 6 +- src/__tests__/continue-as-new.test.ts | 6 +- src/__tests__/definition.test.ts | 178 +++++++++++ src/__tests__/early-return.test.ts | 2 +- src/__tests__/fixtures/batch-demo.ts | 4 +- src/__tests__/fixtures/batch-workflows.ts | 10 +- src/__tests__/fixtures/definition-demo.ts | 105 +++++++ .../fixtures/definition-workflows.ts | 8 + src/__tests__/fixtures/demo-workflows.ts | 3 +- src/__tests__/fixtures/demo.ts | 7 +- src/__tests__/fixtures/lock-demo.ts | 8 +- src/__tests__/fixtures/lock-workflows.ts | 19 +- src/__tests__/fixtures/loop-demo.ts | 7 +- src/__tests__/fixtures/loop-workflows.ts | 16 +- src/__tests__/fixtures/mailbox-demo.ts | 9 +- src/__tests__/fixtures/mailbox-workflows.ts | 8 +- src/__tests__/fixtures/message-demo.ts | 12 +- src/__tests__/fixtures/message-workflows.ts | 14 +- src/__tests__/fixtures/transaction-demo.ts | 4 +- .../fixtures/transaction-workflows.ts | 4 +- src/__tests__/fixtures/typed-activity-demo.ts | 4 +- .../fixtures/typed-activity-workflows.ts | 4 +- src/__tests__/lint.test.ts | 4 + src/__tests__/mailbox.test.ts | 14 +- src/__tests__/message-passing.test.ts | 24 +- src/__tests__/payload-codec.test.ts | 10 +- src/__tests__/primitives.test.ts | 13 +- src/__tests__/prototype/def.ts | 233 -------------- .../prototype/one-declaration.test.ts | 212 ------------- src/__tests__/prototype/order-workflows.ts | 8 - src/__tests__/prototype/order.ts | 83 ----- src/__tests__/prototype/temporal-runtime.ts | 78 ----- .../{prototype => }/schema-evolution.test.ts | 47 +-- src/__tests__/types.test.ts | 24 +- src/definition.ts | 295 ++++++++++++++++++ src/engine-sandbox.ts | 31 +- src/lint.js | 23 +- src/testing.ts | 156 +++++++++ 65 files changed, 1295 insertions(+), 997 deletions(-) create mode 100644 docs/guide/declaring-capabilities.md create mode 100644 src/__tests__/definition.test.ts create mode 100644 src/__tests__/fixtures/definition-demo.ts create mode 100644 src/__tests__/fixtures/definition-workflows.ts delete mode 100644 src/__tests__/prototype/def.ts delete mode 100644 src/__tests__/prototype/one-declaration.test.ts delete mode 100644 src/__tests__/prototype/order-workflows.ts delete mode 100644 src/__tests__/prototype/order.ts delete mode 100644 src/__tests__/prototype/temporal-runtime.ts rename src/__tests__/{prototype => }/schema-evolution.test.ts (58%) create mode 100644 src/definition.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73dd94a..e8506cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,31 @@ interfaces from `effect/unstable/*`, whose API can move between releases. Each r of this package states the one `effect` version it is built and tested against, and tracking a new `effect` release is a new release of this package. -## 0.2.0 (unreleased) +## 0.3.0 (unreleased) + +- NEW: the `definition` module — declare each capability once and use it + directly inside handlers: `defineActivity` (callable: `yield* Charge({ orderId })`), + `defineDeferred` (`.await`), `defineMailbox` (`.take`/`.poll`), + `defineUpdate` (`.take`), `defineState` (`.set`), plus `version` (patch-marker + logic branches) and `evolved` (newest-first schema evolution with pure + migrations). Every primitive requires only the `WorkflowOps` service — the + one seam an engine implements — so handlers import nothing from + `engine-sandbox` and are engine-agnostic. Client-side driving uses the + declaration's underlying primitive (`U.update`, `M.mailbox`, `C.cell`, + `D.deferred`) with the existing `engine-client` ops. +- NEW: `makeTestWorkflowOps` in `testing` — an in-memory `WorkflowOps` + runtime (activities run their `handle` bindings with schema-validated + payloads; deferreds/mailboxes/updates/state driven via + `resolve`/`offer`/`request`/`stateOf`), so the same handler that runs on + Temporal runs in a plain unit test with no engine and no test server. +- `workflowBundle` provides the Temporal `WorkflowOps` runtime to hosted + layers automatically; bundle authoring is otherwise unchanged. +- The repository's fixtures, examples, and docs author with the `definition` + module throughout. The low-level per-primitive calls (`callActivity`, + `takeMailbox`, `pollMailbox`, `takeUpdate`, `setStateCell`) remain + exported from `engine-sandbox` as the machinery underneath. + +## 0.2.0 (2026-08-27) - BREAKING: workflow bundles are authored with `Workflow.toLayer`, hosted behind `workflowBundle(layer)` — one dynamic default export per bundle, the same registration-driven authoring diff --git a/EXAMPLES.md b/EXAMPLES.md index 6d1d9d7..0c827e6 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -25,9 +25,9 @@ workflow-semantics content). | Sample | Status | With this package | | --------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [signals-queries](https://github.com/temporalio/samples-typescript/tree/main/signals-queries) | ✅ | `DurableDeferred.await` (signal) + `deferredState` (query) + `interrupt` (cancellation); custom state reads via `StateCell`. Tests: [primitives.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/primitives.test.ts), [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts). | -| [state](https://github.com/temporalio/samples-typescript/tree/main/state) | ✅ | Both halves: `DurableMailbox` for the repeated update signals, `StateCell` (`setStateCell` / `readStateCell`) for the query — read mid-flight and after completion. Test: [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts). | -| [mutex](https://github.com/temporalio/samples-typescript/tree/main/mutex) | ✅ | Lock workflow loops on `takeMailbox`, granting and collecting releases via workflow-to-workflow `offerMailbox`; the test asserts serialized critical sections across three contenders. Test: [mutex.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mutex.test.ts). | -| [message-passing](https://github.com/temporalio/samples-typescript/tree/main/message-passing) | ✅ | `DurableUpdate` (`make` + `takeUpdate` + `executeUpdate`): request/response with typed success AND typed failure riding the update result. Test: [message-passing.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/message-passing.test.ts). | +| [state](https://github.com/temporalio/samples-typescript/tree/main/state) | ✅ | Both halves: `defineMailbox` for the repeated update signals, `defineState` (`.set` in the handler, `readStateCell` client-side) for the query — read mid-flight and after completion. Test: [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts). | +| [mutex](https://github.com/temporalio/samples-typescript/tree/main/mutex) | ✅ | Lock workflow loops on its mailbox's `.take`, granting and collecting releases via workflow-to-workflow `offerMailbox`; the test asserts serialized critical sections across three contenders. Test: [mutex.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mutex.test.ts). | +| [message-passing](https://github.com/temporalio/samples-typescript/tree/main/message-passing) | ✅ | `defineUpdate` (`.take` in the handler + `executeUpdate` client-side): request/response with typed success AND typed failure riding the update result. Test: [message-passing.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/message-passing.test.ts). | | [query-subscriptions](https://github.com/temporalio/samples-typescript/tree/main/query-subscriptions) | ✅ | `DurableMailbox` feeds the state, `StateCell` reads observe it evolving across repeated queries. Test: [mailbox.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/mailbox.test.ts). | | [expense](https://github.com/temporalio/samples-typescript/tree/main/expense) (async activity completion) | 🟢 | Async completion is activity-side (`CompleteAsyncError` + client), untouched by the shim. | @@ -37,7 +37,7 @@ workflow-semantics content). | ------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [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). | | [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 `takeMailbox` 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, 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). | ## Workflow composition @@ -46,7 +46,7 @@ workflow-semantics content). | [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). | | [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. | -| [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 `pollMailbox` into the carried in-flight set. Test: [batch.test.ts](https://github.com/TeamSpringbird/effect-temporal/blob/main/src/__tests__/batch.test.ts). | +| [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`. | diff --git a/README.md b/README.md index f8a9f8c..e351d97 100644 --- a/README.md +++ b/README.md @@ -17,32 +17,33 @@ The same pages live in [docs/](docs/) (`pnpm docs:dev` to browse locally). import { Effect, Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; -import { callActivity, workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition"; +import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; import { WorkflowClient } from "@springbird/effect-temporal/client"; -// Define once — shared by the workflow bundle, the worker, and every client. +// Declare once — shared by the workflow bundle, the worker, and every client. const OrderFlow = Workflow.make("orderFlow", { payload: { orderId: Schema.String }, idempotencyKey: ({ orderId }) => orderId, success: Schema.String, }); -const Charge = TypedActivity.make("charge", { +const Charge = defineActivity("charge", { payload: { orderId: Schema.String }, success: Schema.String, }); -const ManagerApproval = DurableDeferred.make("manager-approval", { +const ManagerApproval = defineDeferred("manager-approval", { success: Schema.String, }); -// The body is an Effect, running durably inside -// the Temporal sandbox — the same authoring Effect's other engines use: +// The body calls the declarations directly. Its only requirement is the +// WorkflowOps service — workflowBundle provides Temporal's; the testing +// module provides an in-memory one, so the same handler runs in a plain +// unit test with no engine. const OrderFlowLive = OrderFlow.toLayer((payload) => Effect.gen(function* () { - const paid = yield* callActivity(Charge, { orderId: payload.orderId }); + const paid = yield* Charge({ orderId: payload.orderId }); yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" }); - const approver = yield* DurableDeferred.await(ManagerApproval); + const approver = yield* ManagerApproval.await; return `${paid}:approved-by:${approver}`; }), ); @@ -65,20 +66,21 @@ pnpm add @springbird/effect-temporal # or npm / yarn / bun contract over Temporal, for codebases that already run Temporal and do not want a second durable-execution system (Effect's own `effect/unstable/cluster` engine persists to its own SQL tables). -- **One package, tree-shakeable modules** — `@springbird/effect-temporal/engine-sandbox` - (workflow bundle), `/engine-client` + `/client` (ordinary Node), - `/typed-activity` + `/activities` (worker), `/testing`, `/nexus`, `/lint`. - Nexus, worker, and testing peers are optional. +- **One package, tree-shakeable modules** — `@springbird/effect-temporal/definition` + (declare capabilities once, engine-agnostic), `/engine-sandbox` (workflow + bundle), `/engine-client` + `/client` (ordinary Node), `/activities` + (worker), `/testing`, `/nexus`, `/lint`. Nexus, worker, and testing peers + are optional. - **Typed end to end** — payloads, results, and *failures* are schemas at every crossing: workflow results, activity calls, signals, queries, update responses. Typed failures land in the Effect error channel on the reading side; runs show red in the Temporal UI. -- **Entity workflows, complete** — `DurableMailbox` (repeated inbound - signals), `DurableUpdate` (request/response with typed success *and* - failure), `StateCell` (queryable published state, readable after the run - closes), `continueAsNew`, and patch-marker versioning - (`Versioning.match` chains) make the long-lived, observable, mutable - entity expressible end to end. +- **Entity workflows, complete** — `defineMailbox` (repeated inbound + signals), `defineUpdate` (request/response with typed success *and* + failure), `defineState` (queryable published state, readable after the run + closes), `continueAsNew`, patch-marker versioning (`version`), and schema + evolution (`evolved`) make the long-lived, observable, mutable entity + expressible end to end. - **Cancellation that composes** — workflow cancel interrupts the handler fiber (finalizers and `Workflow.withCompensation` run, their activity calls still work), and workflow-internal interruption — `Effect.timeout`, @@ -93,8 +95,10 @@ pnpm add @springbird/effect-temporal # or npm / yarn / bun the workflow sandbox on a microtask scheduler; clocks, randomness, and timers resolve to Temporal's replay-stable primitives. The mechanism is documented in [How the engine works](https://www.effect-temporal.com/reference/how-it-works). -- **Testing story** — `makeFakeTemporalClient` (typed start/signal/ - termination records, loud on everything unstubbed) for service tests, and +- **Testing story** — `makeTestWorkflowOps` (an in-memory `WorkflowOps` + runtime: the same handler that runs on Temporal runs in a plain unit test + with no engine), `makeFakeTemporalClient` (typed start/signal/termination + records, loud on everything unstubbed) for service tests, and `startWorkflowTestHarness` over Temporal's time-skipping test server for real workflow semantics. - **Lint the footguns** — an oxlint/ESLint plugin ships in the package diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index feab0cc..5422b0b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -74,6 +74,7 @@ export default defineConfig({ text: "Author", items: [ { text: "Defining workflows", link: "/guide/defining-workflows" }, + { text: "Declaring capabilities", link: "/guide/declaring-capabilities" }, { text: "Activities", link: "/guide/activities" }, { text: "Timers & approvals", link: "/guide/timers-and-approvals" }, { text: "Child workflows", link: "/guide/child-workflows" }, diff --git a/docs/guide/activities.md b/docs/guide/activities.md index d108a66..a5704e0 100644 --- a/docs/guide/activities.md +++ b/docs/guide/activities.md @@ -4,34 +4,36 @@ All I/O in a workflow body goes through a Temporal activity. Two forms exist, an | Form | Use when | Wire | Failure semantics | | --- | --- | --- | --- | -| **Typed** — `TypedActivity.make` + `callActivity` + `implementActivities` | you implement the activity — **the default** | schema-validated both ways | typed failures land in the Effect error channel, non-retryable; infra errors retry then die | +| **Declared** — `defineActivity` + a direct call + `implementActivities` | you implement the activity — **the default** | schema-validated both ways | typed failures land in the Effect error channel, non-retryable; infra errors retry then die | | **Raw** — `proxyActivities` + `callRawActivity` | the activity is not yours (existing Temporal activities, another team's worker), or you need proxy options per call site | whatever the proxy's functions take, unvalidated | everything thrown is a defect once retries exhaust; no typed channel | Both run under the same per-call cancellation scope. If you find yourself building a typed error channel on top of a raw call, that's the sign you -wanted a `TypedActivity`. +wanted `defineActivity`. ::: info Portability note -Unlike workflow definitions — which are pure upstream API and run on any -engine — `TypedActivity` and `callActivity` are this package's own, and a -workflow using them is Temporal-shaped. That is a deliberate consequence of -Temporal's execution model: upstream `Activity.make` carries its -implementation as a **closure** over workflow state, which other engines can -run in-process, but Temporal executes activities on a separate worker that a -closure cannot reach. `TypedActivity` is the serializable projection that -boundary forces: a name plus schemas, implemented on the worker. Upstream -`Activity.make` still works here as an in-sandbox typed seam (see below) — -it just isn't where I/O can live under Temporal. +A handler that calls declared activities depends on exactly one service — +`WorkflowOps`, the seam an engine implements — and imports nothing +engine-shaped: `workflowBundle` provides the Temporal runtime, the +[in-memory runtime](/guide/testing#the-in-memory-runtime) provides another, +and the same handler runs on both. Underneath the declaration is +`TypedActivity`, the *serializable projection* Temporal's execution model +forces: upstream `Activity.make` carries its implementation as a **closure** +over workflow state, which an in-process engine can run, but Temporal +executes activities on a separate worker that a closure cannot reach — so +what crosses the boundary must be a name plus schemas, implemented on the +worker. Upstream `Activity.make` still works here as an in-sandbox typed +seam (see below) — it just isn't where I/O can live under Temporal. ::: -## Typed activities +## Declared activities -Declare once; the definition is temporal-free and loads in the sandbox bundle, the worker, and clients alike. +Declare once; the declaration is temporal-free and loads in the sandbox bundle, the worker, and clients alike. ```ts -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; +import { defineActivity } from "@springbird/effect-temporal/definition"; -export const Reserve = TypedActivity.make("reserve", { +export const Reserve = defineActivity("reserve", { payload: { sku: Schema.String, quantity: Schema.Finite }, success: Schema.String, error: Schema.TaggedStruct("OutOfStock", { sku: Schema.String }), @@ -39,16 +41,14 @@ export const Reserve = TypedActivity.make("reserve", { }); ``` -Call it from a workflow body with `callActivity`: +Call it directly from a workflow body: ```ts -import { callActivity } from "@springbird/effect-temporal/engine-sandbox"; - -const reservation = yield* callActivity(Reserve, { sku, quantity: 1 }); +const reservation = yield* Reserve({ sku, quantity: 1 }); // reservation: string; error channel: { _tag: "OutOfStock"; sku: string } ``` -The payload is schema-encoded onto the wire and validated before the implementation runs; the result decodes back; a typed failure lands in the Effect error channel. +The payload is schema-encoded onto the wire and validated before the implementation runs; the result decodes back; a typed failure lands in the Effect error channel. The call requires only `WorkflowOps` — no imports from the sandbox module. ### Failure and retry semantics @@ -59,7 +59,7 @@ The line between the two failure kinds is the line between domain outcomes and i ### Implementing on the worker -`implementActivities` binds definitions to Effect handlers over an `ActivityRunner` — the seam where your runtime, spans, and error reporting live: +`implementActivities` binds declarations to Effect handlers over an `ActivityRunner` — the seam where your runtime, spans, and error reporting live. A declared activity *is* its `TypedActivity` (name plus schemas), so `handle` takes it directly: ```ts import { handle, implementActivities, type ActivityRunner } from "@springbird/effect-temporal/activities"; @@ -110,14 +110,14 @@ const paid = yield* Activity.make({ }); ``` -Be clear about what that wrapper is: under this engine `Activity.make` is a **typed seam in the Effect program, not a Temporal Activity** — it gives the step a name, Effect-level success/error schemas, and `Activity.CurrentAttempt` in context, while durability still comes entirely from the `callRawActivity` inside it. It does **not** validate the wire or create a typed failure channel from the worker — that's what `TypedActivity` is for. Wrap a raw call when the step deserves a name and a schema'd shape in your program (the fixtures do it because they mirror the upstream API's style); call `callRawActivity` bare when it doesn't. +Be clear about what that wrapper is: under this engine `Activity.make` is a **typed seam in the Effect program, not a Temporal Activity** — it gives the step a name, Effect-level success/error schemas, and `Activity.CurrentAttempt` in context, while durability still comes entirely from the `callRawActivity` inside it. It does **not** validate the wire or create a typed failure channel from the worker — that's what `defineActivity` is for. Wrap a raw call when the step deserves a name and a schema'd shape in your program (the fixtures do it because they mirror the upstream API's style); call `callRawActivity` bare when it doesn't. ## Cancellation reaches the server -Both `callActivity` and `callRawActivity` run under a per-call cancellation scope. When the calling fiber is interrupted — the workflow is cancelled, an `Effect.timeout` fires, an `Effect.race` is lost — the in-flight server-side activity is **cancelled**, not abandoned: +Declared activity calls and `callRawActivity` run under a per-call cancellation scope. When the calling fiber is interrupted — the workflow is cancelled, an `Effect.timeout` fires, an `Effect.race` is lost — the in-flight server-side activity is **cancelled**, not abandoned: ```ts -const result = yield* callActivity(Slow, payload).pipe( +const result = yield* Slow(payload).pipe( Effect.timeoutOption("30 seconds"), ); // None on timeout; the server-side activity receives a cancellation request ``` diff --git a/docs/guide/cancellation.md b/docs/guide/cancellation.md index 5e2934e..e2966d7 100644 --- a/docs/guide/cancellation.md +++ b/docs/guide/cancellation.md @@ -25,9 +25,9 @@ Interrupting a closed or unknown execution is a no-op. For a hard stop that skip `Workflow.withCompensation` registers an undo step that runs if the workflow later fails or is cancelled — the saga pattern, in ordinary Effect: ```ts -const reservation = yield* callActivity(Reserve, { sku }).pipe( +const reservation = yield* Reserve({ sku }).pipe( Workflow.withCompensation((value) => - callActivity(Release, { reservation: value }).pipe(Effect.asVoid), + Release({ reservation: value }).pipe(Effect.asVoid), ), ); ``` @@ -39,7 +39,7 @@ On a typed failure or an interrupt downstream, `Release` runs during the unwind. Interruption inside the body composes the same way. When `Effect.timeout` fires or an `Effect.race` is lost, the interrupted fiber's in-flight call is **cancelled server-side** while the run itself continues: ```ts -const fast = yield* callActivity(Slow, payload).pipe(Effect.timeoutOption("30 seconds")); +const fast = yield* Slow(payload).pipe(Effect.timeoutOption("30 seconds")); // None on timeout — and the server-side activity received a cancel request, // visible as ActivityTaskCancelRequested in history. ``` diff --git a/docs/guide/child-workflows.md b/docs/guide/child-workflows.md index 040003b..8648b1b 100644 --- a/docs/guide/child-workflows.md +++ b/docs/guide/child-workflows.md @@ -5,7 +5,7 @@ Calling one workflow's `execute` inside another's body starts a Temporal **child ```ts const ParentDemoLive = ParentDemo.toLayer((payload) => Effect.gen(function* () { - const reservation = yield* callActivity(Reserve, { sku: payload.sku, quantity: 1 }); + 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. diff --git a/docs/guide/continue-as-new.md b/docs/guide/continue-as-new.md index 6ffdc9c..d34a3c8 100644 --- a/docs/guide/continue-as-new.md +++ b/docs/guide/continue-as-new.md @@ -7,7 +7,7 @@ import { continueAsNew } from "@springbird/effect-temporal/engine-sandbox"; const LoopDemoLive = LoopDemo.toLayer((payload) => Effect.gen(function* () { - yield* callActivity(Record, { iteration: payload.iteration }); + yield* Record({ iteration: payload.iteration }); if (payload.iteration >= 2) return `done:${payload.iteration}`; // Ends this run; the next starts with the carried payload. return yield* continueAsNew(LoopDemo, { @@ -32,12 +32,12 @@ Like Temporal's native API, continue-as-new ends the run by throwing: Effect **f The new run starts with fresh state: -- **Mailbox buffers.** Messages offered but not yet taken are gone. Drain with `pollMailbox` into carried state first: +- **Mailbox buffers.** Messages offered but not yet taken are gone. Drain with the mailbox's `.poll` into carried state first: ```ts let pending: Report[] = []; while (true) { - const next = yield* pollMailbox(Reports); + const next = yield* Reports.poll; if (Option.isNone(next)) break; pending.push(next.value); } diff --git a/docs/guide/declaring-capabilities.md b/docs/guide/declaring-capabilities.md new file mode 100644 index 0000000..c7e1350 --- /dev/null +++ b/docs/guide/declaring-capabilities.md @@ -0,0 +1,57 @@ +# Declaring capabilities + +Everything a workflow body uses — activities, approvals, mailboxes, updates, state cells — is declared **once** with the `definition` module, and called directly inside the handler: + +```ts +import { Effect, Schema } from "effect"; +import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition"; + +export const Charge = defineActivity("charge", { + payload: { orderId: Schema.String }, + success: Schema.String, +}); +export const Approval = defineDeferred("order/approval", { + success: Schema.String, +}); + +const OrderFlowLive = OrderFlow.toLayer((payload) => + Effect.gen(function* () { + const receipt = yield* Charge({ orderId: payload.orderId }); + const approver = yield* Approval.await; + return `${receipt}:by:${approver}`; + }), +); +``` + +One declaration is the whole contract: the workflow bundle calls it, the worker implements it, every client drives it. A misspelled name or drifted payload shape is a compile error. + +## 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/*`. + +- **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. +- **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 + +| 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)) | + +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. + +## Wire identity is the name + +The explicit name string — `"charge"`, `"order/approval"` — is the identity on the wire: the Temporal activity type, signal payload discriminator, query key, patch-marker site. Renaming a variable, moving a declaration to another module, or restructuring the handler never changes the wire; changing the *name* does, and is a versioning event. + +## Evolving a declaration + +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). +- **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/defining-workflows.md b/docs/guide/defining-workflows.md index f7d4991..2e7fa0b 100644 --- a/docs/guide/defining-workflows.md +++ b/docs/guide/defining-workflows.md @@ -36,7 +36,7 @@ export default workflowBundle(Layer.mergeAll(OrderFlowLive /*, ... */)); This is the same registration-driven authoring Effect's cluster and in-memory engines use — the workflow code is engine-agnostic, and choosing Temporal is choosing this default export plus the client half's engine layer. Handlers can require services provided by ordinary Layers composed into the registration environment (`Layer.provide` on the merged layer). -The handler receives the decoded payload and the execution id. Everything effectful must reach the outside world through an activity call — see [Activities](/guide/activities) and the [authoring rules](/guide/lint-rules). +The handler receives the decoded payload and the execution id. Everything effectful must reach the outside world through an activity call — see [Declaring capabilities](/guide/declaring-capabilities), [Activities](/guide/activities), and the [authoring rules](/guide/lint-rules). ## One definition, three call sites diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index c70b28e..89b7966 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -26,9 +26,9 @@ A definition is a tag, a payload schema, an idempotency key, and success/error s // definitions.ts — shared by the bundle and every client import { Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; +import { defineActivity } from "@springbird/effect-temporal/definition"; -export const Reserve = TypedActivity.make("reserve", { +export const Reserve = defineActivity("reserve", { payload: { sku: Schema.String, quantity: Schema.Finite }, success: Schema.String, error: Schema.TaggedStruct("OutOfStock", { sku: Schema.String }), @@ -51,17 +51,15 @@ The body is an Effect that runs inside the Temporal workflow sandbox. Workflows // workflows.ts — the workflow bundle (Temporal's workflowsPath points here) import { Effect } from "effect"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import { callActivity, workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; import { OrderFlow, Reserve } from "./definitions.js"; const OrderFlowLive = OrderFlow.toLayer((payload) => Effect.gen(function* () { - // A typed activity: payload validated, result decoded, typed failure - // lands in the error channel. Retries are Temporal's, per the options. - const reservation = yield* callActivity(Reserve, { - sku: payload.sku, - quantity: 1, - }); + // A declared activity, called directly: payload validated, result + // 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" }); return `reserved:${reservation}`; }), @@ -138,6 +136,7 @@ That's the whole loop: definition → body → worker → client, with schemas h - **The runnable examples** — each boots its own local Temporal dev server (`pnpm run build`, then `pnpm --dir examples/ start`): [`examples/order-saga`](https://github.com/TeamSpringbird/effect-temporal/tree/main/examples/order-saga) is the one-shot saga (typed activities, compensation, approval, queryable state, idempotent attach, cancellation); [`examples/subscription`](https://github.com/TeamSpringbird/effect-temporal/tree/main/examples/subscription) is the long-lived entity (billing cycles, typed updates, mailbox cancellation, continue-as-new). - [Defining workflows](/guide/defining-workflows): idempotency, execution ids, start semantics. -- [Activities](/guide/activities): typed activities, raw calls, failure and retry semantics. -- [Testing your app](/guide/testing): the typed fake client and the live harness. +- [Declaring capabilities](/guide/declaring-capabilities): the `definition` module — one declaration per capability, one seam (`WorkflowOps`) engines implement. +- [Activities](/guide/activities): declared activities, raw calls, failure and retry semantics. +- [Testing your app](/guide/testing): the in-memory runtime, the typed fake client, and the live harness. - [Lint rules](/guide/lint-rules): catch the authoring footguns mechanically. diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index b755837..0613b8c 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -14,28 +14,28 @@ 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 primitives in the same definition-first style: `DurableMailbox` (repeated inbound signals), `DurableUpdate` (request/response with typed channels), `StateCell` (queryable published state), `continueAsNew` (unbounded workflows), patch-marker versioning, 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), plus `continueAsNew` (unbounded workflows), `version`/`evolved` (logic and schema evolution), 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/engine-sandbox` | the workflow bundle | `workflowBundle`, activity calls, mailbox/update/state-cell operations | +| `@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/engine-client` | ordinary Node | the client-side engine + standalone read/signal operations | | `@springbird/effect-temporal/client` | ordinary Node | `WorkflowClient` — the one client service | -| `@springbird/effect-temporal/typed-activity` | both | schema-typed activity definitions | -| `@springbird/effect-temporal/activities` | worker registration | typed-activity implementation tables + the attach bridge | -| `@springbird/effect-temporal/mailbox`, `/update`, `/state-cell` | both | durable-primitive definitions | -| `@springbird/effect-temporal/versioning` | the workflow bundle | patch-marker version chains | +| `@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/nexus` | worker registration | workflow-backed Nexus operations | -| `@springbird/effect-temporal/testing` | tests | a typed fake Temporal client + a live test harness | +| `@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 | ## Why Effect-native matters Temporal's TypeScript SDK gives you durable execution with untyped seams: workflow arguments, results, signals, and failures all travel as loosely-typed payloads, and failure means catching `ApplicationFailure` and inspecting strings. effect-temporal keeps every one of those seams schema-typed: -- **Engine-agnostic authoring.** Workflows register with `Workflow.toLayer` and are hosted behind one dynamic default export (`workflowBundle`) — the identical workflow code runs on Effect's cluster engine, the in-memory test engine, or Temporal. Choosing a backend is choosing a Layer. +- **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. - **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 d4f7e9a..7376745 100644 --- a/docs/guide/lint-rules.md +++ b/docs/guide/lint-rules.md @@ -6,7 +6,7 @@ The workflow sandbox has authoring rules that a linter can see; this package shi The whole Effect program runs inside the Temporal workflow sandbox. `Activity.make` is a typed seam, not a Temporal Activity — durability comes from the Temporal activity proxies, timers, and signals the effects call, each memoized in history. Hence: -1. **All I/O goes through an activity proxy via `callActivity` / `callRawActivity`.** Anything else is nondeterministic on replay. A raw `Effect.promise(() => acts.foo())` works but is not cancelled on interrupt. +1. **All I/O goes through a Temporal activity — a [declared activity](/guide/declaring-capabilities) call (`yield* Charge(payload)`) or `callRawActivity`.** Anything else is nondeterministic on replay. A raw `Effect.promise(() => acts.foo())` works but is not cancelled on interrupt. 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. diff --git a/docs/guide/mailboxes.md b/docs/guide/mailboxes.md index c6c9383..d68061a 100644 --- a/docs/guide/mailboxes.md +++ b/docs/guide/mailboxes.md @@ -1,12 +1,12 @@ # Mailboxes -A `DurableMailbox` is a durable inbound message queue for a workflow — the repeated-signal counterpart to the one-shot [`DurableDeferred`](/guide/timers-and-approvals#approvals-durabledeferred). Deliveries ride a Temporal signal (recorded in history, so consumption is deterministic on replay) and buffer until the workflow takes them. +A mailbox is a durable inbound message queue for a workflow — the repeated-signal counterpart to the one-shot [deferred](/guide/timers-and-approvals#approvals-definedeferred). Deliveries ride a Temporal signal (recorded in history, so consumption is deterministic on replay) and buffer until the workflow takes them. ```ts // definitions — shared by the workflow body and every offering side -import * as DurableMailbox from "@springbird/effect-temporal/mailbox"; +import { defineMailbox } from "@springbird/effect-temporal/definition"; -export const StateUpdates = DurableMailbox.make("state-updates", { +export const StateUpdates = defineMailbox("state-updates", { payload: Schema.Union([ Schema.Struct({ op: Schema.Literal("set"), key: Schema.String, value: Schema.Finite }), Schema.Struct({ op: Schema.Literal("del"), key: Schema.String }), @@ -17,20 +17,18 @@ export const StateUpdates = DurableMailbox.make("state-updates", { ## Taking messages (workflow side) -`takeMailbox` durably awaits the next message, in delivery order. The long-lived entity loop is the canonical shape: +`.take` durably awaits the next message, in delivery order. The long-lived entity loop is the canonical shape: ```ts -import { setStateCell, takeMailbox } from "@springbird/effect-temporal/engine-sandbox"; - const StateDemoLive = StateDemo.toLayer(() => Effect.gen(function* () { const state = new Map(); while (true) { - const update = yield* takeMailbox(StateUpdates); + const update = yield* StateUpdates.take; if (update.op === "finish") break; if (update.op === "set") state.set(update.key, update.value); else state.delete(update.key); - yield* setStateCell(StateSnapshot, Object.fromEntries(state)); + yield* StateSnapshot.set(Object.fromEntries(state)); } return "done"; }), @@ -39,15 +37,15 @@ const StateDemoLive = StateDemo.toLayer(() => The claim happens synchronously on the taking fiber after the wait, so an interrupted take never steals a message from a later one. Race a take against a timer for deadline patterns — see [Timers & approvals](/guide/timers-and-approvals#composing-time-with-messages). -`pollMailbox` takes without waiting — `None` when the buffer is empty. Its canonical use is draining buffers before [`continueAsNew`](/guide/continue-as-new#what-does-not-survive-the-run-change), since buffered messages do not survive the run change. +`.poll` takes without waiting — `None` when the buffer is empty. Its canonical use is draining buffers before [`continueAsNew`](/guide/continue-as-new#what-does-not-survive-the-run-change), since buffered messages do not survive the run change. ## Offering messages -From a **client** — via the `WorkflowClient` service: +Offering sides address the declaration's underlying primitive, `StateUpdates.mailbox`. From a **client** — via the `WorkflowClient` service: ```ts const wf = yield* WorkflowClient; -yield* wf.offerMailbox(StateUpdates, workflowId, { op: "set", key: "a", value: 1 }); +yield* wf.offerMailbox(StateUpdates.mailbox, workflowId, { op: "set", key: "a", value: 1 }); ``` From **another workflow** (workflow → workflow): @@ -55,7 +53,7 @@ From **another workflow** (workflow → workflow): ```ts import { offerMailbox } from "@springbird/effect-temporal/engine-sandbox"; -yield* offerMailbox(Reports, { workflowId: orchestratorId, payload: report }); +yield* offerMailbox(Reports.mailbox, { workflowId: orchestratorId, payload: report }); ``` 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. @@ -65,4 +63,4 @@ Offers are **fire-and-forget**: offering to a closed or unknown execution is a n - Messages are delivered in signal order, per mailbox. - Buffered messages survive worker crashes and replays — they are history, not memory. - Buffered messages do **not** survive `continueAsNew` — drain first. -- A message that fails the payload schema — raw signal access, a drifted producer, a schema tightened while old messages sat buffered in history — is **dropped at take time with a worker-log warning**, matching the fire-and-forget delivery contract rather than poisoning the run. (`pollMailbox` drops the same way.) +- A message that fails the payload schema — raw signal access, a drifted producer, a schema tightened while old messages sat buffered in history — is **dropped at take time with a worker-log warning**, matching the fire-and-forget delivery contract rather than poisoning the run. (`.poll` drops the same way.) diff --git a/docs/guide/queryable-state.md b/docs/guide/queryable-state.md index aa9808a..e585270 100644 --- a/docs/guide/queryable-state.md +++ b/docs/guide/queryable-state.md @@ -1,12 +1,12 @@ # Queryable state -A `StateCell` makes workflow state observable from outside: the workflow publishes typed snapshots to a named cell, and clients read the latest one through a Temporal query — mid-flight or **after the run has closed**. +A state cell makes workflow state observable from outside: the workflow publishes typed snapshots to a named cell, and clients read the latest one through a Temporal query — mid-flight or **after the run has closed**. ```ts // definitions -import * as StateCell from "@springbird/effect-temporal/state-cell"; +import { defineState } from "@springbird/effect-temporal/definition"; -export const CurrentLanguage = StateCell.make("current-language", { +export const CurrentLanguage = defineState("current-language", { value: Schema.String, }); ``` @@ -14,23 +14,23 @@ export const CurrentLanguage = StateCell.make("current-language", { ## Publishing (workflow side) ```ts -import { setStateCell } from "@springbird/effect-temporal/engine-sandbox"; - -yield* setStateCell(CurrentLanguage, "english"); +yield* CurrentLanguage.set("english"); ``` Each publish replaces the previous snapshot. Publish after every state change you want observers to see. ## Reading (client side) +Readers address the declaration's underlying primitive, `CurrentLanguage.cell`: + ```ts const wf = yield* WorkflowClient; -const snapshot = yield* wf.readStateCell(CurrentLanguage, workflowId); +const snapshot = yield* wf.readStateCell(CurrentLanguage.cell, 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, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. +Without the service, the standalone form is `readStateCell(CurrentLanguage.cell, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. ## Why snapshots, not query functions @@ -47,11 +47,11 @@ Updates, a state cell, and an approval compose into the long-lived observable en const MessageDemoLive = MessageDemo.toLayer(() => Effect.gen(function* () { let language: string = SUPPORTED_LANGUAGES[0]; - yield* setStateCell(CurrentLanguage, language); + yield* CurrentLanguage.set(language); while (true) { const winner = yield* Effect.raceFirst( - takeUpdate(SetLanguage).pipe(Effect.map((request) => ({ kind: "update" as const, request }))), - DurableDeferred.await(Approved).pipe(Effect.map((approver) => ({ kind: "approved" as const, approver }))), + SetLanguage.take.pipe(Effect.map((request) => ({ kind: "update" as const, request }))), + Approved.await.pipe(Effect.map((approver) => ({ kind: "approved" as const, approver }))), ); if (winner.kind === "approved") return `approved:${language} by ${winner.approver}`; @@ -59,7 +59,7 @@ const MessageDemoLive = MessageDemo.toLayer(() => if ((SUPPORTED_LANGUAGES as readonly string[]).includes(requested)) { yield* winner.request.respond(Exit.succeed(language)); language = requested; - yield* setStateCell(CurrentLanguage, language); + yield* CurrentLanguage.set(language); } else { yield* winner.request.respond(Exit.fail(`unsupported:${requested}`)); } diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 412b3cd..24bef94 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -1,11 +1,51 @@ # Testing your app -Two testing stories ship in `@springbird/effect-temporal/testing`, for two kinds of test: +Three testing stories ship in `@springbird/effect-temporal/testing`, for three kinds of test: +- **No engine at all** — `makeTestWorkflowOps`, an in-memory `WorkflowOps` runtime: the same handler that runs on Temporal runs in a plain unit test, driven directly. - **Temporal as a seam** — a typed in-memory fake of the Temporal client, for fast service tests that assert *what was started, signalled, terminated*. - **Real workflow semantics** — a harness over Temporal's own test server (time-skipping timers, real retries, continue-as-new), for tests that run the actual workflow. -Both need the optional peers `@temporalio/testing` and `@temporalio/worker` only for the harness path. +The optional peers `@temporalio/testing` and `@temporalio/worker` are needed only for the harness path. + +## The in-memory runtime + +A handler authored against [declared capabilities](/guide/declaring-capabilities) requires exactly one service, `WorkflowOps`. `makeTestWorkflowOps` builds an in-memory implementation plus the client half of every declaration — a small world your test drives the way a real client would: + +```ts +import { Effect, Fiber } from "effect"; +import { handle } from "@springbird/effect-temporal/activities"; +import { 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"))], +}); + +// The SAME handler that workflowBundle hosts on Temporal: +const fiber = yield* Effect.forkChild( + orderHandler({ orderId: "o-1" }).pipe(Effect.provide(world.layer)), +); + +const previous = yield* world.request(SetAmount, { amountCents: 2500 }); // update: typed response +yield* world.offer(Priority, { level: 2 }); // mailbox message +yield* world.resolve(Approval, "ben"); // deferred completion +const phase = yield* world.stateOf(Status); // Option of last .set + +const result = yield* Fiber.join(fiber); +``` + +The world's surface: + +- **`layer`** — provides `WorkflowOps` backed by this world; provide it to the handler. +- **`resolve(deferred, value)`** — resolves a declared deferred, waking a handler blocked on `.await`. +- **`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`. + +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. + +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. ## The fake client @@ -90,8 +130,9 @@ expect(fake.starts[0].args[0]).toEqual(encodeWorkflowPayload(OrderFlow, payload) | Test | Tool | | --- | --- | +| "the handler's logic is right — branches, messages, typed refusals" | `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 | -| "mailboxes, updates, continue-as-new behave" | live harness | +| "mailboxes, updates, continue-as-new behave under real Temporal" | live harness | | "schedules / Nexus wiring works" | live harness, `mode: "local"` | diff --git a/docs/guide/timers-and-approvals.md b/docs/guide/timers-and-approvals.md index 3fbd221..12c9d77 100644 --- a/docs/guide/timers-and-approvals.md +++ b/docs/guide/timers-and-approvals.md @@ -34,25 +34,27 @@ The timestamp is epoch milliseconds or a date-time string that **carries its zon 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. ::: -## Approvals: DurableDeferred +## Approvals: defineDeferred -A `DurableDeferred` is a one-shot typed completion an outside party resolves — the "wait for a human" primitive. +A deferred is a one-shot typed completion an outside party resolves — the "wait for a human" primitive. ```ts // definitions -export const ManagerApproval = DurableDeferred.make("manager-approval", { +import { defineDeferred } from "@springbird/effect-temporal/definition"; + +export const ManagerApproval = defineDeferred("manager-approval", { success: Schema.String, }); // workflow body: blocks durably on a Temporal signal -const approver = yield* DurableDeferred.await(ManagerApproval); +const approver = yield* ManagerApproval.await; ``` -Complete it from any client — `DurableDeferred.done` rides a signal: +Complete it from any client — `DurableDeferred.done` rides a signal, addressed to the declaration's underlying primitive, `ManagerApproval.deferred`: ```ts -yield* DurableDeferred.done(ManagerApproval, { - token: DurableDeferred.tokenFromExecutionId(ManagerApproval, { +yield* DurableDeferred.done(ManagerApproval.deferred, { + token: DurableDeferred.tokenFromExecutionId(ManagerApproval.deferred, { workflow: OrderFlow, executionId, }), @@ -68,11 +70,11 @@ 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, workflowId); +const state = yield* wf.deferredState(ManagerApproval.deferred, workflowId); // Option.none() while pending or unknown; Option.some(typed exit) once completed ``` -Without the service, the standalone form is `deferredState(ManagerApproval, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. +Without the service, the standalone form is `deferredState(ManagerApproval.deferred, { client, workflowId })` from `@springbird/effect-temporal/engine-client`. ## Composing time with messages @@ -83,7 +85,7 @@ let deadlineMillis = payload.initialMillis; let updates = 0; while (true) { const winner = yield* Effect.raceFirst( - takeMailbox(DeadlineUpdates).pipe(Effect.map((u) => ({ kind: "update" as const, u }))), + DeadlineUpdates.take.pipe(Effect.map((u) => ({ kind: "update" as const, u }))), DurableClock.sleep({ name: `deadline-${updates}`, duration: `${deadlineMillis} millis`, diff --git a/docs/guide/updates.md b/docs/guide/updates.md index 4ea7d54..3f51488 100644 --- a/docs/guide/updates.md +++ b/docs/guide/updates.md @@ -1,12 +1,12 @@ # Updates -A `DurableUpdate` is request/response into a running workflow — Temporal updates with typed channels. Unlike a fire-and-forget [mailbox](/guide/mailboxes) message, the caller blocks for the workflow's answer and receives the handler's typed success **or typed failure** back in its own Effect channels. +An update is request/response into a running workflow — Temporal updates with typed channels. Unlike a fire-and-forget [mailbox](/guide/mailboxes) message, the caller blocks for the workflow's answer and receives the handler's typed success **or typed failure** back in its own Effect channels. ```ts // definitions -import * as DurableUpdate from "@springbird/effect-temporal/update"; +import { defineUpdate } from "@springbird/effect-temporal/definition"; -export const SetLanguage = DurableUpdate.make("set-language", { +export const SetLanguage = defineUpdate("set-language", { payload: Schema.Struct({ language: Schema.String }), success: Schema.String, // the previous language error: Schema.String, // "unsupported:" @@ -15,14 +15,12 @@ export const SetLanguage = DurableUpdate.make("set-language", { ## Serving requests (workflow side) -`takeUpdate` durably awaits the next request, in delivery order; each request carries a one-shot `respond`: +`.take` durably awaits the next request, in delivery order; each request carries a one-shot `respond`: ```ts -import { takeUpdate } from "@springbird/effect-temporal/engine-sandbox"; - let language = "english"; while (true) { - const request = yield* takeUpdate(SetLanguage); + const request = yield* SetLanguage.take; const requested = request.payload.language; if (SUPPORTED.includes(requested)) { yield* request.respond(Exit.succeed(language)); // answer: the previous value @@ -42,9 +40,11 @@ Rules of the road: ## Calling (client side) +Callers address the declaration's underlying primitive, `SetLanguage.update`: + ```ts const wf = yield* WorkflowClient; -const previous = yield* wf.executeUpdate(SetLanguage, workflowId, { language: "french" }); +const previous = yield* wf.executeUpdate(SetLanguage.update, 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, { +const previous = yield* executeUpdate(SetLanguage.update, { client, workflowId, payload: { language: "french" }, @@ -69,6 +69,6 @@ A request that fails the payload schema is the caller's bug: it is **answered wi | | delivery | response | closed execution | | --- | --- | --- | --- | -| `DurableDeferred` | one-shot signal | none (it *is* the workflow's input) | no-op | -| `DurableMailbox` | repeated signals, ordered | none | no-op | -| `DurableUpdate` | repeated updates, ordered | typed success/failure per request | defect (interruption if cancelled while pending) | +| deferred (`defineDeferred`) | one-shot signal | none (it *is* the workflow's input) | no-op | +| mailbox (`defineMailbox`) | repeated signals, ordered | none | no-op | +| update (`defineUpdate`) | repeated updates, ordered | typed success/failure per request | defect (interruption if cancelled while pending) | diff --git a/docs/guide/versioning.md b/docs/guide/versioning.md index 710da9a..19a5a7e 100644 --- a/docs/guide/versioning.md +++ b/docs/guide/versioning.md @@ -2,7 +2,37 @@ Changing workflow code while executions are in flight is the sharpest knife in any durable-execution system: Temporal **replays** a workflow's history through your current code, and code that would produce different commands than the history records fails the replay — or worse, silently corrupts it. -effect-temporal wraps Temporal's patch markers in a composable, Effect-shaped form: a **version chain** per code site. +Versioning has two halves. **Logic** changes at a code site use `version`; **data** changes in a declared schema use `evolved`. Both live in the [definition module](/guide/declaring-capabilities). + +## Logic: `version` + +`version(site, names)` selects one of the named behaviors at a code site, backed by Temporal patch markers: + +```ts +import { version } from "@springbird/effect-temporal/definition"; + +const pricing = yield* version("pricing", ["v1", "v2"]); +const result = pricing === "v2" ? yield* revisedPricing : yield* originalPricing; +``` + +- the **first name is the original behavior**, guarded by no marker — histories from before the site adopted versioning replay through it; +- each later name is guarded by its own patch marker (`pricing-v2`, …); +- **fresh executions answer the newest name** and record only its marker; +- **replays answer the name their history's marker selects**; +- engines without replay — the [in-memory test runtime](/guide/testing#the-in-memory-runtime) — always answer the newest name. + +The result is typed as the literal union of exactly the names given, so a `switch` over it is exhaustive. + +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 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: ```ts import * as Versioning from "@springbird/effect-temporal/versioning"; @@ -13,30 +43,42 @@ const result = yield* Versioning.match("pricing", [ ]); ``` -- the **first case is the original behavior**, guarded by no marker — histories from before the site adopted versioning replay through it; -- each later case is guarded by its own patch marker (`pricing-v2`, …); -- **fresh executions take the newest case** and record only its marker; -- **replays take the case their history's marker selects**; -- result, error, and service channels are unioned across cases. +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. -Evolving a site is appending a case. Adopting `match` on an existing workflow is safe. +### Rules -## The lifecycle of a case +**Evaluate a site's version at a deterministic point on the main workflow fiber** — never inside `Effect.fork*`, `Effect.race*`, or `Effect.all` branches. Marker order is part of history; racing fibers make it nondeterministic. The [lint rule](/guide/lint-rules) `versioning-on-main-fiber` catches this shape. -1. **Append** `{ version: "v3", run: newBehavior }`. Deploy. Fresh runs take v3; in-flight runs keep replaying their recorded case. -2. **Retire** an old case only after every history carrying its marker has closed: remove it from the chain 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. +**A site re-evaluated in a later workflow task of the same run may advance** to a newer version (Temporal semantics). Evaluate once, keep the result, where consistency across the run matters: -The primitives underneath are exported too: `Versioning.version(site, names)` returns the selected name as a literal for branching by hand, `patched(id)` is the raw boolean guard, and `deprecatePatch(id)` is Temporal's phase-two marker. +```ts +const pricingVersion = yield* version("pricing", ["v1", "v2"]); +// ... branch on pricingVersion wherever needed, without re-evaluating +``` -## Rules +**Version discipline applies even where replay passes.** Temporal's replay check compares command kind and activity type, not arguments — an argument-only change replays "clean" against old histories while silently corrupting them. If a change alters what an activity is asked to do, it needs a version even though the replayer won't complain. -**Evaluate a site's version at a deterministic point on the main workflow fiber** — never inside `Effect.fork*`, `Effect.race*`, or `Effect.all` branches. Marker order is part of history; racing fibers make it nondeterministic. The [lint rule](/guide/lint-rules) `versioning-on-main-fiber` catches this shape. +## Schema evolution: `evolved` -**A site re-evaluated in a later workflow task of the same run may advance** to a newer version (Temporal semantics). Evaluate once, keep the result, where consistency across the run matters: +The data half: a declaration's schema can evolve — add or change fields — while old runs are in flight. Every boundary is schema-encoded JSON, and every decode happens deterministically on replay, so the whole problem reduces to: the **current schema must decode the wire old code wrote**. `evolved` makes that a declaration-level concern: ```ts -const pricingVersion = yield* Versioning.version("pricing", ["v1", "v2"]); -// ... branch on pricingVersion wherever needed, without re-evaluating +import { evolved } from "@springbird/effect-temporal/definition"; + +// V1 shipped without `priority`; V2 adds it. In-flight runs hold V1 wire +// in their histories (start events, activity results, buffered signals). +const OrderV1 = Schema.Struct({ orderId: Schema.String }); +const OrderV2 = Schema.Struct({ orderId: Schema.String, priority: Schema.Finite }); + +const OrderPayload = evolved(OrderV2, OrderV1, (v1) => ({ ...v1, priority: 0 })); ``` -**Version discipline applies even where replay passes.** Temporal's replay check compares command kind and activity type, not arguments — an argument-only change replays "clean" against old histories while silently corrupting them. If a change alters what an activity is asked to do, it needs a version case even though the replayer won't complain. +Use `OrderPayload` anywhere a schema goes — a workflow payload, an activity's `success`, a mailbox's `payload`. The contract: + +- decode tries `current` first, then `legacy` migrated forward through the function; +- the migration is a **pure** function — purity is what keeps replay deterministic; +- encoding always writes the newest shape; the legacy shape is never written again; +- handler types only ever see the newest `Type`; +- wire that matches *no* generation fails loudly instead of guessing. + +Chain `evolved` calls for further generations: `evolved(V3, evolved(V2, V1, migrate12), migrate23)`. diff --git a/docs/index.md b/docs/index.md index fee6d4b..f3a6f0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,8 +21,8 @@ features: details: Workflows are schema-typed definitions, bodies are Effects, failures are typed channels. The whole Effect program runs deterministically inside the Temporal workflow sandbox. - title: Temporal underneath details: Durability comes from real Temporal primitives — activities, timers, signals, updates, queries, schedules, Nexus — each memoized in history and visible in the Temporal UI. - - title: One definition, both sides - details: Workflows, activities, mailboxes, updates, and state cells are declared once and shared by the workflow bundle, the worker, and every client — the two sides cannot drift. + - title: One declaration, every side + details: Workflows, activities, mailboxes, updates, and state cells are declared once with define* and shared by the workflow bundle, the worker, and every client — the sides cannot drift. Handlers depend on one seam (WorkflowOps), so the same handler runs on Temporal or in a plain unit test. - title: Built for entities details: Long-lived, observable, mutable entity workflows are expressible end to end — repeated signals, request/response updates, queryable snapshots, continue-as-new, patch-marker versioning. --- @@ -33,32 +33,32 @@ features: import { Effect, Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; -import { callActivity, workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; +import { defineActivity, defineDeferred } from "@springbird/effect-temporal/definition"; +import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; import { WorkflowClient } from "@springbird/effect-temporal/client"; -// 1. Define once: shared by the workflow bundle, the worker, and every client. +// 1. Declare once: shared by the workflow bundle, the worker, and every client. const OrderFlow = Workflow.make("orderFlow", { payload: { orderId: Schema.String }, idempotencyKey: ({ orderId }) => orderId, success: Schema.String, }); -const Charge = TypedActivity.make("charge", { +const Charge = defineActivity("charge", { payload: { orderId: Schema.String }, success: Schema.String, }); -const ManagerApproval = DurableDeferred.make("manager-approval", { +const ManagerApproval = defineDeferred("manager-approval", { success: Schema.String, }); -// 2. Author the body — an Effect, running durably in the -// Temporal sandbox. (workflow bundle: engine-sandbox module) +// 2. Author the body — call the declarations directly. The handler needs +// only WorkflowOps: workflowBundle provides Temporal's; the testing +// module provides an in-memory one for plain unit tests. const OrderFlowLive = OrderFlow.toLayer((payload) => Effect.gen(function* () { - const paid = yield* callActivity(Charge, { orderId: payload.orderId }); + const paid = yield* Charge({ orderId: payload.orderId }); yield* DurableClock.sleep({ name: "cooling-off", duration: "3 days" }); - const approver = yield* DurableDeferred.await(ManagerApproval); + const approver = yield* ManagerApproval.await; return `${paid}:approved-by:${approver}`; }), ); diff --git a/docs/reference/how-it-works.md b/docs/reference/how-it-works.md index 24f8e39..716831f 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, runs your handler as an Effect program, and encodes the exit. Engine operations map to sandbox primitives — child starts to `startChild`, deferreds to signals + `condition()`, clocks to durable timers. +- **`engine-sandbox`** runs *inside* the Temporal workflow sandbox. `workflowBundle` builds the bundle's one dynamic workflow function from `Workflow.toLayer` registrations: per run it decodes the payload, provides the engine 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/src/definitions.ts b/examples/order-saga/src/definitions.ts index b1018cd..38f1c80 100644 --- a/examples/order-saga/src/definitions.ts +++ b/examples/order-saga/src/definitions.ts @@ -5,19 +5,21 @@ import { Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import * as StateCell from "@springbird/effect-temporal/state-cell"; -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; +import { + defineActivity, + defineDeferred, + defineState, +} from "@springbird/effect-temporal/definition"; /** Reserving inventory — the compensated step. */ -export const Reserve = TypedActivity.make("reserve", { +export const Reserve = defineActivity("reserve", { payload: { orderId: Schema.String }, success: Schema.String, // the reservation id options: { startToCloseTimeout: "10 seconds", retry: { maximumAttempts: 3 } }, }); /** Releasing a reservation — the compensation for `Reserve`. */ -export const Release = TypedActivity.make("release", { +export const Release = defineActivity("release", { payload: { reservation: Schema.String }, options: { startToCloseTimeout: "10 seconds", retry: { maximumAttempts: 3 } }, }); @@ -28,7 +30,7 @@ export const CardDeclined = Schema.TaggedStruct("CardDeclined", { orderId: Schema.String, }); -export const Charge = TypedActivity.make("charge", { +export const Charge = defineActivity("charge", { payload: { orderId: Schema.String, card: Schema.String }, success: Schema.String, // the receipt id error: CardDeclined, @@ -36,12 +38,12 @@ export const Charge = TypedActivity.make("charge", { }); /** The manager's one-shot approval, completed from outside the workflow. */ -export const ManagerApproval = DurableDeferred.make("manager-approval", { +export const ManagerApproval = defineDeferred("manager-approval", { success: Schema.String, // who approved }); /** Observable progress, readable mid-flight and after the run closes. */ -export const OrderStatus = StateCell.make("order-status", { +export const OrderStatus = defineState("order-status", { value: Schema.Struct({ phase: Schema.String }), }); diff --git a/examples/order-saga/src/main.ts b/examples/order-saga/src/main.ts index c05da94..00f26ba 100644 --- a/examples/order-saga/src/main.ts +++ b/examples/order-saga/src/main.ts @@ -111,12 +111,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, workflowId); + const status = yield* wf.readStateCell(OrderStatus.cell, workflowId); console.log(" status mid-flight:", Option.getOrElse(status, () => ({ phase: "?" }))); // The manager approves — a signal from entirely outside the workflow. - yield* DurableDeferred.done(ManagerApproval, { - token: DurableDeferred.tokenFromExecutionId(ManagerApproval, { + yield* DurableDeferred.done(ManagerApproval.deferred, { + token: DurableDeferred.tokenFromExecutionId(ManagerApproval.deferred, { workflow: OrderSaga, executionId: workflowId, }), diff --git a/examples/order-saga/src/workflows.ts b/examples/order-saga/src/workflows.ts index 0429a92..7b595bd 100644 --- a/examples/order-saga/src/workflows.ts +++ b/examples/order-saga/src/workflows.ts @@ -1,45 +1,45 @@ // The workflow bundle — Temporal's workflowsPath points here. The whole body // is an Effect running deterministically inside the workflow sandbox; every // side effect goes through a typed activity. The workflow registers itself -// with `Workflow.toLayer`, hosted by the bundle's default export. +// with `Workflow.toLayer`, hosted by the bundle's default export — which +// provides the `WorkflowOps` runtime the declarations require. import { Effect } from "effect"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import { callActivity, workflowBundle, setStateCell } from "@springbird/effect-temporal/engine-sandbox"; +import { workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; import { Charge, ManagerApproval, OrderSaga, OrderStatus, Release, Reserve } from "./definitions.js"; const OrderSagaLive = OrderSaga.toLayer((payload) => Effect.gen(function* () { - yield* setStateCell(OrderStatus, { phase: "reserving" }); + yield* OrderStatus.set({ phase: "reserving" }); // A compensated step: if anything later fails typed or the run is // cancelled, `Release` runs during the unwind. - const reservation = yield* callActivity(Reserve, { orderId: payload.orderId }).pipe( + const reservation = yield* Reserve({ orderId: payload.orderId }).pipe( Workflow.withCompensation((value) => - callActivity(Release, { reservation: value }).pipe(Effect.asVoid), + Release({ reservation: value }).pipe(Effect.asVoid), ), ); - yield* setStateCell(OrderStatus, { phase: "charging" }); + yield* OrderStatus.set({ phase: "charging" }); // A declined card lands in the workflow's TYPED error channel (and the // run fails red in the Temporal UI) — after compensation has released // the reservation. - const receipt = yield* callActivity(Charge, { + const receipt = yield* Charge({ orderId: payload.orderId, card: payload.card, }); // A durable timer: survives worker restarts; costs no worker resources. - yield* setStateCell(OrderStatus, { phase: "cooling-off" }); + yield* OrderStatus.set({ phase: "cooling-off" }); yield* DurableClock.sleep({ name: "cooling-off", duration: "2 seconds" }); // Block durably until a human approves (a signal from outside). - yield* setStateCell(OrderStatus, { phase: "awaiting-approval" }); - const approver = yield* DurableDeferred.await(ManagerApproval); + yield* OrderStatus.set({ phase: "awaiting-approval" }); + const approver = yield* ManagerApproval.await; - yield* setStateCell(OrderStatus, { phase: "complete" }); + yield* OrderStatus.set({ phase: "complete" }); return `${reservation}|${receipt}|approved-by:${approver}`; }), ); diff --git a/examples/subscription/src/definitions.ts b/examples/subscription/src/definitions.ts index 538a512..733054a 100644 --- a/examples/subscription/src/definitions.ts +++ b/examples/subscription/src/definitions.ts @@ -6,13 +6,15 @@ import { Schema } from "effect"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableMailbox from "@springbird/effect-temporal/mailbox"; -import * as DurableUpdate from "@springbird/effect-temporal/update"; -import * as StateCell from "@springbird/effect-temporal/state-cell"; -import * as TypedActivity from "@springbird/effect-temporal/typed-activity"; +import { + defineActivity, + defineMailbox, + defineState, + defineUpdate, +} from "@springbird/effect-temporal/definition"; /** One billing cycle's charge. */ -export const ChargeCard = TypedActivity.make("chargeCard", { +export const ChargeCard = defineActivity("chargeCard", { payload: { customerId: Schema.String, amountCents: Schema.Finite }, success: Schema.String, // receipt id options: { startToCloseTimeout: "10 seconds", retry: { maximumAttempts: 3 } }, @@ -23,20 +25,20 @@ export const ChargeCard = TypedActivity.make("chargeCard", { * the caller gets the PREVIOUS plan back as the typed success, or a typed * rejection for plans below the floor. */ -export const SetPlan = DurableUpdate.make("set-plan", { +export const SetPlan = defineUpdate("set-plan", { payload: Schema.Struct({ planCents: Schema.Finite }), success: Schema.Finite, // the previous plan error: Schema.String, // "plan-below-minimum" }); /** Cancellation requests — fire-and-forget inbound messages. */ -export const CancelRequests = DurableMailbox.make("cancel-requests", { +export const CancelRequests = defineMailbox("cancel-requests", { payload: Schema.Struct({ reason: Schema.String }), }); /** Observable state, readable mid-flight and after the run closes. Cells * are per-run: each continue-as-new republishes. */ -export const SubscriptionStatus = StateCell.make("subscription-status", { +export const SubscriptionStatus = defineState("subscription-status", { value: Schema.Struct({ phase: Schema.String, planCents: Schema.Finite, diff --git a/examples/subscription/src/main.ts b/examples/subscription/src/main.ts index 2c7eaf1..4d7f805 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, workflowId); + const early = yield* wf.readStateCell(SubscriptionStatus.cell, workflowId); console.log(" status:", Option.getOrNull(early)); console.log("\n2. change the plan through a typed update"); - const previous = yield* wf.executeUpdate(SetPlan, workflowId, { planCents: 1999 }); + const previous = yield* wf.executeUpdate(SetPlan.update, workflowId, { planCents: 1999 }); console.log(" previous plan:", previous, "¢"); const rejected = yield* Effect.result( - wf.executeUpdate(SetPlan, workflowId, { planCents: 50 }), + wf.executeUpdate(SetPlan.update, 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, workflowId); + const carried = yield* wf.readStateCell(SubscriptionStatus.cell, 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, workflowId, { reason: "user-requested" }); + yield* wf.offerMailbox(CancelRequests.mailbox, 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, workflowId); + const final = yield* wf.readStateCell(SubscriptionStatus.cell, workflowId); console.log(" status after close:", Option.getOrNull(final)); }), ), diff --git a/examples/subscription/src/workflows.ts b/examples/subscription/src/workflows.ts index 4935cc1..24bd4ea 100644 --- a/examples/subscription/src/workflows.ts +++ b/examples/subscription/src/workflows.ts @@ -5,21 +5,14 @@ // // The workflow registers itself with `Workflow.toLayer`, and // `workflowBundle` hosts every registration behind the bundle's one -// dynamic default export. +// dynamic default export — providing the `WorkflowOps` runtime the +// declarations require. 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 { - callActivity, - continueAsNew, - workflowBundle, - pollMailbox, - setStateCell, - takeMailbox, - takeUpdate, -} from "@springbird/effect-temporal/engine-sandbox"; +import { continueAsNew, workflowBundle } from "@springbird/effect-temporal/engine-sandbox"; import { CancelRequests, ChargeCard, @@ -41,7 +34,7 @@ const SubscriptionLive = Subscription.toLayer((payload) => // Cells are per-run: republish immediately so observers never see a gap // after continue-as-new. - yield* setStateCell(SubscriptionStatus, { phase: "active", planCents, cyclesBilled }); + yield* SubscriptionStatus.set({ phase: "active", planCents, cyclesBilled }); while (true) { const winner = yield* Effect.raceAll([ @@ -51,26 +44,26 @@ const SubscriptionLive = Subscription.toLayer((payload) => duration: "1 second", }).pipe(Effect.map(() => ({ kind: "bill" as const }))), // A plan change — answered with the PREVIOUS plan, typed both ways. - takeUpdate(SetPlan).pipe(Effect.map((request) => ({ kind: "plan" as const, request }))), + SetPlan.take.pipe(Effect.map((request) => ({ kind: "plan" as const, request }))), // A cancellation — fire-and-forget from anywhere. - takeMailbox(CancelRequests).pipe( + CancelRequests.take.pipe( Effect.map((message) => ({ kind: "cancel" as const, message })), ), ]); switch (winner.kind) { case "bill": { - yield* callActivity(ChargeCard, { customerId: payload.customerId, amountCents: planCents }); + yield* ChargeCard({ customerId: payload.customerId, amountCents: planCents }); cyclesBilled++; cyclesThisRun++; - yield* setStateCell(SubscriptionStatus, { phase: "active", planCents, cyclesBilled }); + yield* SubscriptionStatus.set({ phase: "active", planCents, cyclesBilled }); if (cyclesThisRun >= CYCLES_PER_RUN) { // Drain the mailbox BEFORE continuing — buffered messages do // not survive the run change. A cancellation that raced the // final cycle is honored instead of lost. - const pendingCancel = yield* pollMailbox(CancelRequests); + const pendingCancel = yield* CancelRequests.poll; if (Option.isSome(pendingCancel)) { - yield* setStateCell(SubscriptionStatus, { phase: "cancelled", planCents, cyclesBilled }); + yield* SubscriptionStatus.set({ phase: "cancelled", planCents, cyclesBilled }); return `cancelled(${pendingCancel.value.reason}) after ${cyclesBilled} cycles`; } return yield* continueAsNew(Subscription, { @@ -89,11 +82,11 @@ const SubscriptionLive = Subscription.toLayer((payload) => } yield* winner.request.respond(Exit.succeed(planCents)); planCents = requested; - yield* setStateCell(SubscriptionStatus, { phase: "active", planCents, cyclesBilled }); + yield* SubscriptionStatus.set({ phase: "active", planCents, cyclesBilled }); break; } case "cancel": { - yield* setStateCell(SubscriptionStatus, { phase: "cancelled", planCents, cyclesBilled }); + yield* SubscriptionStatus.set({ phase: "cancelled", planCents, cyclesBilled }); return `cancelled(${winner.message.reason}) after ${cyclesBilled} cycles`; } } diff --git a/package.json b/package.json index 68e712f..a001372 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@springbird/effect-temporal", - "version": "0.2.0", + "version": "0.3.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", @@ -26,6 +26,10 @@ "types": "./dist/client.d.ts", "default": "./dist/client.js" }, + "./definition": { + "types": "./dist/definition.d.ts", + "default": "./dist/definition.js" + }, "./engine-client": { "types": "./dist/engine-client.d.ts", "default": "./dist/engine-client.js" diff --git a/src/__tests__/continue-as-new.test.ts b/src/__tests__/continue-as-new.test.ts index 780c66f..61f8c1a 100644 --- a/src/__tests__/continue-as-new.test.ts +++ b/src/__tests__/continue-as-new.test.ts @@ -68,11 +68,11 @@ describe("continueAsNew over Temporal", { concurrent: false }, () => { const handle = client.workflow.getHandle(executionId); const readStage = () => - Effect.runPromise(readStateCell(LoopStage, { client, workflowId: executionId })); + Effect.runPromise(readStateCell(LoopStage.cell, { client, workflowId: executionId })); const releaseGate = (word: string) => run( - DurableDeferred.done(LoopGate, { - token: DurableDeferred.tokenFromExecutionId(LoopGate, { + DurableDeferred.done(LoopGate.deferred, { + token: DurableDeferred.tokenFromExecutionId(LoopGate.deferred, { workflow: CellLoopDemo, executionId, }), diff --git a/src/__tests__/definition.test.ts b/src/__tests__/definition.test.ts new file mode 100644 index 0000000..09e1a2c --- /dev/null +++ b/src/__tests__/definition.test.ts @@ -0,0 +1,178 @@ +// The single-declaration contract: +// +// 1. TYPES FLOW: payloads, successes, and typed errors infer end-to-end +// from each declaration (pinned below with expectTypeOf). +// 2. ENGINE-AGNOSTIC: the SAME handler function object runs (a) on the +// in-memory `makeTestWorkflowOps` runtime with zero engine anywhere, +// and (b) on real Temporal via `workflowBundle`. + +import { fileURLToPath } from "node:url"; +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 * 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 { + Approval, + CardDeclined, + Charge, + chargeImpl, + OrderFlow, + orderHandler, + Priority, + Reserve, + reserveImpl, + SetAmount, + Status, +} from "./fixtures/definition-demo.js"; +import { createWorkflowTestEnv } from "./utils/workflow-test-env.js"; + +const temporal = createWorkflowTestEnv("definition"); + +const bindings = [handle(Reserve, reserveImpl), handle(Charge, chargeImpl)] as const; + +// ── 1. The type pins ───────────────────────────────────────────────────────── + +const _types = () => { + // Activities: payload in, success out, typed error channel, WorkflowOps in R. + const charge = Charge({ orderId: "x", amountCents: 1 }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + const _r: Effect.Effect = charge; + void _r; + // @ts-expect-error wrong payload shape + Charge({ orderId: 1 }); + + // Messages: deferred success, mailbox payload, update request typing. + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<{ + readonly level: number; + }>(); + expectTypeOf>().toEqualTypeOf< + UpdateRequest<{ readonly amountCents: number }, number, string> + >(); + + // version answers one of exactly the names given. + const pricing = version("site", ["flat", "tiered"]); + expectTypeOf>().toEqualTypeOf<"flat" | "tiered">(); + + // Worker binding is payload/success/error-checked from the declaration. + handle(Charge, chargeImpl); + // @ts-expect-error wrong success type + handle(Charge, () => Effect.succeed(42)); +}; +void _types; + +// ── 2. Same handler, two engines ───────────────────────────────────────────── + +describe("definition: one declaration, types flow, engine-agnostic", { concurrent: false }, () => { + it("runs the handler on the in-memory runtime (no engine at all)", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const world = yield* makeTestWorkflowOps({ activities: bindings }); + const fiber = yield* Effect.forkChild( + orderHandler({ orderId: "m-1" }).pipe(Effect.provide(world.layer)), + ); + + // 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 }); + yield* settle; + expect(yield* world.stateOf(Status)).toEqual(Option.some({ phase: "awaiting-approval" })); + yield* world.resolve(Approval, "memory-ben"); + + const result = yield* Fiber.join(fiber); + expect(result).toBe("res-m-1|receipt-m-1-2500|p2|tiered|by:memory-ben"); + expect(yield* world.stateOf(Status)).toEqual(Option.some({ phase: "complete" })); + }), + ); + }, 20_000); + + it("answers a typed update refusal in memory", async () => { + await Effect.runPromise( + Effect.gen(function* () { + const world = yield* makeTestWorkflowOps({ activities: bindings }); + yield* Effect.forkChild(orderHandler({ orderId: "m-2" }).pipe(Effect.provide(world.layer))); + const refused = yield* Effect.result(world.request(SetAmount, { amountCents: 50 })); + expect(Result.isFailure(refused) && refused.failure).toBe("amount-too-low"); + }), + ); + }, 20_000); + + it("runs the SAME handler on real Temporal through workflowBundle", async () => { + const workflowsPath = fileURLToPath(new URL("./fixtures/definition-workflows.ts", import.meta.url)); + const runner: ActivityRunner = { + run: (_name, _payload, effect) => Effect.runPromiseExit(effect), + }; + const activities = implementActivities(runner, 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)); + const approve = (executionId: string, approver: string) => + run( + DurableDeferred.done(Approval.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approval.deferred, { + workflow: OrderFlow, + executionId, + }), + exit: Exit.succeed(approver), + }), + ); + + const payload = { orderId: "t-1" }; + const workflowId = await run(OrderFlow.execute(payload, { discard: true })); + + // 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 } }), + ); + 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 })); + 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 })); + expect(Option.getOrNull(final)).toEqual({ phase: "complete" }); + + // The typed activity failure flows into the workflow error channel. + const declinePayload = { orderId: "t-declined" }; + const declineId = await run(OrderFlow.execute(declinePayload, { discard: true })); + await Effect.runPromise( + executeUpdate(SetAmount.update, { + client, + workflowId: declineId, + payload: { amountCents: 10_000 }, + }), + ); + await Effect.runPromise( + offerMailbox(Priority.mailbox, { client, workflowId: declineId, payload: { level: 1 } }), + ); + await approve(declineId, "x"); + const declined = await run(Effect.result(OrderFlow.execute(declinePayload))); + expect(Result.isFailure(declined) && declined.failure).toEqual({ + _tag: "CardDeclined", + orderId: "t-declined", + }); + }); + }, 120_000); +}); diff --git a/src/__tests__/early-return.test.ts b/src/__tests__/early-return.test.ts index 5d4dd46..17186a5 100644 --- a/src/__tests__/early-return.test.ts +++ b/src/__tests__/early-return.test.ts @@ -34,7 +34,7 @@ describe("early return via update", { concurrent: false }, () => { // The early return: confirmed before the run completes. const confirmation = await run( - executeUpdate(GetConfirmation, { client, workflowId: executionId, payload: {} }), + executeUpdate(GetConfirmation.update, { client, workflowId: executionId, payload: {} }), ); expect(confirmation).toBe("confirmed"); expect((await client.workflow.getHandle(executionId).describe()).status.name).toBe("RUNNING"); diff --git a/src/__tests__/fixtures/batch-demo.ts b/src/__tests__/fixtures/batch-demo.ts index 6c7798c..3da66bf 100644 --- a/src/__tests__/fixtures/batch-demo.ts +++ b/src/__tests__/fixtures/batch-demo.ts @@ -6,9 +6,9 @@ import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableMailbox from "../../mailbox.js"; +import { defineMailbox } from "../../definition.js"; -export const CompletionReports = DurableMailbox.make("record-complete", { +export const CompletionReports = defineMailbox("record-complete", { payload: Schema.Struct({ index: Schema.Finite }), }); diff --git a/src/__tests__/fixtures/batch-workflows.ts b/src/__tests__/fixtures/batch-workflows.ts index 3e0d1b0..93b114c 100644 --- a/src/__tests__/fixtures/batch-workflows.ts +++ b/src/__tests__/fixtures/batch-workflows.ts @@ -14,8 +14,6 @@ import { continueAsNew, workflowBundle, offerMailbox, - pollMailbox, - takeMailbox, } from "../../engine-sandbox.js"; import { BatchDemo, CompletionReports, RecordDemo } from "./batch-demo.js"; @@ -34,7 +32,7 @@ const BatchDemoLive = BatchDemo.toLayer((payload) => while (offset < payload.total && startedThisRun < CHILDREN_PER_RUN) { if (inFlight.size >= payload.window) { - const report = yield* takeMailbox(CompletionReports); + const report = yield* CompletionReports.take; inFlight.delete(report.index); } yield* RecordDemo.execute( @@ -54,7 +52,7 @@ const BatchDemoLive = BatchDemo.toLayer((payload) => // Reports that landed during this run would be lost at // continue-as-new; drain them into the carried set first. while (true) { - const report = yield* pollMailbox(CompletionReports); + const report = yield* CompletionReports.poll; if (Option.isNone(report)) break; inFlight.delete(report.value.index); } @@ -62,7 +60,7 @@ const BatchDemoLive = BatchDemo.toLayer((payload) => } while (inFlight.size > 0) { - const report = yield* takeMailbox(CompletionReports); + const report = yield* CompletionReports.take; inFlight.delete(report.index); } return `processed:${payload.total}`; @@ -76,7 +74,7 @@ const RecordDemoLive = RecordDemo.toLayer((payload) => success: Schema.String, execute: callRawActivity(() => acts.processRecord(String(payload.index))), }); - yield* offerMailbox(CompletionReports, { + yield* offerMailbox(CompletionReports.mailbox, { workflowId: payload.batchExecutionId, payload: { index: payload.index }, }); diff --git a/src/__tests__/fixtures/definition-demo.ts b/src/__tests__/fixtures/definition-demo.ts new file mode 100644 index 0000000..c7796d4 --- /dev/null +++ b/src/__tests__/fixtures/definition-demo.ts @@ -0,0 +1,105 @@ +// The single-declaration demo shared by definition.test.ts: every +// capability declared ONCE with define*, one engine-agnostic handler using +// them directly. Note what this module imports: upstream Effect and the +// definition module only — no engine-sandbox, no Temporal. The memory test +// and the Temporal bundle both load it. + +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Workflow from "effect/unstable/workflow/Workflow"; +import { + defineActivity, + defineDeferred, + defineMailbox, + defineState, + defineUpdate, + version, +} from "../../definition.js"; + +export const CardDeclined = Schema.TaggedStruct("CardDeclined", { + orderId: Schema.String, +}); + +export const OrderFlow = Workflow.make("defOrder", { + payload: { orderId: Schema.String }, + idempotencyKey: ({ orderId }) => orderId, + success: Schema.String, + error: CardDeclined, +}); + +export const Reserve = defineActivity("defOrder/reserve", { + payload: { orderId: Schema.String }, + success: Schema.String, +}); + +export const Charge = defineActivity("defOrder/charge", { + payload: { orderId: Schema.String, amountCents: Schema.Finite }, + success: Schema.String, + error: CardDeclined, +}); + +export const Approval = defineDeferred("defOrder/approval", { + success: Schema.String, +}); + +export const Priority = defineMailbox("defOrder/priority", { + payload: Schema.Struct({ level: Schema.Finite }), +}); + +export const SetAmount = defineUpdate("defOrder/setAmount", { + payload: Schema.Struct({ amountCents: Schema.Finite }), + success: Schema.Finite, // the previous amount + error: Schema.String, // "amount-too-low" +}); + +export const Status = defineState("defOrder/status", { + value: Schema.Struct({ phase: Schema.String }), +}); + +/** The handler: primitives are yielded directly; its only requirement is + * `WorkflowOps`, so it runs on Temporal or on the in-memory test runtime. */ +export const orderHandler = (payload: { readonly orderId: string }) => + Effect.gen(function* () { + yield* Status.set({ phase: "reserving" }); + const reservation = yield* Reserve({ orderId: payload.orderId }); + + // A typed update: respond with the PREVIOUS amount, or a typed refusal. + yield* Status.set({ phase: "pricing" }); + let amountCents = 1000; + const request = yield* SetAmount.take; + if (request.payload.amountCents < 100) { + yield* request.respond(Exit.fail("amount-too-low")); + } else { + yield* request.respond(Exit.succeed(amountCents)); + amountCents = request.payload.amountCents; + } + + // A mailbox message, a patch-marker branch, and a one-shot approval. + const priority = yield* Priority.take; + const pricing = yield* version("defOrder/pricing", ["flat", "tiered"]); + yield* Status.set({ phase: "awaiting-approval" }); + const approver = yield* Approval.await; + + // A typed activity failure flows straight into the workflow error channel. + const receipt = yield* Charge({ orderId: payload.orderId, amountCents }); + + yield* Status.set({ phase: "complete" }); + return `${reservation}|${receipt}|p${priority.level}|${pricing}|by:${approver}`; + }); + +/** Worker-side activity implementations, bound to the declarations by the + * tests (memory and Temporal alike) via `handle`. */ +export const reserveImpl = ({ orderId }: { readonly orderId: string }) => + Effect.succeed(`res-${orderId}`); + +export const chargeImpl = ({ + orderId, + amountCents, +}: { + readonly orderId: string; + readonly amountCents: number; +}) => + amountCents >= 10_000 + ? Effect.fail({ _tag: "CardDeclined", orderId } as const) + : Effect.succeed(`receipt-${orderId}-${amountCents}`); diff --git a/src/__tests__/fixtures/definition-workflows.ts b/src/__tests__/fixtures/definition-workflows.ts new file mode 100644 index 0000000..2af61d4 --- /dev/null +++ b/src/__tests__/fixtures/definition-workflows.ts @@ -0,0 +1,8 @@ +// The Temporal bundle for the definition demo: the SAME handler the memory +// test runs, hosted by workflowBundle — which provides the Temporal +// `WorkflowOps` runtime the declarations require. + +import { workflowBundle } from "../../engine-sandbox.js"; +import { OrderFlow, orderHandler } from "./definition-demo.js"; + +export default workflowBundle(OrderFlow.toLayer(orderHandler)); diff --git a/src/__tests__/fixtures/demo-workflows.ts b/src/__tests__/fixtures/demo-workflows.ts index afdc3fb..47a0b16 100644 --- a/src/__tests__/fixtures/demo-workflows.ts +++ b/src/__tests__/fixtures/demo-workflows.ts @@ -8,7 +8,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; import * as DurableClock from "effect/unstable/workflow/DurableClock"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; import { proxyActivities } from "@temporalio/workflow"; import { callRawActivity, workflowBundle } from "../../engine-sandbox.js"; @@ -65,7 +64,7 @@ const DemoLive = Demo.toLayer((payload) => return yield* Effect.fail("business-failure"); } - const approver = yield* DurableDeferred.await(Approval); + const approver = yield* Approval.await; return `${reservation}|approved-by:${approver}`; }), ); diff --git a/src/__tests__/fixtures/demo.ts b/src/__tests__/fixtures/demo.ts index 750f940..d8662fa 100644 --- a/src/__tests__/fixtures/demo.ts +++ b/src/__tests__/fixtures/demo.ts @@ -2,11 +2,12 @@ // client-side tests. No `@temporalio/*` imports: it loads in both worlds. import * as Schema from "effect/Schema"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; +import { defineDeferred } from "../../definition.js"; -/** The approval gate; completed from outside via `DurableDeferred.done`. */ -export const Approval = DurableDeferred.make("demo-approval", { +/** The approval gate; completed from outside via + * `DurableDeferred.done(Approval.deferred, ...)`. */ +export const Approval = defineDeferred("demo-approval", { success: Schema.String, }); diff --git a/src/__tests__/fixtures/lock-demo.ts b/src/__tests__/fixtures/lock-demo.ts index a1b1ac9..4bd8368 100644 --- a/src/__tests__/fixtures/lock-demo.ts +++ b/src/__tests__/fixtures/lock-demo.ts @@ -4,17 +4,17 @@ import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableMailbox from "../../mailbox.js"; +import { defineMailbox } from "../../definition.js"; -export const AcquireRequests = DurableMailbox.make("acquire", { +export const AcquireRequests = defineMailbox("acquire", { payload: Schema.Struct({ requester: Schema.String }), }); -export const Grants = DurableMailbox.make("grant", { +export const Grants = defineMailbox("grant", { payload: Schema.Struct({ token: Schema.Finite }), }); -export const Releases = DurableMailbox.make("release", { +export const Releases = defineMailbox("release", { payload: Schema.Struct({ token: Schema.Finite }), }); diff --git a/src/__tests__/fixtures/lock-workflows.ts b/src/__tests__/fixtures/lock-workflows.ts index 3dfa78d..e9d7343 100644 --- a/src/__tests__/fixtures/lock-workflows.ts +++ b/src/__tests__/fixtures/lock-workflows.ts @@ -5,12 +5,7 @@ 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, - takeMailbox, -} from "../../engine-sandbox.js"; +import { callRawActivity, workflowBundle, offerMailbox } from "../../engine-sandbox.js"; import { AcquireRequests, ContenderDemo, Grants, LockDemo, Releases } from "./lock-demo.js"; const acts = proxyActivities<{ @@ -23,9 +18,9 @@ const acts = proxyActivities<{ const LockDemoLive = LockDemo.toLayer((payload) => Effect.gen(function* () { for (let token = 0; token < payload.grants; token++) { - const request = yield* takeMailbox(AcquireRequests); - yield* offerMailbox(Grants, { workflowId: request.requester, payload: { token } }); - while ((yield* takeMailbox(Releases)).token !== token) { + const request = yield* AcquireRequests.take; + yield* offerMailbox(Grants.mailbox, { workflowId: request.requester, payload: { token } }); + while ((yield* Releases.take).token !== token) { // A stale release from a misbehaving holder never unlocks a newer // grant. } @@ -36,11 +31,11 @@ const LockDemoLive = LockDemo.toLayer((payload) => const ContenderDemoLive = ContenderDemo.toLayer((payload, executionId) => Effect.gen(function* () { - yield* offerMailbox(AcquireRequests, { + yield* offerMailbox(AcquireRequests.mailbox, { workflowId: payload.lockExecutionId, payload: { requester: executionId }, }); - const grant = yield* takeMailbox(Grants); + const grant = yield* Grants.take; yield* Activity.make({ name: "enter", @@ -53,7 +48,7 @@ const ContenderDemoLive = ContenderDemo.toLayer((payload, executionId) => execute: callRawActivity(() => acts.leave(payload.name)), }); - yield* offerMailbox(Releases, { + yield* offerMailbox(Releases.mailbox, { workflowId: payload.lockExecutionId, payload: { token: grant.token }, }); diff --git a/src/__tests__/fixtures/loop-demo.ts b/src/__tests__/fixtures/loop-demo.ts index 433575a..24f8182 100644 --- a/src/__tests__/fixtures/loop-demo.ts +++ b/src/__tests__/fixtures/loop-demo.ts @@ -3,9 +3,8 @@ // until the iteration cap. import * as Schema from "effect/Schema"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as StateCell from "../../state-cell.js"; +import { defineDeferred, defineState } from "../../definition.js"; export const LoopDemo = Workflow.make("effectLoopDemo", { payload: { requestId: Schema.String, iteration: Schema.Finite }, @@ -16,13 +15,13 @@ export const LoopDemo = Workflow.make("effectLoopDemo", { /** Snapshot published by `CellLoopDemo` — cells are PER-RUN, so the value * run 1 publishes must read as `None` after continue-as-new until run 2 * republishes. */ -export const LoopStage = StateCell.make("loop-stage", { +export const LoopStage = defineState("loop-stage", { value: Schema.String, }); /** Gate awaited once per run, so the test controls exactly when run 1 * continues-as-new and when run 2 republishes and finishes. */ -export const LoopGate = DurableDeferred.make("loop-gate", { +export const LoopGate = defineDeferred("loop-gate", { success: Schema.String, }); diff --git a/src/__tests__/fixtures/loop-workflows.ts b/src/__tests__/fixtures/loop-workflows.ts index 0269b88..020c3d7 100644 --- a/src/__tests__/fixtures/loop-workflows.ts +++ b/src/__tests__/fixtures/loop-workflows.ts @@ -4,14 +4,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Activity from "effect/unstable/workflow/Activity"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import { proxyActivities } from "@temporalio/workflow"; -import { - callRawActivity, - continueAsNew, - workflowBundle, - setStateCell, -} from "../../engine-sandbox.js"; +import { callRawActivity, continueAsNew, workflowBundle } from "../../engine-sandbox.js"; import { CellLoopDemo, LoopDemo, LoopGate, LoopStage } from "./loop-demo.js"; const acts = proxyActivities<{ record(iteration: string): Promise }>({ @@ -36,8 +30,8 @@ const LoopDemoLive = LoopDemo.toLayer((payload) => const CellLoopDemoLive = CellLoopDemo.toLayer((payload) => Effect.gen(function* () { if (payload.iteration === 0) { - yield* setStateCell(LoopStage, "run-0"); - yield* DurableDeferred.await(LoopGate); + yield* LoopStage.set("run-0"); + yield* LoopGate.await; return yield* continueAsNew(CellLoopDemo, { requestId: payload.requestId, iteration: 1, @@ -45,8 +39,8 @@ const CellLoopDemoLive = CellLoopDemo.toLayer((payload) => } // Run 2: idle (cell unpublished in THIS run) until released, then // republish and finish. - const release = yield* DurableDeferred.await(LoopGate); - yield* setStateCell(LoopStage, `run-1:${release}`); + const release = yield* LoopGate.await; + yield* LoopStage.set(`run-1:${release}`); return "cell-done"; }), ); diff --git a/src/__tests__/fixtures/mailbox-demo.ts b/src/__tests__/fixtures/mailbox-demo.ts index d911b2c..28cc06d 100644 --- a/src/__tests__/fixtures/mailbox-demo.ts +++ b/src/__tests__/fixtures/mailbox-demo.ts @@ -7,10 +7,9 @@ import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableMailbox from "../../mailbox.js"; -import * as StateCell from "../../state-cell.js"; +import { defineMailbox, defineState } from "../../definition.js"; -export const StateUpdates = DurableMailbox.make("state-updates", { +export const StateUpdates = defineMailbox("state-updates", { payload: Schema.Union([ Schema.Struct({ op: Schema.Literal("set"), @@ -24,7 +23,7 @@ export const StateUpdates = DurableMailbox.make("state-updates", { /** The `state` sample's query half: the current entries, published after * every update and readable mid-flight or after completion. */ -export const StateSnapshot = StateCell.make("state-snapshot", { +export const StateSnapshot = defineState("state-snapshot", { value: Schema.Record(Schema.String, Schema.Finite), }); @@ -36,7 +35,7 @@ export const StateDemo = Workflow.make("effectStateDemo", { success: Schema.String, }); -export const DeadlineUpdates = DurableMailbox.make("deadline-updates", { +export const DeadlineUpdates = defineMailbox("deadline-updates", { payload: Schema.Struct({ millis: Schema.Finite }), }); diff --git a/src/__tests__/fixtures/mailbox-workflows.ts b/src/__tests__/fixtures/mailbox-workflows.ts index c524750..68ecfbe 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, setStateCell, takeMailbox } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../engine-sandbox.js"; import { DeadlineUpdates, StateDemo, @@ -16,11 +16,11 @@ const StateDemoLive = StateDemo.toLayer(() => Effect.gen(function* () { const state = new Map(); while (true) { - const update = yield* takeMailbox(StateUpdates); + const update = yield* StateUpdates.take; if (update.op === "finish") break; if (update.op === "set") state.set(update.key, update.value); else state.delete(update.key); - yield* setStateCell(StateSnapshot, Object.fromEntries(state)); + yield* StateSnapshot.set(Object.fromEntries(state)); } return Array.from(state.entries()) .toSorted(([a], [b]) => a.localeCompare(b)) @@ -35,7 +35,7 @@ const UpdatableTimerDemoLive = UpdatableTimerDemo.toLayer((payload) => let updates = 0; while (true) { const winner = yield* Effect.raceFirst( - takeMailbox(DeadlineUpdates).pipe( + DeadlineUpdates.take.pipe( Effect.map((update) => ({ kind: "update" as const, update })), ), DurableClock.sleep({ diff --git a/src/__tests__/fixtures/message-demo.ts b/src/__tests__/fixtures/message-demo.ts index 2654257..b7be5b8 100644 --- a/src/__tests__/fixtures/message-demo.ts +++ b/src/__tests__/fixtures/message-demo.ts @@ -4,30 +4,28 @@ // finished by a one-shot approval. import * as Schema from "effect/Schema"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as StateCell from "../../state-cell.js"; -import * as DurableUpdate from "../../update.js"; +import { defineDeferred, defineState, defineUpdate } from "../../definition.js"; export const SUPPORTED_LANGUAGES = ["english", "french", "spanish"] as const; -export const SetLanguage = DurableUpdate.make("set-language", { +export const SetLanguage = defineUpdate("set-language", { payload: Schema.Struct({ language: Schema.String }), success: Schema.String, error: Schema.String, }); -export const CurrentLanguage = StateCell.make("current-language", { +export const CurrentLanguage = defineState("current-language", { value: Schema.String, }); -export const Approved = DurableDeferred.make("message-approved", { +export const Approved = defineDeferred("message-approved", { success: Schema.String, }); /** An update the workflow NEVER takes — for the lifecycle-edge test where a * run completes with the request still pending. */ -export const Orphan = DurableUpdate.make("orphan", { +export const Orphan = defineUpdate("orphan", { payload: Schema.Struct({ note: Schema.String }), success: Schema.String, error: Schema.String, diff --git a/src/__tests__/fixtures/message-workflows.ts b/src/__tests__/fixtures/message-workflows.ts index 5680047..bdbade9 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, setStateCell, takeUpdate } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../engine-sandbox.js"; import { Approved, CurrentLanguage, @@ -17,13 +17,13 @@ import { const MessageDemoLive = MessageDemo.toLayer(() => Effect.gen(function* () { let language: string = SUPPORTED_LANGUAGES[0]; - yield* setStateCell(CurrentLanguage, language); + yield* CurrentLanguage.set(language); while (true) { const winner = yield* Effect.raceFirst( - takeUpdate(SetLanguage).pipe( + SetLanguage.take.pipe( Effect.map((request) => ({ kind: "update" as const, request })), ), - DurableDeferred.await(Approved).pipe( + Approved.await.pipe( Effect.map((approver) => ({ kind: "approved" as const, approver })), ), ); @@ -34,7 +34,7 @@ const MessageDemoLive = MessageDemo.toLayer(() => if ((SUPPORTED_LANGUAGES as readonly string[]).includes(requested)) { yield* winner.request.respond(Exit.succeed(language)); language = requested; - yield* setStateCell(CurrentLanguage, language); + yield* CurrentLanguage.set(language); } else { yield* winner.request.respond(Exit.fail(`unsupported:${requested}`)); } @@ -47,8 +47,8 @@ const MessageDemoLive = MessageDemo.toLayer(() => * for this sender. */ const DeferredPokeDemoLive = DeferredPokeDemo.toLayer((payload) => Effect.gen(function* () { - yield* DurableDeferred.done(Approved, { - token: DurableDeferred.tokenFromExecutionId(Approved, { + yield* DurableDeferred.done(Approved.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approved.deferred, { workflow: MessageDemo, executionId: payload.targetExecutionId, }), diff --git a/src/__tests__/fixtures/transaction-demo.ts b/src/__tests__/fixtures/transaction-demo.ts index 1ca6ed2..6cc97f3 100644 --- a/src/__tests__/fixtures/transaction-demo.ts +++ b/src/__tests__/fixtures/transaction-demo.ts @@ -4,9 +4,9 @@ import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableUpdate from "../../update.js"; +import { defineUpdate } from "../../definition.js"; -export const GetConfirmation = DurableUpdate.make("get-confirmation", { +export const GetConfirmation = defineUpdate("get-confirmation", { payload: Schema.Struct({}), success: Schema.String, error: Schema.Never, diff --git a/src/__tests__/fixtures/transaction-workflows.ts b/src/__tests__/fixtures/transaction-workflows.ts index 85e00ff..b6d3199 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, takeUpdate } from "../../engine-sandbox.js"; +import { workflowBundle } from "../../engine-sandbox.js"; import { GetConfirmation, TransactionDemo } from "./transaction-demo.js"; const TransactionDemoLive = TransactionDemo.toLayer(() => @@ -15,7 +15,7 @@ const TransactionDemoLive = TransactionDemo.toLayer(() => yield* Effect.forkChild( Effect.gen(function* () { - const request = yield* takeUpdate(GetConfirmation); + const request = yield* GetConfirmation.take; // condition() is a durable workflow wait, not activity I/O. // oxlint-disable-next-line effect-temporal/prefer-call-temporal-activity yield* Effect.promise(() => condition(() => state.confirmed)); diff --git a/src/__tests__/fixtures/typed-activity-demo.ts b/src/__tests__/fixtures/typed-activity-demo.ts index e634237..4f895aa 100644 --- a/src/__tests__/fixtures/typed-activity-demo.ts +++ b/src/__tests__/fixtures/typed-activity-demo.ts @@ -1,12 +1,12 @@ import * as Schema from "effect/Schema"; import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as TypedActivity from "../../typed-activity.js"; +import { defineActivity } from "../../definition.js"; export const OutOfStock = Schema.TaggedStruct("OutOfStock", { sku: Schema.String, }); -export const Reserve = TypedActivity.make("typedReserve", { +export const Reserve = defineActivity("typedReserve", { payload: { sku: Schema.String, quantity: Schema.Finite }, success: Schema.String, error: OutOfStock, diff --git a/src/__tests__/fixtures/typed-activity-workflows.ts b/src/__tests__/fixtures/typed-activity-workflows.ts index 6b4e7fc..cb1bc69 100644 --- a/src/__tests__/fixtures/typed-activity-workflows.ts +++ b/src/__tests__/fixtures/typed-activity-workflows.ts @@ -1,5 +1,5 @@ import * as Effect from "effect/Effect"; -import { callActivity, workflowBundle, sleepUntil } from "../../engine-sandbox.js"; +import { workflowBundle, sleepUntil } from "../../engine-sandbox.js"; import { Reserve, TypedActivityDemo } from "./typed-activity-demo.js"; // Exercises the typed seam end-to-end: an absolute-time durable sleep, a @@ -7,7 +7,7 @@ import { Reserve, TypedActivityDemo } from "./typed-activity-demo.js"; const TypedActivityDemoLive = TypedActivityDemo.toLayer((payload) => Effect.gen(function* () { yield* sleepUntil({ name: "not-before", timestamp: payload.notBeforeISO }); - return yield* callActivity(Reserve, { sku: payload.sku, quantity: 2 }).pipe( + return yield* Reserve({ sku: payload.sku, quantity: 2 }).pipe( Effect.catchTag("OutOfStock", (error) => Effect.succeed(`backordered:${error.sku}`)), ); }), diff --git a/src/__tests__/lint.test.ts b/src/__tests__/lint.test.ts index a23110c..4413ed1 100644 --- a/src/__tests__/lint.test.ts +++ b/src/__tests__/lint.test.ts @@ -18,6 +18,7 @@ import { proxyActivities } from "@temporalio/workflow"; import { callRawActivity } from "@springbird/effect-temporal/engine-sandbox"; import { makeTemporalClientEngine } from "@springbird/effect-temporal/engine-client"; import * as Versioning from "@springbird/effect-temporal/versioning"; +import { version } from "@springbird/effect-temporal/definition"; let counter = 0; const acts = proxyActivities<{ foo(): Promise }>({ startToCloseTimeout: "10 seconds" }); @@ -26,13 +27,16 @@ export const a = Effect.promise((signal) => acts.foo()); export const b = Effect.promise(() => acts.foo()); export const c = callRawActivity(() => acts.foo()); export const d = Effect.forkChild(Versioning.patched("x")); +export const e = Effect.forkChild(version("y", ["v1", "v2"])); `; const GOOD = ` import { callRawActivity } from "@springbird/effect-temporal/engine-sandbox"; +import { version } from "@springbird/effect-temporal/definition"; declare const acts: { foo(): Promise }; export const c = callRawActivity(() => acts.foo()); +export const v = version("site", ["v1", "v2"]); `; const runOxlint = (directory: string, files: string[]) => { diff --git a/src/__tests__/mailbox.test.ts b/src/__tests__/mailbox.test.ts index 63a8f38..a4ddd76 100644 --- a/src/__tests__/mailbox.test.ts +++ b/src/__tests__/mailbox.test.ts @@ -43,15 +43,15 @@ describe("DurableMailbox over Temporal", { concurrent: false }, () => { const payload = { requestId: "state-1" } as const; const executionId = await run(StateDemo.execute(payload, { discard: true })); - const offer = (message: (typeof StateUpdates)["payloadSchema"]["Type"]) => + const offer = (message: (typeof StateUpdates)["mailbox"]["payloadSchema"]["Type"]) => Effect.runPromise( - offerMailbox(StateUpdates, { client, workflowId: executionId, payload: message }), + offerMailbox(StateUpdates.mailbox, { client, workflowId: executionId, payload: message }), ); // A signal and a query race through separate workflow tasks, so // mid-flight reads poll until the published snapshot catches up. const readSnapshot = () => - Effect.runPromise(readStateCell(StateSnapshot, { client, workflowId: executionId })); + Effect.runPromise(readStateCell(StateSnapshot.cell, { client, workflowId: executionId })); const readUntil = async (expected: Record) => { for (let i = 0; i < 50; i++) { const snapshot = await readSnapshot(); @@ -101,7 +101,7 @@ describe("DurableMailbox over Temporal", { concurrent: false }, () => { for (const millis of [300_000, 120_000]) { await Effect.runPromise( - offerMailbox(DeadlineUpdates, { client, workflowId: executionId, payload: { millis } }), + offerMailbox(DeadlineUpdates.mailbox, { client, workflowId: executionId, payload: { millis } }), ); } @@ -132,9 +132,9 @@ describe("DurableMailbox over Temporal", { concurrent: false }, () => { }); // Well-formed messages after the junk still apply, in order. - const offer = (message: (typeof StateUpdates)["payloadSchema"]["Type"]) => + const offer = (message: (typeof StateUpdates)["mailbox"]["payloadSchema"]["Type"]) => Effect.runPromise( - offerMailbox(StateUpdates, { client, workflowId: executionId, payload: message }), + offerMailbox(StateUpdates.mailbox, { client, workflowId: executionId, payload: message }), ); await offer({ op: "set", key: "a", value: 1 }); await offer({ op: "set", key: "b", value: 2 }); @@ -149,7 +149,7 @@ describe("DurableMailbox over Temporal", { concurrent: false }, () => { it("drops offers to closed executions instead of failing", async () => { await temporal.withWorker({ activities, workflowsPath }, async () => { await Effect.runPromise( - offerMailbox(StateUpdates, { + offerMailbox(StateUpdates.mailbox, { client: temporal.env.client, workflowId: "effect-mailbox-never-started", payload: { op: "finish" }, diff --git a/src/__tests__/message-passing.test.ts b/src/__tests__/message-passing.test.ts index fb34f0b..4a756a3 100644 --- a/src/__tests__/message-passing.test.ts +++ b/src/__tests__/message-passing.test.ts @@ -45,7 +45,7 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { const setLanguage = (language: string) => Effect.runPromise( Effect.result( - executeUpdate(SetLanguage, { client, workflowId: executionId, payload: { language } }), + executeUpdate(SetLanguage.update, { client, workflowId: executionId, payload: { language } }), ), ); @@ -60,13 +60,13 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { const rejected = await setLanguage("klingon"); expect(Result.isFailure(rejected) && rejected.failure).toBe("unsupported:klingon"); const snapshot = await Effect.runPromise( - readStateCell(CurrentLanguage, { client, workflowId: executionId }), + readStateCell(CurrentLanguage.cell, { client, workflowId: executionId }), ); expect(Option.isSome(snapshot) && snapshot.value).toBe("spanish"); await run( - DurableDeferred.done(Approved, { - token: DurableDeferred.tokenFromExecutionId(Approved, { + DurableDeferred.done(Approved.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approved.deferred, { workflow: MessageDemo, executionId, }), @@ -91,7 +91,7 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { // `Effect.exit` never rejects, so the floating promise is safe. const orphanExit = Effect.runPromise( Effect.exit( - executeUpdate(Orphan, { client, workflowId: executionId, payload: { note: "hello" } }), + executeUpdate(Orphan.update, { client, workflowId: executionId, payload: { note: "hello" } }), ), ); // Give the update time to be admitted while the workflow still runs. @@ -99,8 +99,8 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { // Complete the workflow out from under the pending update. await run( - DurableDeferred.done(Approved, { - token: DurableDeferred.tokenFromExecutionId(Approved, { + DurableDeferred.done(Approved.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approved.deferred, { workflow: MessageDemo, executionId, }), @@ -120,7 +120,7 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { await temporal.withWorker({ activities, workflowsPath }, async () => { const exit = await Effect.runPromise( Effect.exit( - executeUpdate(SetLanguage, { + executeUpdate(SetLanguage.update, { client: temporal.env.client, workflowId: "effect-update-never-started", payload: { language: "french" }, @@ -148,7 +148,7 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { .executeUpdate(WORKFLOW_UPDATE, { args: [{ updateName: SetLanguage.name, payload: { language: 42 } }], }); - const malformedExit = updateCodec(SetLanguage).decodeExit(wire); + const malformedExit = updateCodec(SetLanguage.update).decodeExit(wire); expect(Exit.isFailure(malformedExit) && Cause.hasDies(malformedExit.cause)).toBe(true); expect( Exit.isFailure(malformedExit) && String(Cause.squash(malformedExit.cause)), @@ -158,7 +158,7 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { // well-formed update, with state untouched. const ok = await Effect.runPromise( Effect.result( - executeUpdate(SetLanguage, { + executeUpdate(SetLanguage.update, { client, workflowId: executionId, payload: { language: "french" }, @@ -168,8 +168,8 @@ describe("DurableUpdate over Temporal", { concurrent: false }, () => { expect(Result.isSuccess(ok) && ok.success).toBe("english"); await run( - DurableDeferred.done(Approved, { - token: DurableDeferred.tokenFromExecutionId(Approved, { + DurableDeferred.done(Approved.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approved.deferred, { workflow: MessageDemo, executionId, }), diff --git a/src/__tests__/payload-codec.test.ts b/src/__tests__/payload-codec.test.ts index 9747722..8acd816 100644 --- a/src/__tests__/payload-codec.test.ts +++ b/src/__tests__/payload-codec.test.ts @@ -114,7 +114,7 @@ describe("payload codecs", { concurrent: false }, () => { // Update request/response, typed both ways. const previous = await run( - executeUpdate(SetLanguage, { + executeUpdate(SetLanguage.update, { client, workflowId: executionId, payload: { language: "french" }, @@ -123,7 +123,7 @@ describe("payload codecs", { concurrent: false }, () => { expect(previous).toBe("english"); const rejected = await Effect.runPromise( Effect.result( - executeUpdate(SetLanguage, { + executeUpdate(SetLanguage.update, { client, workflowId: executionId, payload: { language: "klingon" }, @@ -134,14 +134,14 @@ describe("payload codecs", { concurrent: false }, () => { // State-cell query. const snapshot = await Effect.runPromise( - readStateCell(CurrentLanguage, { client, workflowId: executionId }), + readStateCell(CurrentLanguage.cell, { client, workflowId: executionId }), ); expect(Option.isSome(snapshot) && snapshot.value).toBe("french"); // Deferred-done signal + final result. await run( - DurableDeferred.done(Approved, { - token: DurableDeferred.tokenFromExecutionId(Approved, { + DurableDeferred.done(Approved.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approved.deferred, { workflow: MessageDemo, executionId, }), diff --git a/src/__tests__/primitives.test.ts b/src/__tests__/primitives.test.ts index bc6c51b..55aed8d 100644 --- a/src/__tests__/primitives.test.ts +++ b/src/__tests__/primitives.test.ts @@ -42,8 +42,11 @@ const makeActivities = () => { }; const approve = (executionId: string, approver: string) => - DurableDeferred.done(Approval, { - token: DurableDeferred.tokenFromExecutionId(Approval, { workflow: Demo, executionId }), + DurableDeferred.done(Approval.deferred, { + token: DurableDeferred.tokenFromExecutionId(Approval.deferred, { + workflow: Demo, + executionId, + }), exit: Exit.succeed(approver), }); @@ -64,7 +67,7 @@ describe("core primitives over Temporal", { concurrent: false }, () => { expect( Option.isNone( await Effect.runPromise( - deferredState(Approval, { client: engineOptions.client, workflowId: executionId }), + deferredState(Approval.deferred, { client: engineOptions.client, workflowId: executionId }), ), ), ).toBe(true); @@ -77,7 +80,7 @@ describe("core primitives over Temporal", { concurrent: false }, () => { // Resolved deferred now reads back its typed exit. const state = await Effect.runPromise( - deferredState(Approval, { client: engineOptions.client, workflowId: executionId }), + deferredState(Approval.deferred, { client: engineOptions.client, workflowId: executionId }), ); expect(Option.isSome(state) && Exit.isSuccess(state.value) && state.value.value).toBe("uri"); @@ -263,7 +266,7 @@ describe("core primitives over Temporal", { concurrent: false }, () => { expect( Option.isNone( await Effect.runPromise( - deferredState(Approval, { client: engineOptions.client, workflowId: executionId }), + deferredState(Approval.deferred, { client: engineOptions.client, workflowId: executionId }), ), ), ).toBe(true); diff --git a/src/__tests__/prototype/def.ts b/src/__tests__/prototype/def.ts deleted file mode 100644 index dd8cfaf..0000000 --- a/src/__tests__/prototype/def.ts +++ /dev/null @@ -1,233 +0,0 @@ -// PROTOTYPE — throwaway design spike, NOT production code. -// -// Question: can ONE declaration (workflow + activities + messages + state) -// make the types flow to the handler, the worker implementation, and the -// client — with the handler fully engine-agnostic? This is the answer to -// the "TypedActivity / DurableDeferred leak": the workflow body should -// import nothing from engine-sandbox; every capability arrives as a typed -// `ops` toolkit derived from the single declaration, and the ENGINE decides -// how each op executes. - -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Workflow from "effect/unstable/workflow/Workflow"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -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"; - -// ── Declaration shapes ─────────────────────────────────────────────────────── - -export interface ActivityDecl { - readonly payload: Schema.Top; - readonly success?: Schema.Top; - readonly error?: Schema.Top; - readonly options?: TypedActivity.TypedActivityOptions; -} - -export type MessageDecl = - | { readonly deferred: Schema.Top } - | { readonly mailbox: Schema.Top } - | { readonly update: { readonly payload: Schema.Top; readonly success: Schema.Top; readonly error: Schema.Top } }; - -type SchemaType = S extends Schema.Top ? S["Type"] : Fallback; - -// ── The typed ops toolkit the handler receives ─────────────────────────────── - -export interface UpdateRequestOf { - readonly payload: P; - readonly respond: (exit: Exit.Exit) => Effect.Effect; -} - -export type OpsOf< - A extends Record, - M extends Record, - St extends Record, -> = { - readonly activity: { - readonly [K in keyof A]: ( - payload: A[K]["payload"]["Type"], - ) => Effect.Effect, SchemaType>; - }; - readonly message: { - readonly [K in keyof M]: M[K] extends { readonly deferred: infer S extends Schema.Top } - ? { readonly await: Effect.Effect } - : M[K] extends { readonly mailbox: infer P extends Schema.Top } - ? { - readonly take: Effect.Effect; - readonly poll: Effect.Effect>; - } - : M[K] extends { - readonly update: { - readonly payload: infer P extends Schema.Top; - readonly success: infer S extends Schema.Top; - readonly error: infer E extends Schema.Top; - }; - } - ? { readonly take: Effect.Effect> } - : never; - }; - readonly state: { - readonly [K in keyof St]: { readonly set: (value: St[K]["Type"]) => Effect.Effect }; - }; -}; - -// ── The one engine seam: an untyped runtime the typed ops dispatch through ── -// This is what each engine implements. The Temporal one wraps the existing -// engine-sandbox machinery; the memory one is plain queues and deferreds. - -export interface OpsRuntime { - readonly activity: ( - activity: TypedActivity.AnyTypedActivity, - payload: unknown, - ) => Effect.Effect; - readonly deferredAwait: (name: string) => Effect.Effect; - readonly mailboxTake: (name: string) => Effect.Effect; - readonly mailboxPoll: (name: string) => Effect.Effect>; - readonly updateTake: ( - name: string, - ) => Effect.Effect>; - readonly stateSet: (name: string, value: unknown) => Effect.Effect; -} - -// ── Worker implementation typing: completeness-checked from the declaration ─ - -export type ImplementationsOf> = { - readonly [K in keyof A]: ( - payload: A[K]["payload"]["Type"], - ) => Effect.Effect, SchemaType>; -}; - -// ── defineWorkflow ─────────────────────────────────────────────────────────── - -export const defineWorkflow = < - const Tag extends string, - const P extends Schema.Struct.Fields, - S extends Schema.Top, - E extends Schema.Top, - const A extends Record, - const M extends Record, - const St extends Record, ->( - tag: Tag, - decl: { - readonly payload: P; - readonly idempotencyKey: (payload: Schema.Struct

["Type"]) => string; - readonly success?: S; - readonly error?: E; - readonly activities?: A; - readonly messages?: M; - readonly state?: St; - }, -) => { - const workflow = Workflow.make(tag, { - payload: decl.payload, - idempotencyKey: decl.idempotencyKey, - ...(decl.success === undefined ? {} : { success: decl.success }), - ...(decl.error === undefined ? {} : { error: decl.error }), - }); - - // Materialize the existing primitives once, from the declaration. Names - // are namespaced by tag so two definitions never collide. - const activities = Object.fromEntries( - Object.entries(decl.activities ?? {}).map(([key, a]) => [ - key, - TypedActivity.make(`${tag}/${key}`, { - payload: a.payload, - ...(a.success === undefined ? {} : { success: a.success }), - ...(a.error === undefined ? {} : { error: a.error }), - ...(a.options === undefined ? {} : { options: a.options }), - }), - ]), - ) as Record; - - const messages = decl.messages ?? ({} as M); - const messageName = (key: string) => `${tag}/${key}`; - const deferreds = Object.fromEntries( - Object.entries(messages) - .filter(([, m]) => "deferred" in m) - .map(([key, m]) => [ - key, - DurableDeferred.make(messageName(key), { success: (m as { deferred: Schema.Top }).deferred }), - ]), - ); - const mailboxes = Object.fromEntries( - Object.entries(messages) - .filter(([, m]) => "mailbox" in m) - .map(([key, m]) => [ - key, - DurableMailbox.make(messageName(key), { payload: (m as { mailbox: Schema.Top }).mailbox }), - ]), - ); - const updates = Object.fromEntries( - Object.entries(messages) - .filter(([, m]) => "update" in m) - .map(([key, m]) => { - const u = (m as { update: { payload: Schema.Top; success: Schema.Top; error: Schema.Top } }).update; - return [key, DurableUpdate.make(messageName(key), { payload: u.payload, success: u.success, error: u.error })]; - }), - ); - const cells = Object.fromEntries( - Object.entries(decl.state ?? {}).map(([key, value]) => [ - key, - StateCell.make(messageName(key), { value }), - ]), - ); - - /** Build the TYPED ops toolkit over an untyped runtime — the single place - * the unknown-seam casts live. */ - const makeOps = (runtime: OpsRuntime): OpsOf => { - const activity = Object.fromEntries( - Object.entries(activities).map(([key, a]) => [ - key, - (payload: unknown) => runtime.activity(a, payload), - ]), - ); - const message = Object.fromEntries( - Object.keys(messages).map((key) => { - const name = messageName(key); - const m = messages[key]!; - if ("deferred" in m) return [key, { await: runtime.deferredAwait(name) }]; - if ("mailbox" in m) - return [key, { take: runtime.mailboxTake(name), poll: runtime.mailboxPoll(name) }]; - return [key, { take: runtime.updateTake(name) }]; - }), - ); - const state = Object.fromEntries( - Object.keys(cells).map((key) => [ - key, - { set: (value: unknown) => runtime.stateSet(messageName(key), value) }, - ]), - ); - // SAFETY (prototype): the runtime dispatches by the primitives built - // from the same declaration the Ops type is derived from. - return { activity, message, state } as unknown as OpsOf; - }; - - type Payload = Schema.Struct

["Type"]; - type Success = SchemaType; - type Err = SchemaType; - - return { - tag, - workflow, - activities, - deferreds, - mailboxes, - updates, - cells, - makeOps, - /** Bind the engine-agnostic handler. The handler's R is `never`: it can - * touch the outside world only through the typed ops. */ - handler: ( - body: (payload: Payload, ops: OpsOf) => Effect.Effect, - ) => ({ definition: { tag, workflow, activities, deferreds, mailboxes, updates, cells, makeOps }, body }), - /** Worker-side implementations, completeness-checked from the declaration. */ - implement: (impls: ImplementationsOf): ImplementationsOf => impls, - }; -}; - -export type AnyDefined = ReturnType; diff --git a/src/__tests__/prototype/one-declaration.test.ts b/src/__tests__/prototype/one-declaration.test.ts deleted file mode 100644 index 9d32315..0000000 --- a/src/__tests__/prototype/one-declaration.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// PROTOTYPE — throwaway. The proof for the single-declaration design: -// -// 1. TYPES FLOW: payloads, successes, and typed errors infer end-to-end -// from the one declaration (pinned below with expectTypeOf). -// 2. NO LEAK: the SAME handler function object runs (a) on a plain -// in-memory runtime with zero engine anywhere, and (b) on real -// Temporal via the existing engine — the definition and handler are -// engine-agnostic. - -import { fileURLToPath } from "node:url"; -import * as Deferred from "effect/Deferred"; -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 Queue from "effect/Queue"; -import * as Result from "effect/Result"; -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, type BoundActivity } from "../../activities.js"; -import { executeUpdate, makeTemporalClientEngine, offerMailbox, readStateCell } from "../../engine-client.js"; -import { createWorkflowTestEnv } from "../utils/workflow-test-env.js"; -import type { OpsRuntime, UpdateRequestOf } from "./def.js"; -import { CardDeclined, Order, orderBound, orderImpls } from "./order.js"; - -const temporal = createWorkflowTestEnv("proto-one-decl"); - -// ── 1. The type pins ───────────────────────────────────────────────────────── - -const _types = () => { - const bound = Order.handler((payload, ops) => { - expectTypeOf(payload).toEqualTypeOf<{ readonly orderId: string }>(); - // Activities: payload in, success out, typed error channel. - const charge = ops.activity.charge({ orderId: payload.orderId, amountCents: 1 }); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf(); - // Messages: deferred success, mailbox payload, update request typing. - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf<{ - readonly level: number; - }>(); - expectTypeOf>().toEqualTypeOf< - UpdateRequestOf<{ readonly amountCents: number }, number, string> - >(); - return Effect.succeed("ok"); - }); - void bound; - - // Worker implementations are completeness-checked from the declaration. - Order.implement({ - reserve: () => Effect.succeed("r"), - // @ts-expect-error wrong success type - charge: () => Effect.succeed(42), - }); - // @ts-expect-error missing implementation for `charge` - Order.implement({ reserve: () => Effect.succeed("r") }); -}; -void _types; - -// ── 2. The in-memory runtime: no engine, plain Effect ──────────────────────── - -const makeMemoryWorld = Effect.gen(function* () { - // The prototype hardcodes the declaration's channel names — throwaway. - const approval = yield* Deferred.make(); - const priority = yield* Queue.unbounded(); - const setAmount = yield* Queue.unbounded>(); - const state = new Map(); - - const runtime: OpsRuntime = { - activity: (activity, payload) => { - const impl = (orderImpls as unknown as Record Effect.Effect>)[ - activity.name.split("/")[1]! - ]!; - return impl(payload as never); - }, - deferredAwait: () => Deferred.await(approval), - mailboxTake: () => Queue.take(priority), - mailboxPoll: () => Queue.poll(priority), - updateTake: () => Queue.take(setAmount), - stateSet: (name, value) => Effect.sync(() => void state.set(name, value)), - }; - - return { - runtime, - approve: (value: unknown) => Deferred.done(approval, Exit.succeed(value)), - offer: (value: unknown) => Queue.offer(priority, value), - update: (payload: unknown) => - Effect.gen(function* () { - const reply = yield* Deferred.make(); - yield* Queue.offer(setAmount, { - payload, - respond: (exit: Exit.Exit) => - Deferred.done(reply, exit as Exit.Exit).pipe(Effect.asVoid), - }); - return yield* Deferred.await(reply); - }), - readState: (name: string) => state.get(name), - }; -}); - -describe("prototype: one declaration, types flow, no engine leak", { concurrent: false }, () => { - it("runs the SAME handler on a plain in-memory runtime (no engine at all)", async () => { - await Effect.runPromise( - Effect.gen(function* () { - const world = yield* makeMemoryWorld; - const ops = Order.makeOps(world.runtime); - - const fiber = yield* Effect.forkChild(orderBound.body({ orderId: "m-1" }, ops)); - - // 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.update({ amountCents: 2500 }); - expect(previous).toBe(1000); - yield* world.offer({ level: 2 }); - yield* settle; - expect(world.readState("protoOrder/status")).toEqual({ phase: "awaiting-approval" }); - yield* world.approve("memory-ben"); - - const result = yield* Fiber.join(fiber); - expect(result).toBe("res-m-1|receipt-m-1-2500|p2|by:memory-ben"); - expect(world.readState("protoOrder/status")).toEqual({ phase: "complete" }); - }), - ); - }, 20_000); - - it("runs the SAME handler on real Temporal through the existing engine", async () => { - const workflowsPath = fileURLToPath(new URL("./order-workflows.ts", import.meta.url)); - const runner: ActivityRunner = { - run: (_name, _payload, effect) => Effect.runPromiseExit(effect), - }; - const activities = implementActivities( - runner, - Object.entries(Order.activities).map(([key, activity]) => - handle(activity, (orderImpls as never as Record)[key]!), - ) as ReadonlyArray>, - ); - - 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)); - const approve = (executionId: string, approver: string) => - run( - DurableDeferred.done(Order.deferreds["approval"]!, { - token: DurableDeferred.tokenFromExecutionId(Order.deferreds["approval"]!, { - workflow: Order.workflow, - executionId, - }), - exit: Exit.succeed(approver), - // The fromEntries-built deferred record erases services — prototype. - }) as Effect.Effect, - ); - - const payload = { orderId: "t-1" }; - const workflowId = await run(Order.workflow.execute(payload, { discard: true })); - - // Same drive sequence as the memory test, through the real client ops. - const previous = await Effect.runPromise( - executeUpdate(Order.updates["setAmount"]!, { - client, - workflowId, - payload: { amountCents: 2500 }, - }), - ); - expect(previous).toBe(1000); - await Effect.runPromise( - offerMailbox(Order.mailboxes["priority"]!, { client, workflowId, payload: { level: 2 } }), - ); - const mid = await Effect.runPromise( - readStateCell(Order.cells["status"]!, { client, workflowId }), - ); - expect(Option.getOrNull(mid)).toEqual({ phase: "awaiting-approval" }); - await approve(workflowId, "temporal-ben"); - - const result = await run(Order.workflow.execute(payload)); - expect(result).toBe("res-t-1|receipt-t-1-2500|p2|by:temporal-ben"); - const final = await Effect.runPromise( - readStateCell(Order.cells["status"]!, { client, workflowId }), - ); - expect(Option.getOrNull(final)).toEqual({ phase: "complete" }); - - // The typed activity failure flows into the workflow error channel. - const declinePayload = { orderId: "t-declined" }; - const declineId = await run(Order.workflow.execute(declinePayload, { discard: true })); - await Effect.runPromise( - executeUpdate(Order.updates["setAmount"]!, { - client, - workflowId: declineId, - payload: { amountCents: 10_000 }, - }), - ); - await Effect.runPromise( - offerMailbox(Order.mailboxes["priority"]!, { - client, - workflowId: declineId, - payload: { level: 1 }, - }), - ); - await approve(declineId, "x"); - const declined = await run(Effect.result(Order.workflow.execute(declinePayload))); - expect(Result.isFailure(declined) && declined.failure).toEqual({ - _tag: "CardDeclined", - orderId: "t-declined", - }); - }); - }, 120_000); -}); diff --git a/src/__tests__/prototype/order-workflows.ts b/src/__tests__/prototype/order-workflows.ts deleted file mode 100644 index b7f047d..0000000 --- a/src/__tests__/prototype/order-workflows.ts +++ /dev/null @@ -1,8 +0,0 @@ -// PROTOTYPE — throwaway. The Temporal bundle for the prototype definition: -// the SAME bound handler the memory test runs, hosted on the real engine. - -import { workflowBundle } from "../../engine-sandbox.js"; -import { toTemporalLayer } from "./temporal-runtime.js"; -import { orderBound } from "./order.js"; - -export default workflowBundle(toTemporalLayer(orderBound)); diff --git a/src/__tests__/prototype/order.ts b/src/__tests__/prototype/order.ts deleted file mode 100644 index afe6784..0000000 --- a/src/__tests__/prototype/order.ts +++ /dev/null @@ -1,83 +0,0 @@ -// PROTOTYPE — throwaway. THE single declaration, and the one -// engine-agnostic handler bound to it. Note what this module imports: -// upstream Effect only, plus the prototype's def module. No engine-sandbox, -// no Temporal, no per-primitive make calls — the leak under test. - -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Schema from "effect/Schema"; -import { defineWorkflow } from "./def.js"; - -export const CardDeclined = Schema.TaggedStruct("CardDeclined", { - orderId: Schema.String, -}); - -export const Order = defineWorkflow("protoOrder", { - payload: { orderId: Schema.String }, - idempotencyKey: ({ orderId }) => orderId, - success: Schema.String, - error: CardDeclined, - activities: { - reserve: { payload: Schema.Struct({ orderId: Schema.String }), success: Schema.String }, - charge: { - payload: Schema.Struct({ orderId: Schema.String, amountCents: Schema.Finite }), - success: Schema.String, - error: CardDeclined, - }, - }, - messages: { - approval: { deferred: Schema.String }, - priority: { mailbox: Schema.Struct({ level: Schema.Finite }) }, - setAmount: { - update: { - payload: Schema.Struct({ amountCents: Schema.Finite }), - success: Schema.Finite, // the previous amount - error: Schema.String, // "amount-too-low" - }, - }, - }, - state: { - status: Schema.Struct({ phase: Schema.String }), - }, -}); - -/** The handler: every capability arrives through `ops`, fully typed from - * the declaration above; its R channel is `never`. */ -export const orderBound = Order.handler((payload, ops) => - Effect.gen(function* () { - yield* ops.state.status.set({ phase: "reserving" }); - const reservation = yield* ops.activity.reserve({ orderId: payload.orderId }); - - // A typed update: respond with the PREVIOUS amount, or a typed refusal. - yield* ops.state.status.set({ phase: "pricing" }); - let amountCents = 1000; - const request = yield* ops.message.setAmount.take; - if (request.payload.amountCents < 100) { - yield* request.respond(Exit.fail("amount-too-low")); - } else { - yield* request.respond(Exit.succeed(amountCents)); - amountCents = request.payload.amountCents; - } - - // A mailbox message and a one-shot approval. - const priority = yield* ops.message.priority.take; - yield* ops.state.status.set({ phase: "awaiting-approval" }); - const approver = yield* ops.message.approval.await; - - // A typed activity failure flows straight into the workflow error channel. - const receipt = yield* ops.activity.charge({ orderId: payload.orderId, amountCents }); - - yield* ops.state.status.set({ phase: "complete" }); - return `${reservation}|${receipt}|p${priority.level}|by:${approver}`; - }), -); - -/** Worker-side activity implementations — completeness-checked against the - * declaration; engine decides where they run. */ -export const orderImpls = Order.implement({ - reserve: ({ orderId }) => Effect.succeed(`res-${orderId}`), - charge: ({ orderId, amountCents }) => - amountCents >= 10_000 - ? Effect.fail({ _tag: "CardDeclined", orderId } as const) - : Effect.succeed(`receipt-${orderId}-${amountCents}`), -}); diff --git a/src/__tests__/prototype/temporal-runtime.ts b/src/__tests__/prototype/temporal-runtime.ts deleted file mode 100644 index 7fe0784..0000000 --- a/src/__tests__/prototype/temporal-runtime.ts +++ /dev/null @@ -1,78 +0,0 @@ -// PROTOTYPE — throwaway. The Temporal implementation of the ops seam: -// every op dispatches into the EXISTING engine-sandbox machinery, so the -// prototype rides the proven engine underneath. Sandbox-only module. - -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import { - callActivity, - pollMailbox, - setStateCell, - takeMailbox, - takeUpdate, -} from "../../engine-sandbox.js"; -import type { DurableMailbox } from "../../mailbox.js"; -import type { DurableUpdate } from "../../update.js"; -import type { StateCell } from "../../state-cell.js"; -import type * as Schema from "effect/Schema"; -import type { OpsRuntime } from "./def.js"; - -interface DefinitionLike { - readonly workflow: { - readonly toLayer: (execute: (payload: any, executionId: string) => Effect.Effect) => Layer.Layer; - }; - readonly deferreds: Record>; - readonly mailboxes: Record>; - readonly updates: Record>; - readonly cells: Record>; - readonly makeOps: (runtime: OpsRuntime) => any; -} - -const byName = (record: Record) => - new Map(Object.values(record).map((item) => [item.name, item])); - -const temporalRuntime = (def: DefinitionLike): OpsRuntime => { - const deferreds = new Map(Object.entries(def.deferreds).map(([k, d]) => [d.name ?? k, d])); - const mailboxes = byName(def.mailboxes); - const updates = byName(def.updates); - const cells = byName(def.cells); - const missing = (kind: string, name: string) => - Effect.die(`prototype: no ${kind} named "${name}" in this definition`); - // SAFETY (prototype): these ops require SandboxRun / the engine at the - // type level; the per-run wrapper provides them. The seam types R=never - // so handlers stay engine-agnostic. - return { - activity: (activity, payload) => callActivity(activity, payload as never) as never, - deferredAwait: (name) => { - const d = deferreds.get(name); - return d ? (DurableDeferred.await(d) as never) : missing("deferred", name); - }, - mailboxTake: (name) => { - const m = mailboxes.get(name); - return m ? (takeMailbox(m) as never) : missing("mailbox", name); - }, - mailboxPoll: (name) => { - const m = mailboxes.get(name); - return m ? (pollMailbox(m) as never) : missing("mailbox", name); - }, - updateTake: (name) => { - const u = updates.get(name); - return u ? (takeUpdate(u) as never) : missing("update", name); - }, - stateSet: (name, value) => { - const c = cells.get(name); - return c ? (setStateCell(c, value) as never) : missing("cell", name); - }, - }; -}; - -/** Turn a bound handler into an upstream `toLayer` registration whose body - * receives Temporal-backed typed ops — ready for `workflowBundle`. */ -export const toTemporalLayer = (bound: { - readonly definition: DefinitionLike; - readonly body: (payload: any, ops: any) => Effect.Effect; -}): Layer.Layer => { - const ops = bound.definition.makeOps(temporalRuntime(bound.definition)); - return bound.definition.workflow.toLayer((payload) => bound.body(payload, ops)); -}; diff --git a/src/__tests__/prototype/schema-evolution.test.ts b/src/__tests__/schema-evolution.test.ts similarity index 58% rename from src/__tests__/prototype/schema-evolution.test.ts rename to src/__tests__/schema-evolution.test.ts index ee5d754..e2df554 100644 --- a/src/__tests__/prototype/schema-evolution.test.ts +++ b/src/__tests__/schema-evolution.test.ts @@ -1,36 +1,16 @@ -// PROTOTYPE — throwaway. The data half of versioning: can a declaration's -// schema EVOLVE (add/change fields) while old runs are in flight? -// -// Every boundary in the engine is schema-encoded JSON, and every decode -// happens deterministically on replay — so the whole problem reduces to: -// the CURRENT schema must decode the wire that OLD code wrote. `evolved` -// makes that a declaration-level concern: newest schema first, legacy -// schemas behind pure migrations, one Type coming out — so handler types -// only ever see the newest shape. +// The data half of versioning: a declaration's schema can EVOLVE (add or +// change fields) while old runs are in flight. Every boundary in the engine +// is schema-encoded JSON, and every decode happens deterministically on +// replay — so the whole problem reduces to: the CURRENT schema must decode +// the wire that OLD code wrote. `evolved` makes that a declaration-level +// concern: newest schema first, legacy schemas behind pure migrations, one +// Type coming out — so handler types only ever see the newest shape. import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as SchemaGetter from "effect/SchemaGetter"; import { describe, expect, expectTypeOf, it } from "vitest"; -import { wireValueCodec } from "../../wire.js"; - -/** Newest-first schema evolution: decode tries `current`, then each legacy - * schema migrated forward by a PURE function (pure = deterministic on - * replay). Encoding always writes the newest shape. */ -const evolved = ( - current: Current, - legacy: Legacy, - migrate: (value: Legacy["Type"]) => Current["Type"], -) => - Schema.Union([ - current, - legacy.pipe( - Schema.decodeTo(current, { - decode: SchemaGetter.transform(migrate), - encode: SchemaGetter.forbidden(() => "legacy shapes are never written"), - }), - ), - ]); +import { evolved } from "../definition.js"; +import { wireValueCodec } from "../wire.js"; // V1 shipped without `priority`; V2 adds it. In-flight runs hold V1 wire in // their histories (start events, activity results, buffered signals). @@ -38,7 +18,7 @@ const OrderV1 = Schema.Struct({ orderId: Schema.String }); const OrderV2 = Schema.Struct({ orderId: Schema.String, priority: Schema.Finite }); const OrderPayload = evolved(OrderV2, OrderV1, (v1) => ({ ...v1, priority: 0 })); -describe("prototype: schema evolution across in-flight versions", () => { +describe("schema evolution across in-flight versions", () => { it("decodes V1 wire (old histories) and V2 wire to ONE newest Type", () => { const codec = wireValueCodec(OrderPayload); @@ -54,9 +34,10 @@ describe("prototype: schema evolution across in-flight versions", () => { expect(v2Wire).toEqual({ orderId: "b", priority: 3 }); // The handler-facing Type is ONLY the newest shape. - expectTypeOf<(typeof OrderPayload)["Type"]>().toEqualTypeOf< - { readonly orderId: string; readonly priority: number } - >(); + expectTypeOf<(typeof OrderPayload)["Type"]>().toEqualTypeOf<{ + readonly orderId: string; + readonly priority: number; + }>(); }); it("rejects wire that matches NO version, instead of guessing", () => { diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index aa8493a..113e52e 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -99,23 +99,23 @@ const _activities = () => { const _mailboxes = () => { const { client } = clientOptions; - const taken = takeMailbox(StateUpdates); + const taken = takeMailbox(StateUpdates.mailbox); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); return [ taken, - offerMailbox(StateUpdates, { client, workflowId: "id", payload: { op: "finish" } }), - offerMailboxFromWorkflow(StateUpdates, { workflowId: "id", payload: { op: "finish" } }), + offerMailbox(StateUpdates.mailbox, { client, workflowId: "id", payload: { op: "finish" } }), + offerMailboxFromWorkflow(StateUpdates.mailbox, { workflowId: "id", payload: { op: "finish" } }), // @ts-expect-error unknown mailbox op - offerMailbox(StateUpdates, { client, workflowId: "id", payload: { op: "reset" } }), + offerMailbox(StateUpdates.mailbox, { client, workflowId: "id", payload: { op: "reset" } }), // @ts-expect-error a set requires key and value - offerMailboxFromWorkflow(StateUpdates, { workflowId: "id", payload: { op: "set" } }), + offerMailboxFromWorkflow(StateUpdates.mailbox, { workflowId: "id", payload: { op: "set" } }), ]; }; const _stateCells = () => { - const read = readStateCell(StateSnapshot, { + const read = readStateCell(StateSnapshot.cell, { client: clientOptions.client, workflowId: "id", }); @@ -125,15 +125,15 @@ const _stateCells = () => { return [ read, - setStateCell(StateSnapshot, { a: 1 }), + setStateCell(StateSnapshot.cell, { a: 1 }), // @ts-expect-error cell values are numbers - setStateCell(StateSnapshot, { a: "one" }), + setStateCell(StateSnapshot.cell, { a: "one" }), ]; }; const _updates = () => { const { client } = clientOptions; - const response = executeUpdate(SetLanguage, { + const response = executeUpdate(SetLanguage.update, { client, workflowId: "id", payload: { language: "french" }, @@ -143,9 +143,9 @@ const _updates = () => { const negative = // @ts-expect-error payload must match the update's schema - executeUpdate(SetLanguage, { client, workflowId: "id", payload: { lang: "x" } }); + executeUpdate(SetLanguage.update, { client, workflowId: "id", payload: { lang: "x" } }); - const taken = takeUpdate(SetLanguage); + const taken = takeUpdate(SetLanguage.update); expectTypeOf>().toEqualTypeOf(); const served = Effect.andThen(taken, (request) => { expectTypeOf(request.payload).toEqualTypeOf<{ readonly language: string }>(); @@ -174,7 +174,7 @@ const _continueAsNew = () => { }; const _deferreds = () => { - const state = deferredState(Approved, { client: clientOptions.client, workflowId: "id" }); + const state = deferredState(Approved.deferred, { client: clientOptions.client, workflowId: "id" }); expectTypeOf>().toEqualTypeOf< Option.Option> >(); diff --git a/src/definition.ts b/src/definition.ts new file mode 100644 index 0000000..8e4c6b8 --- /dev/null +++ b/src/definition.ts @@ -0,0 +1,295 @@ +/** + * Engine-agnostic workflow capabilities: declare each primitive ONCE — + * activities, deferreds, mailboxes, updates, state cells — and use it + * directly inside workflow handlers: + * + * ```ts + * const Charge = defineActivity("charge", { + * payload: { orderId: Schema.String }, + * success: Schema.String, + * }); + * const Approval = defineDeferred("order/approval", { success: Schema.String }); + * + * const OrderLive = OrderFlow.toLayer((payload) => + * Effect.gen(function* () { + * const receipt = yield* Charge({ orderId: payload.orderId }); + * 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 + * `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. + * + * @since 0.3.0 + */ + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * 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 * 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"; + +// ─── The ops seam ──────────────────────────────────────────────────────────── + +/** + * A taken update request: the decoded payload and its one-shot typed + * response channel. + * + * @since 0.3.0 + * @category models + */ +export interface UpdateRequest { + readonly payload: P; + readonly respond: (exit: Exit.Exit) => Effect.Effect; +} + +/** + * What an engine implements to host declared capabilities: one operation + * per primitive kind, dispatching on the declaration instances. The typed + * surfaces below narrow this seam exactly once each. + * + * @since 0.3.0 + * @category models + */ +export interface WorkflowOpsRuntime { + readonly activity: ( + activity: TypedActivity.AnyTypedActivity, + payload: unknown, + ) => Effect.Effect; + readonly deferredAwait: ( + deferred: DurableDeferred.DurableDeferred, + ) => Effect.Effect; + readonly mailboxTake: ( + mailbox: DurableMailbox.DurableMailbox, + ) => Effect.Effect; + readonly mailboxPoll: ( + mailbox: DurableMailbox.DurableMailbox, + ) => Effect.Effect>; + readonly updateTake: ( + update: DurableUpdate.DurableUpdate, + ) => Effect.Effect>; + readonly stateSet: (cell: StateCell.StateCell, value: unknown) => Effect.Effect; + readonly version: (site: string, names: ReadonlyArray) => Effect.Effect; +} + +/** + * The service an engine provides to run declared capabilities — + * `workflowBundle` provides the Temporal runtime automatically; the + * `testing` module provides an in-memory one. + * + * @since 0.3.0 + * @category services + */ +export class WorkflowOps extends Context.Service()( + "effect-temporal/WorkflowOps", +) {} + +// ─── 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. + * + * @since 0.3.0 + * @category models + */ +export interface DefinedActivity< + Name extends string, + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +> extends TypedActivity.TypedActivity { + (payload: Payload["Type"]): Effect.Effect; +} + +/** + * Declare an activity where its implementation lives; call it with its + * payload from any workflow handler: `yield* Charge({ orderId })`. + * + * @since 0.3.0 + * @category constructors + */ +export const defineActivity = < + 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, + decl: { + readonly payload: Payload; + readonly success?: Success; + readonly error?: Error; + readonly options?: TypedActivity.TypedActivityOptions; + }, +): DefinedActivity< + Name, + Payload extends Schema.Struct.Fields ? Schema.Struct : Payload, + Success, + Error +> => { + const activity = TypedActivity.make(name, decl); + const call = (payload: unknown) => + Effect.flatMap(WorkflowOps, (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 + // this very declaration carries. + return Object.defineProperties(call, Object.getOwnPropertyDescriptors(activity)) as never; +}; + +// ─── Message channels and state ────────────────────────────────────────────── + +/** + * 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`). + * + * @since 0.3.0 + * @category constructors + */ +export const defineDeferred = ( + name: string, + decl: { readonly success: Success }, +) => { + const deferred = DurableDeferred.make(name, { success: decl.success }); + return { + name, + deferred, + await: Effect.flatMap(WorkflowOps, (runtime) => + runtime.deferredAwait(deferred as DurableDeferred.DurableDeferred), + ) as Effect.Effect, + } as const; +}; + +/** + * A durable inbound message queue. `take`/`poll` consume inside a handler; + * the underlying `mailbox` drives the client side (`offerMailbox`). + * + * @since 0.3.0 + * @category constructors + */ +export const defineMailbox = ( + name: string, + decl: { readonly payload: Payload }, +) => { + 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< + Payload["Type"], + never, + WorkflowOps + >, + poll: withRuntime((runtime) => runtime.mailboxPoll(mailbox)) as Effect.Effect< + Option.Option, + never, + WorkflowOps + >, + } as const; +}; + +/** + * 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`). + * + * @since 0.3.0 + * @category constructors + */ +export const defineUpdate = < + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, +>( + name: string, + decl: { readonly payload: Payload; readonly success: Success; readonly error: Error }, +) => { + const update = DurableUpdate.make(name, decl); + return { + name, + update, + take: Effect.flatMap(WorkflowOps, (runtime) => + runtime.updateTake(update), + ) as Effect.Effect, never, WorkflowOps>, + } as const; +}; + +/** + * Observable workflow state. `set` publishes inside a handler; the + * underlying `cell` drives the client side (`readStateCell`). + * + * @since 0.3.0 + * @category constructors + */ +export const defineState = ( + name: string, + decl: { readonly value: Value }, +) => { + 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; +}; + +/** + * 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 + */ +export const version = ( + site: string, + names: Names, +): Effect.Effect => + Effect.flatMap(WorkflowOps, (runtime) => + runtime.version(site, names), + ) as Effect.Effect; + +// ─── Schema evolution ──────────────────────────────────────────────────────── + +/** + * Newest-first schema evolution for any declared boundary: decode tries + * `current`, then `legacy` migrated forward by a PURE function (purity is + * what keeps replay deterministic). Encoding always writes the newest + * shape, and handlers only ever see the newest Type. Chain `evolved` calls + * for further generations. + * + * @since 0.3.0 + * @category schemas + */ +export const evolved = ( + current: Current, + legacy: Legacy, + migrate: (value: Legacy["Type"]) => Current["Type"], +) => + Schema.Union([ + current, + legacy.pipe( + Schema.decodeTo(current, { + decode: SchemaGetter.transform(migrate), + encode: SchemaGetter.forbidden(() => "legacy shapes are never written"), + }), + ), + ]); diff --git a/src/engine-sandbox.ts b/src/engine-sandbox.ts index 9681e18..8756ff5 100644 --- a/src/engine-sandbox.ts +++ b/src/engine-sandbox.ts @@ -96,6 +96,9 @@ import { type MailboxSignalPayload, } from "./mailbox.js"; import { STATE_CELL_QUERY, stateCellCodec, type StateCell } from "./state-cell.js"; +import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; +import { WorkflowOps, type WorkflowOpsRuntime } from "./definition.js"; +import * as Versioning from "./versioning.js"; import { updateCodec, WORKFLOW_UPDATE, @@ -1020,12 +1023,12 @@ const registrationSandboxRun = new Proxy({} as RunState, { /** layer → registry, memoized per V8 context (reuseV8Context-safe). */ const workflowRegistries = new Map< - Layer.Layer, + Layer.Layer, Promise> >(); const buildRegistry = ( - workflows: Layer.Layer, + workflows: Layer.Layer, ): Effect.Effect> => Effect.gen(function* () { const registry = new Map(); @@ -1058,6 +1061,7 @@ const buildRegistry = ( Layer.mergeAll( Layer.succeed(WorkflowEngine.WorkflowEngine, registrationEngine), Layer.succeed(SandboxRunTag, registrationSandboxRun), + Layer.succeed(WorkflowOps, temporalWorkflowOps), ), ), scope, @@ -1065,6 +1069,27 @@ const buildRegistry = ( return registry; }); +/** + * The Temporal implementation of the declared-workflow ops seam: every + * operation dispatches into this module's machinery. Provided automatically + * to the layers `workflowBundle` hosts. + * + * @since 0.3.0 + * @category workflow + */ +export const temporalWorkflowOps: WorkflowOpsRuntime = { + // SAFETY: each op requires SandboxRun (and the engine) at the type level; + // the per-run wrapper provides them — same discipline as SandboxHandler. + activity: (activity, payload) => callActivity(activity, payload as never) as never, + deferredAwait: (deferred) => DurableDeferred.await(deferred) as never, + mailboxTake: (mailbox) => takeMailbox(mailbox) as never, + mailboxPoll: (mailbox) => pollMailbox(mailbox) as never, + updateTake: (update) => takeUpdate(update) as never, + stateSet: (cell, value) => setStateCell(cell, value) as never, + version: (site, names) => + Versioning.version(site, names as unknown as readonly [string, ...string[]]), +}; + /** * Host `Workflow.toLayer` registrations behind one dynamic Temporal * workflow, exported as the bundle's DEFAULT export — Temporal routes every @@ -1087,7 +1112,7 @@ const buildRegistry = ( * @category constructors */ export const workflowBundle = ( - workflows: Layer.Layer, + workflows: Layer.Layer, ): ((wirePayload: unknown) => Promise) => { return async function runDynamic(wirePayload: unknown): Promise { ensureSandboxPolyfills(); diff --git a/src/lint.js b/src/lint.js index d592cf4..effde93 100644 --- a/src/lint.js +++ b/src/lint.js @@ -124,11 +124,13 @@ const rules = { type: "suggestion", docs: { description: - "In workflow code, prefer callActivity (typed) or callRawActivity over raw " + - "Effect.promise: raw promises are not cancelled when the workflow is interrupted.", + "In workflow code, prefer a defined activity call (defineActivity) or " + + "callRawActivity over raw Effect.promise: raw promises are not cancelled " + + "when the workflow is interrupted.", }, messages: { - prefer: "Prefer callActivity or callRawActivity — this call is not cancelled on interrupt.", + prefer: + "Prefer a defined activity (or callRawActivity) — this call is not cancelled on interrupt.", }, schema: [], }, @@ -164,8 +166,9 @@ const rules = { type: "problem", docs: { description: - "Versioning.match/version/patched must run at a deterministic point on the main " + - "workflow fiber — inside forks and races, marker order becomes nondeterministic.", + "Versioning.match/version/patched — and the definition module's version() — must " + + "run at a deterministic point on the main workflow fiber: inside forks and races, " + + "marker order becomes nondeterministic.", }, messages: { fiber: "Do not evaluate versions inside a fork or race." }, schema: [], @@ -179,12 +182,14 @@ const rules = { forkDepth++; return; } - if ( - forkDepth > 0 && + const isVersioningMember = node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && - node.callee.object.name === "Versioning" - ) { + node.callee.object.name === "Versioning"; + // The definition module's bare `version(site, names)` call. + const isDefinedVersion = + node.callee.type === "Identifier" && node.callee.name === "version"; + if (forkDepth > 0 && (isVersioningMember || isDefinedVersion)) { state.reports.push({ node, messageId: "fiber" }); } }, diff --git a/src/testing.ts b/src/testing.ts index 3377632..d091d93 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -14,13 +14,20 @@ * @since 0.1.0 */ +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import type * as Schema from "effect/Schema"; 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 } from "./wire.js"; /** @@ -402,3 +409,152 @@ export const startWorkflowTestHarness = async ( }, }; }; + +// ─── In-memory WorkflowOps ─────────────────────────────────────────────────── + +/** + * A running in-memory world for handler unit tests: provide `layer` to a + * workflow handler and drive its declared channels from the outside — + * exactly what a client would do against the real engine, minus the engine. + * + * @since 0.3.0 + * @category models + */ +export interface TestWorkflowOps { + /** Provides `WorkflowOps` backed by this world. */ + readonly layer: Layer.Layer; + /** Resolve a declared deferred, waking any handler blocked on `.await`. */ + readonly resolve: ( + deferred: { readonly deferred: object; readonly await: Effect.Effect }, + value: A, + ) => Effect.Effect; + /** Deliver one mailbox message. */ + readonly offer:

( + mailbox: { readonly mailbox: object; readonly take: Effect.Effect }, + payload: P, + ) => Effect.Effect; + /** Send an update request and await its typed response. */ + readonly request: ( + update: { + readonly update: object; + readonly take: Effect.Effect, never, WorkflowOps>; + }, + payload: P, + ) => Effect.Effect; + /** Read the last value a handler `set` on a declared state cell. */ + readonly stateOf: (cell: { + readonly cell: object; + readonly set: (value: V) => Effect.Effect; + }) => Effect.Effect>; +} + +/** + * Build an in-memory `WorkflowOps` runtime, so the SAME handler that runs + * on Temporal runs in a plain unit test: + * + * ```ts + * const world = yield* makeTestWorkflowOps({ + * activities: [handle(Charge, () => Effect.succeed("receipt"))], + * }); + * const fiber = yield* Effect.forkChild( + * orderHandler({ orderId: "o-1" }).pipe(Effect.provide(world.layer)), + * ); + * yield* world.resolve(Approval, "ben"); + * ``` + * + * 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. + * + * @since 0.3.0 + * @category constructors + */ +export const makeTestWorkflowOps = (options?: { + readonly activities?: ReadonlyArray>; +}): Effect.Effect => + Effect.sync(() => { + const bindings = new Map((options?.activities ?? []).map((b) => [b.activity.name, b])); + const runner: ActivityRunner = { + run: (_name, _payload, effect) => Effect.runPromiseExit(effect), + }; + const deferreds = new Map>(); + const mailboxes = new Map>(); + const updates = new Map>>(); + const cells = new Map(); + + const deferredFor = (key: object) => { + const existing = deferreds.get(key); + if (existing) return existing; + const created = Deferred.makeUnsafe(); + deferreds.set(key, created); + return created; + }; + const queueFor = (map: Map>, key: object) => + Effect.suspend(() => { + const existing = map.get(key); + if (existing) return Effect.succeed(existing); + return Effect.map(Queue.unbounded(), (created) => { + const raced = map.get(key); + if (raced) return raced; + map.set(key, created); + return created; + }); + }); + + const runtime: WorkflowOpsRuntime = { + activity: (activity, payload) => + Effect.suspend(() => { + const binding = bindings.get(activity.name); + if (binding === undefined) { + return Effect.die( + `makeTestWorkflowOps: no binding for activity "${activity.name}" — pass it in \`activities\``, + ); + } + const codecs = codecsFor(binding.activity); + const validated = codecs.payload.decode(codecs.payload.encode(payload)); + return Effect.flatten( + Effect.promise(() => binding.execute(validated as never, runner)), + ); + }), + deferredAwait: (deferred) => Deferred.await(deferredFor(deferred)), + mailboxTake: (mailbox) => Effect.flatMap(queueFor(mailboxes, mailbox), Queue.take), + mailboxPoll: (mailbox) => Effect.flatMap(queueFor(mailboxes, mailbox), Queue.poll), + updateTake: (update) => Effect.flatMap(queueFor(updates, update), Queue.take), + stateSet: (cell, value) => Effect.sync(() => void cells.set(cell, value)), + version: (_site, names) => Effect.succeed(names[names.length - 1]!), + }; + + const world: TestWorkflowOps = { + layer: Layer.succeed(WorkflowOps, runtime), + resolve: (wrapper, value) => + Effect.asVoid(Deferred.done(deferredFor(wrapper.deferred), Exit.succeed(value))), + offer: (wrapper, payload) => + Effect.asVoid( + Effect.flatMap(queueFor(mailboxes, wrapper.mailbox), (queue) => + Queue.offer(queue, payload), + ), + ), + request: (wrapper, payload) => + Effect.flatMap(queueFor(updates, wrapper.update), (queue) => { + const reply = Deferred.makeUnsafe(); + return Effect.flatMap( + Queue.offer(queue, { + payload, + respond: (exit) => Effect.asVoid(Deferred.done(reply, exit)), + }), + () => Deferred.await(reply), + ); + // SAFETY: the reply deferred completes only through `respond`, + // whose exit the wrapper's declaration types as S/E. + }) as Effect.Effect, + stateOf: (wrapper) => + Effect.sync(() => + cells.has(wrapper.cell) + ? Option.some(cells.get(wrapper.cell) as never) + : Option.none(), + ), + }; + return world; + }); From 17fb3944d0e8168044c50be5f9a499e9079d9623 Mon Sep 17 00:00:00 2001 From: Ben Weis Date: Thu, 27 Aug 2026 12:16:38 -0400 Subject: [PATCH 4/4] audit fixes: honest seam types, faithful memory runtime, live lint gate From the pre-PR bug/type-honesty audit: - engine-sandbox: eraseR replaces the as-never casts on the WorkflowOps runtime, so only the R channel is erased and success/error shapes stay compile-checked against the seam. UpdateRequest is now an alias of the definition module's (one shape, no drift). temporalWorkflowOps is module-private: outside the per-run wrapper its erased services are missing and every op would die at call time. - definition: WorkflowOpsRuntime.version is generic over the names tuple, deleting three casts across the seam and both engines. Dead deferred cast removed. Documented that declaration schemas must be context-free. - testing: the memory runtime now round-trips EVERY channel through the declaration's wire codec (activity payload/success/error, mailbox and update payloads, update responses, state values, deferred completions), so schema-invalid values defect in unit tests exactly as they would on Temporal. Responding twice to one update dies, mirroring the engine. - lint: the versioning-on-main-fiber rule now recognizes definition-only handler modules (which import nothing engine-shaped) via their version import, alias-aware; the old gate made the bare-version detection dead for exactly the files it was added for. Covered by a new lint fixture. --- docs/guide/declaring-capabilities.md | 4 ++ docs/guide/lint-rules.md | 2 +- src/__tests__/lint.test.ts | 20 ++++++++- src/definition.ts | 17 ++++--- src/engine-sandbox.ts | 45 ++++++++++++------- src/lint.js | 21 ++++++++- src/testing.ts | 66 +++++++++++++++++++++++----- 7 files changed, 139 insertions(+), 36 deletions(-) diff --git a/docs/guide/declaring-capabilities.md b/docs/guide/declaring-capabilities.md index c7e1350..2005fad 100644 --- a/docs/guide/declaring-capabilities.md +++ b/docs/guide/declaring-capabilities.md @@ -45,6 +45,10 @@ Every in-handler operation requires exactly one service, `WorkflowOps` — the s 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. +::: 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. +::: + ## Wire identity is the name The explicit name string — `"charge"`, `"order/approval"` — is the identity on the wire: the Temporal activity type, signal payload discriminator, query key, patch-marker site. Renaming a variable, moving a declaration to another module, or restructuring the handler never changes the wire; changing the *name* does, and is a versioning event. diff --git a/docs/guide/lint-rules.md b/docs/guide/lint-rules.md index 7376745..daef3ab 100644 --- a/docs/guide/lint-rules.md +++ b/docs/guide/lint-rules.md @@ -42,7 +42,7 @@ Two presets ship: `recommended` (all five rules, `prefer-call-temporal-activity` ## 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. +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`. The remaining footguns — drain mailboxes before `continueAsNew`, respond to updates before completion — are runtime-shaped and covered by runtime guards and the guide instead. diff --git a/src/__tests__/lint.test.ts b/src/__tests__/lint.test.ts index 4413ed1..bd07dd3 100644 --- a/src/__tests__/lint.test.ts +++ b/src/__tests__/lint.test.ts @@ -39,6 +39,16 @@ export const c = callRawActivity(() => acts.foo()); 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. +const DEFINITION_ONLY = ` +import * as Effect from "effect/Effect"; +import { version as pickVersion } from "@springbird/effect-temporal/definition"; + +export const bad = Effect.forkChild(pickVersion("site", ["v1", "v2"])); +export const fine = pickVersion("site", ["v1", "v2"]); +`; + const runOxlint = (directory: string, files: string[]) => { const config = join(directory, ".oxlintrc.json"); writeFileSync(config, JSON.stringify({ extends: [presetPath] })); @@ -51,10 +61,12 @@ describe("lint plugin", { concurrent: false }, () => { const directory = mkdtempSync(join(tmpdir(), "effect-workflow-lint-")); const bad = join(directory, "bad.ts"); const good = join(directory, "good.ts"); + const definitionOnly = join(directory, "definition-only.ts"); writeFileSync(bad, BAD); writeFileSync(good, GOOD); + writeFileSync(definitionOnly, DEFINITION_ONLY); - const output = runOxlint(directory, [bad, good]); + const output = runOxlint(directory, [bad, good, definitionOnly]); for (const rule of [ "zero-arity-effect-promise", @@ -70,5 +82,11 @@ describe("lint plugin", { concurrent: false }, () => { .split("\n") .filter((line) => line.includes("good.ts") && line.includes("effect-temporal(")); expect(goodFindings).toEqual([]); + + // The definition-only module (no engine imports, aliased `version`) is + // still covered by the versioning rule — exactly one finding, the fork. + const definitionOutput = runOxlint(directory, [definitionOnly]); + expect(definitionOutput).toContain("effect-temporal(versioning-on-main-fiber)"); + expect(definitionOutput.match(/effect-temporal\(/g)).toHaveLength(1); }, 60_000); }); diff --git a/src/definition.ts b/src/definition.ts index 8e4c6b8..593556f 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -60,6 +60,10 @@ export interface UpdateRequest { * per primitive kind, dispatching on the declaration instances. The typed * surfaces below narrow this seam exactly once each. * + * Declaration schemas must be context-free: a schema requiring decoding or + * encoding services would have that requirement erased by this seam and + * defect at runtime. + * * @since 0.3.0 * @category models */ @@ -81,7 +85,10 @@ export interface WorkflowOpsRuntime { update: DurableUpdate.DurableUpdate, ) => Effect.Effect>; readonly stateSet: (cell: StateCell.StateCell, value: unknown) => Effect.Effect; - readonly version: (site: string, names: ReadonlyArray) => Effect.Effect; + readonly version: ( + site: string, + names: Names, + ) => Effect.Effect; } /** @@ -141,6 +148,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)); @@ -170,7 +179,7 @@ export const defineDeferred = ( name, deferred, await: Effect.flatMap(WorkflowOps, (runtime) => - runtime.deferredAwait(deferred as DurableDeferred.DurableDeferred), + runtime.deferredAwait(deferred), ) as Effect.Effect, } as const; }; @@ -263,9 +272,7 @@ export const version = ( site: string, names: Names, ): Effect.Effect => - Effect.flatMap(WorkflowOps, (runtime) => - runtime.version(site, names), - ) as Effect.Effect; + Effect.flatMap(WorkflowOps, (runtime) => runtime.version(site, names)); // ─── Schema evolution ──────────────────────────────────────────────────────── diff --git a/src/engine-sandbox.ts b/src/engine-sandbox.ts index 8756ff5..78b2c4e 100644 --- a/src/engine-sandbox.ts +++ b/src/engine-sandbox.ts @@ -97,7 +97,11 @@ import { } from "./mailbox.js"; import { STATE_CELL_QUERY, stateCellCodec, type StateCell } from "./state-cell.js"; import * as DurableDeferred from "effect/unstable/workflow/DurableDeferred"; -import { WorkflowOps, type WorkflowOpsRuntime } from "./definition.js"; +import { + WorkflowOps, + type UpdateRequest as DefUpdateRequest, + type WorkflowOpsRuntime, +} from "./definition.js"; import * as Versioning from "./versioning.js"; import { updateCodec, @@ -340,10 +344,11 @@ const updateBuffer = (run: RunState, name: string): PendingUpdate[] => { * @since 0.1.0 * @category models */ -export interface UpdateRequest { - readonly payload: P; - readonly respond: (exit: Exit.Exit) => Effect.Effect; -} +export type UpdateRequest = DefUpdateRequest< + P, + S["Type"], + E["Type"] +>; /** * Durably await the next `executeUpdate` request for `update`, in delivery @@ -1069,25 +1074,33 @@ const buildRegistry = ( return registry; }); +/** Forget ONLY the requirements of a sandbox op; success and error survive. */ +const eraseR = (effect: Effect.Effect): Effect.Effect => + effect as Effect.Effect; + /** * The Temporal implementation of the declared-workflow ops seam: every * operation dispatches into this module's machinery. Provided automatically * to the layers `workflowBundle` hosts. * + * Module-private on purpose: outside `workflowBundle`'s per-run wrapper the + * erased sandbox services are missing and every op would die at call time. + * * @since 0.3.0 * @category workflow */ -export const temporalWorkflowOps: WorkflowOpsRuntime = { - // SAFETY: each op requires SandboxRun (and the engine) at the type level; - // the per-run wrapper provides them — same discipline as SandboxHandler. - activity: (activity, payload) => callActivity(activity, payload as never) as never, - deferredAwait: (deferred) => DurableDeferred.await(deferred) as never, - mailboxTake: (mailbox) => takeMailbox(mailbox) as never, - mailboxPoll: (mailbox) => pollMailbox(mailbox) as never, - updateTake: (update) => takeUpdate(update) as never, - stateSet: (cell, value) => setStateCell(cell, value) as never, - version: (site, names) => - Versioning.version(site, names as unknown as readonly [string, ...string[]]), +const temporalWorkflowOps: WorkflowOpsRuntime = { + // SAFETY: eraseR removes ONLY the R channel — each op requires SandboxRun + // (and the engine services) at the type level, and the per-run wrapper + // provides them, same discipline as SandboxHandler. Success and error + // shapes stay compile-checked against the seam. + activity: (activity, payload) => eraseR(callActivity(activity, payload)), + deferredAwait: (deferred) => eraseR(DurableDeferred.await(deferred)), + mailboxTake: (mailbox) => eraseR(takeMailbox(mailbox)), + mailboxPoll: (mailbox) => eraseR(pollMailbox(mailbox)), + updateTake: (update) => eraseR(takeUpdate(update)), + stateSet: (cell, value) => eraseR(setStateCell(cell, value)), + version: (site, names) => Versioning.version(site, names), }; /** diff --git a/src/lint.js b/src/lint.js index effde93..66a1267 100644 --- a/src/lint.js +++ b/src/lint.js @@ -176,7 +176,25 @@ const rules = { ...sandboxRule((_context, state) => { const FORKING = ["forkChild", "forkDetach", "raceFirst", "race", "raceAll", "all"]; let forkDepth = 0; + let definedVersionLocal = null; 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). + ImportDeclaration(node) { + const source = node.source.value; + if ( + typeof source === "string" && + (source.endsWith("/definition") || source.endsWith("/definition.js")) + ) { + for (const specifier of node.specifiers ?? []) { + if (specifier.type === "ImportSpecifier" && specifier.imported?.name === "version") { + definedVersionLocal = specifier.local.name; + state.sandbox = true; + } + } + } + }, CallExpression(node) { if (isEffectCall(node, FORKING)) { forkDepth++; @@ -188,7 +206,8 @@ const rules = { node.callee.object.name === "Versioning"; // The definition module's bare `version(site, names)` call. const isDefinedVersion = - node.callee.type === "Identifier" && node.callee.name === "version"; + node.callee.type === "Identifier" && + node.callee.name === (definedVersionLocal ?? "version"); if (forkDepth > 0 && (isVersioningMember || isDefinedVersion)) { state.reports.push({ node, messageId: "fiber" }); } diff --git a/src/testing.ts b/src/testing.ts index d091d93..6dc351d 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -28,7 +28,7 @@ import { WorkflowExecutionAlreadyStartedError, type Client } from "@temporalio/c import type { PayloadOf, SuccessOf } from "./client.js"; import { WorkflowOps, type UpdateRequest, type WorkflowOpsRuntime } from "./definition.js"; import { codecsFor } from "./typed-activity.js"; -import { wireCodecsFor } from "./wire.js"; +import { wireCodecsFor, wireValueCodec } from "./wire.js"; /** * One recorded `workflow.start` call, as the fake captured it. @@ -462,11 +462,14 @@ export interface TestWorkflowOps { * yield* world.resolve(Approval, "ben"); * ``` * - * 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. + * Every declared channel round-trips its values through the declaration's + * own schema, exactly as the wire would: activity payloads, successes, and + * typed failures; mailbox and update payloads; update responses; state + * values; deferred completions. A value that fails its schema is a defect + * here AND on Temporal — the memory test catches what production would. + * A call to an activity with no binding dies loudly, responding twice to + * one update dies (as the engine does), and `version` always answers the + * newest name — there is no replay in memory. * * @since 0.3.0 * @category constructors @@ -503,6 +506,13 @@ export const makeTestWorkflowOps = (options?: { }); }); + // Encode-then-decode through the declaration's own schema — the same + // trip the real wire takes, so schema-invalid values defect here too. + const roundTrip = (schema: Schema.Top, value: unknown): unknown => { + const codec = wireValueCodec(schema); + return codec.decode(codec.encode(value)); + }; + const runtime: WorkflowOpsRuntime = { activity: (activity, payload) => Effect.suspend(() => { @@ -516,14 +526,46 @@ export const makeTestWorkflowOps = (options?: { const validated = codecs.payload.decode(codecs.payload.encode(payload)); return Effect.flatten( Effect.promise(() => binding.execute(validated as never, runner)), + ).pipe( + Effect.map((value) => codecs.success.decode(codecs.success.encode(value))), + Effect.mapError((error) => codecs.error.decode(codecs.error.encode(error))), ); }), - deferredAwait: (deferred) => Deferred.await(deferredFor(deferred)), - mailboxTake: (mailbox) => Effect.flatMap(queueFor(mailboxes, mailbox), Queue.take), - mailboxPoll: (mailbox) => Effect.flatMap(queueFor(mailboxes, mailbox), Queue.poll), - updateTake: (update) => Effect.flatMap(queueFor(updates, update), Queue.take), - stateSet: (cell, value) => Effect.sync(() => void cells.set(cell, value)), - version: (_site, names) => Effect.succeed(names[names.length - 1]!), + deferredAwait: (deferred) => + Effect.map(Deferred.await(deferredFor(deferred)), (value) => + // SAFETY: upstream bounds successSchema as Schema.Constraint, but + // every constructible deferred carries a real (Top) schema. + roundTrip(deferred.successSchema as unknown as Schema.Top, value), + ), + mailboxTake: (mailbox) => + Effect.map(Effect.flatMap(queueFor(mailboxes, mailbox), Queue.take), (value) => + roundTrip(mailbox.payloadSchema, value), + ), + mailboxPoll: (mailbox) => + Effect.map(Effect.flatMap(queueFor(mailboxes, mailbox), Queue.poll), (value) => + Option.map(value, (payload) => roundTrip(mailbox.payloadSchema, payload)), + ), + updateTake: (update) => + Effect.map(Effect.flatMap(queueFor(updates, update), Queue.take), (request) => { + let responded = false; + return { + payload: roundTrip(update.payloadSchema, request.payload), + respond: (exit: Exit.Exit) => + Effect.suspend(() => { + if (responded) { + return Effect.die(new Error(`respond called twice for update "${update.name}"`)); + } + responded = true; + const wired = Exit.isSuccess(exit) + ? Exit.succeed(roundTrip(update.successSchema, exit.value)) + : Exit.mapError(exit, (error) => roundTrip(update.errorSchema, error)); + return request.respond(wired); + }), + }; + }), + stateSet: (cell, value) => + Effect.sync(() => void cells.set(cell, roundTrip(cell.valueSchema, value))), + version: (_site, names) => Effect.succeed(names[names.length - 1] ?? names[0]), }; const world: TestWorkflowOps = {