diff --git a/.changeset/add-define-workflow.md b/.changeset/add-define-workflow.md new file mode 100644 index 0000000..d2927b2 --- /dev/null +++ b/.changeset/add-define-workflow.md @@ -0,0 +1,5 @@ +--- +'@observerly/orderly': minor +--- + +Add defineWorkflow(): declare a Cloudflare Workflow's params and schema once, then start it with `run(env, params, { id })`, which validates the params, starts one instance by the id given or a fresh UUID, and resolves with that id. A run by an id that has already started starts nothing. Add the WorkflowParams type to type the Workflow's own WorkflowEntrypoint from the contract. diff --git a/src/index.ts b/src/index.ts index b27e4cc..b2c2fe5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,4 +98,13 @@ export type { RetryPolicy, } from './retry/index'; +export { defineWorkflow } from './workflows/index'; + +export type { + DefineWorkflowOptions, + RunOptions, + WorkflowContract, + WorkflowParams, +} from './workflows/index'; + /*****************************************************************************************************************/ diff --git a/src/workflows/contract.ts b/src/workflows/contract.ts new file mode 100644 index 0000000..3db4b40 --- /dev/null +++ b/src/workflows/contract.ts @@ -0,0 +1,107 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly/workflows +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import type { BodySchema } from '../consumer/index'; + +import { parseBody } from '../consumer/validation'; + +import { instanceIdOf } from './identity'; + +import { startInstance } from './start'; + +/*****************************************************************************************************************/ + +export interface DefineWorkflowOptions { + // The Workflow's name as declared in the wrangler configuration. Carried as metadata on the contract; nothing + // is enforced against it at runtime. + name: string; + // Validates params before a run is started, and the parsed value is what the Workflow receives. The same + // schema the consumer takes, so any Standard Schema or a plain parse function plugs in. + schema?: BodySchema; + // Selects the Workflow's binding from the environment a run is given. NoInfer keeps the binding from deciding + // the params: a binding written by `wrangler types` has unknown params, and would otherwise widen them to + // unknown, so the schema or the type argument decides them alone. + workflow: (env: Env) => Workflow>; +} + +/*****************************************************************************************************************/ + +export interface RunOptions { + // The instance's id. A run by an id that has already started starts nothing, so an id derived from what the + // run is for makes it happen once. Absent means a fresh UUID. + readonly id?: string; +} + +/*****************************************************************************************************************/ + +export interface WorkflowContract { + // The name the contract was defined with. + readonly name: string; + // Starts the Workflow now, and resolves with the id of the instance it started. Every refusal rejects. + run: (env: Env, params: Params, options?: RunOptions) => Promise; +} + +/*****************************************************************************************************************/ + +// The params a contract carries, taken from the contract itself, so the shape is named once in the schema and +// read from the contract everywhere else, the Workflow's entrypoint first among them. Anything that is not a +// contract yields never, and the tuples keep the conditional from distributing, so a contract joined with +// undefined yields never too. The second check is what refuses a union of contracts: params are read from a +// parameter, so inferring them from two contracts yields the intersection of both, which no single contract +// carries, and one contract, one set of params, is the rule. +export type WorkflowParams = [Contract] extends [ + WorkflowContract, +] + ? [WorkflowContract] extends [Contract] + ? Params + : never + : never; + +/*****************************************************************************************************************/ + +// The shared contract: a Workflow's params and their schema declared once, and the run started from it. The +// Workflow class itself stays the platform's own WorkflowEntrypoint, typed with WorkflowParams. +export const defineWorkflow = ( + // The options carry a schema and a selector, which are read and never written; readonly all the way down would + // ask the same of the schema's own Standard Schema properties, which are the schema library's concern. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + options: DefineWorkflowOptions, +): WorkflowContract => { + const { name, schema, workflow } = options; + + const parse = async (params: Params): Promise => { + if (schema === undefined) { + return params; + } + + const attempt = await parseBody(schema, params); + + if (attempt.parsed) { + return attempt.value; + } + + throw new Error('the params failed validation and no run was started', { + cause: attempt.error, + }); + }; + + return { + name, + run: async (env, params, runOptions) => { + // The id and the binding are settled before anything is awaited, so a bad id or a selector that throws is + // refused before the schema is ever consulted. + const id = instanceIdOf(runOptions?.id); + + const binding = workflow(env); + + return startInstance(binding, id, await parse(params)); + }, + }; +}; + +/*****************************************************************************************************************/ diff --git a/src/workflows/identity.ts b/src/workflows/identity.ts new file mode 100644 index 0000000..7600c8c --- /dev/null +++ b/src/workflows/identity.ts @@ -0,0 +1,47 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly/workflows +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +// The longest id the platform gives an instance. +export const MAXIMUM_INSTANCE_ID_LENGTH = 100; + +/*****************************************************************************************************************/ + +// The characters the platform allows in an instance id: letters, digits, underscores and hyphens, never a hyphen +// first. A colon is refused, and a UUID is accepted. +const INSTANCE_ID = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/u; + +/*****************************************************************************************************************/ + +// Whether a value is an id the platform would accept for an instance. Checked here rather than left to the +// platform, so a bad id is refused before anything is parsed, sent or created, and refused the same way on the +// path that starts a run now and on the path that holds one in the queue. +export const isInstanceId = (value: unknown): value is string => + typeof value === 'string' && + value.length <= MAXIMUM_INSTANCE_ID_LENGTH && + INSTANCE_ID.test(value); + +/*****************************************************************************************************************/ + +// The id a run is started by: the one given, refused with the rule it broke when it breaks one, or a fresh UUID +// when none was given. +export const instanceIdOf = (id: string | undefined): string => { + if (id === undefined) { + return crypto.randomUUID(); + } + + if (!isInstanceId(id)) { + throw new RangeError( + `a Workflow instance id must match ${INSTANCE_ID.source} and be at most ` + + `${MAXIMUM_INSTANCE_ID_LENGTH} characters long`, + ); + } + + return id; +}; + +/*****************************************************************************************************************/ diff --git a/src/workflows/index.ts b/src/workflows/index.ts new file mode 100644 index 0000000..8a6c7e8 --- /dev/null +++ b/src/workflows/index.ts @@ -0,0 +1,18 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly/workflows +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +export { defineWorkflow } from './contract'; + +export type { + DefineWorkflowOptions, + RunOptions, + WorkflowContract, + WorkflowParams, +} from './contract'; + +/*****************************************************************************************************************/ diff --git a/src/workflows/start.ts b/src/workflows/start.ts new file mode 100644 index 0000000..116d34e --- /dev/null +++ b/src/workflows/start.ts @@ -0,0 +1,55 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly/workflows +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +// Whether an instance already stands behind an id. Anything the platform cannot answer reads as no instance, so +// the caller falls back to the error it already has rather than to a new one from here. +const exists = async ( + // The platform's binding, read and never written: its methods are what make it a mutable type. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + binding: Workflow, + id: string, +): Promise => { + try { + const instance = await binding.get(id); + + const { status } = await instance.status(); + + return status !== 'unknown'; + } catch { + return false; + } +}; + +/*****************************************************************************************************************/ + +// Starts one instance by its id, and never a second. A create the platform refuses is followed by asking for the +// instance by that id: one already there means the run has already started, so the id is resolved just as though +// this call had started it. Without one, the platform's own error is rethrown unchanged. The error is never +// matched on its text, which the platform does not promise, and the local engine does not refuse a repeated id +// at all, handing back the instance already there. +export const startInstance = async ( + // The platform's binding, read and never written: its methods are what make it a mutable type. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + binding: Workflow, + id: string, + params: Params, +): Promise => { + try { + await binding.create({ id, params }); + } catch (error) { + if (await exists(binding, id)) { + return id; + } + + throw error; + } + + return id; +}; + +/*****************************************************************************************************************/ diff --git a/tests/fixtures/workflow.ts b/tests/fixtures/workflow.ts new file mode 100644 index 0000000..d989953 --- /dev/null +++ b/tests/fixtures/workflow.ts @@ -0,0 +1,113 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import type { StandardSchema } from '../../src/index'; + +/*****************************************************************************************************************/ + +export type Closedown = { observatory: string }; + +/*****************************************************************************************************************/ + +// A conforming schema that trims and lowercases the observatory, standing in for zod and friends, so a test can +// tell the parsed params from the params a run was given. An observatory that trims to nothing is refused. +export const closedown: StandardSchema = { + '~standard': { + version: 1, + vendor: 'orderly-tests', + validate: value => + typeof value === 'object' && + value !== null && + 'observatory' in value && + typeof value.observatory === 'string' && + value.observatory.trim() !== '' + ? { value: { observatory: value.observatory.trim().toLowerCase() } } + : { issues: [{ message: 'observatory must name an observatory' }] }, + }, +}; + +/*****************************************************************************************************************/ + +// The closedown schema, counting every time it is consulted, so a test can say a refusal came before it. +export const watched = () => { + const consulted: unknown[] = []; + + const schema: StandardSchema = { + '~standard': { + ...closedown['~standard'], + validate: value => { + consulted.push(value); + + return closedown['~standard'].validate(value); + }, + }, + }; + + return { schema, consulted }; +}; + +/*****************************************************************************************************************/ + +// What an instance behind an id answers when asked for its status: a status it reads, a status() that throws, +// or no instance behind the id at all. +export type Existing = InstanceStatus['status'] | 'status throws' | 'missing'; + +/*****************************************************************************************************************/ + +export interface StubbedOptions { + // The error create() rejects with. Absent means create() resolves. + refusal?: Error; + // What stands behind the id once create() has refused. Absent means no instance at all. + existing?: Existing; +} + +/*****************************************************************************************************************/ + +// A binding that records what it was created with, and refuses the way it is told to, because what reaches the +// Workflow, and what a refused create does next, is not observable through the real binding. +// The options are read and never written; the refusal is an Error, whose own fields are what make them mutable. +// oxlint-disable-next-line typescript/prefer-readonly-parameter-types +export const stubbed = (options: StubbedOptions = {}) => { + const { refusal, existing = 'missing' } = options; + + const created: WorkflowInstanceCreateOptions[] = []; + + const instance = { + id: 'stubbed', + status: () => + existing === 'status throws' + ? Promise.reject(new Error('the status could not be read')) + : Promise.resolve({ status: existing }), + }; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + const binding = { + // Recorded as given, so the options a create was handed are exactly what a test reads back. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + create: (createOptions: WorkflowInstanceCreateOptions) => { + created.push(createOptions); + + return refusal === undefined ? Promise.resolve(instance) : Promise.reject(refusal); + }, + get: () => + existing === 'missing' + ? Promise.reject(new Error('instance.not_found')) + : Promise.resolve(instance), + } as unknown as Workflow; + + return { binding, created }; +}; + +/*****************************************************************************************************************/ + +// The real binding, selected from the environment a run is given. +// The environment is the platform's own type, read and never written: its bindings are what make it mutable. +// oxlint-disable-next-line typescript/prefer-readonly-parameter-types +export const echoing = (bindings: Cloudflare.Env): Workflow => bindings.ECHO; + +/*****************************************************************************************************************/ diff --git a/tests/workflow-binding.spec.ts b/tests/workflow-binding.spec.ts index b730d3c..7c47855 100644 --- a/tests/workflow-binding.spec.ts +++ b/tests/workflow-binding.spec.ts @@ -10,8 +10,12 @@ import { env } from 'cloudflare:workers'; import { describe, expect, expectTypeOf, it } from 'vitest'; +import type { DefineWorkflowOptions } from '../src/index'; + import { settled } from './fixtures/settled'; +import type { Closedown } from './fixtures/workflow'; + /*****************************************************************************************************************/ // What these specs pin is the real binding, as the local engine runs it, rather than anything orderly does: the @@ -139,7 +143,8 @@ describe('the Workflow binding creating by an id that exists', () => { /*****************************************************************************************************************/ -type Closedown = { observatory: string }; +// The selector defineWorkflow() takes for a Workflow whose params are a Closedown. +type Selector = DefineWorkflowOptions['workflow']; /*****************************************************************************************************************/ @@ -147,19 +152,19 @@ type Closedown = { observatory: string }; // `wrangler types` writes them, or wider than a schema's, and refused only when they are unrelated to it. describe('the Workflow binding as a type', () => { it('accepts a binding with unknown params where a typed one is wanted', () => { - expectTypeOf(env.ECHO).toExtend>(); + expectTypeOf<() => Cloudflare.Env['ECHO']>().toExtend(); }); it('accepts a binding whose params are wider', () => { - expectTypeOf>().toExtend>(); + expectTypeOf<() => Workflow>().toExtend(); - expectTypeOf>().toExtend>(); + expectTypeOf<() => Workflow>().toExtend(); }); it('refuses a binding whose params are unrelated', () => { - expectTypeOf>().not.toExtend>(); + expectTypeOf<() => Workflow<{ telescope: number }>>().not.toExtend(); - expectTypeOf>().not.toExtend>(); + expectTypeOf<() => Workflow>().not.toExtend(); }); }); diff --git a/tests/workflow-contract.spec.ts b/tests/workflow-contract.spec.ts new file mode 100644 index 0000000..359d652 --- /dev/null +++ b/tests/workflow-contract.spec.ts @@ -0,0 +1,187 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { env } from 'cloudflare:workers'; + +import { describe, expect, it } from 'vitest'; + +import { defineWorkflow } from '../src/index'; + +import { settled } from './fixtures/settled'; + +import { type Closedown, closedown, echoing, stubbed, watched } from './fixtures/workflow'; + +/*****************************************************************************************************************/ + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +/*****************************************************************************************************************/ + +// The real binding, with no schema, so what the Workflow saw is exactly what the run was given. +const echo = defineWorkflow({ name: 'echo', workflow: echoing }); + +/*****************************************************************************************************************/ + +describe('defineWorkflow', () => { + it('carries the name it was defined with', () => { + expect(echo.name).toBe('echo'); + }); + + it('mints a UUID for a run given no id, naming the instance it started', async () => { + const id = await echo.run(env, { observatory: 'greenwich' }); + + expect(id).toMatch(UUID); + + await expect(settled(await env.ECHO.get(id))).resolves.toMatchObject({ status: 'complete' }); + }); + + it.each([ + ['an id', 'greenwich-1768467600000'], + ['an id of exactly 100 characters', 'x'.repeat(100)], + ])('starts the instance by %s given', async (_, id) => { + await expect(echo.run(env, {}, { id })).resolves.toBe(id); + + await expect(settled(await env.ECHO.get(id))).resolves.toMatchObject({ status: 'complete' }); + }); + + it('carries a Date into the Workflow as a Date', async () => { + const id = await echo.run(env, { at: new Date() }); + + await expect(settled(await env.ECHO.get(id))).resolves.toMatchObject({ + output: { seen: { at: 'Date' } }, + }); + }); +}); + +/*****************************************************************************************************************/ + +describe('defineWorkflow hands the Workflow its params', () => { + it('creates the instance with the parsed params, by the id it resolves with', async () => { + const { binding, created } = stubbed(); + + const contract = defineWorkflow({ + name: 'closedown', + schema: closedown, + workflow: () => binding, + }); + + const id = await contract.run(env, { observatory: ' Greenwich ' }); + + expect(created).toStrictEqual([{ id, params: { observatory: 'greenwich' } }]); + }); + + it('passes the params through as given when there is no schema', async () => { + const { binding, created } = stubbed(); + + const contract = defineWorkflow({ name: 'closedown', workflow: () => binding }); + + await contract.run(env, { observatory: ' Greenwich ' }, { id: 'given' }); + + expect(created).toStrictEqual([{ id: 'given', params: { observatory: ' Greenwich ' } }]); + }); + + it('hands the selector the environment the run was given', async () => { + const { binding } = stubbed(); + + const selected: unknown[] = []; + + const contract = defineWorkflow({ + name: 'closedown', + // The environment is read and never written: its bindings are what make it a mutable type. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + workflow: bindings => { + selected.push(bindings); + + return binding; + }, + }); + + await contract.run(env, { observatory: 'greenwich' }); + + expect(selected).toHaveLength(1); + + expect(selected[0]).toBe(env); + }); +}); + +/*****************************************************************************************************************/ + +describe('defineWorkflow refuses an id before anything else', () => { + it.each([ + ['the empty string', ''], + ['a leading hyphen', '-greenwich'], + ['a colon', 'greenwich:sunrise'], + ['101 characters', 'x'.repeat(101)], + ])('refuses %s before the schema is consulted or anything is created', async (_, id) => { + const { binding, created } = stubbed(); + + const { schema, consulted } = watched(); + + const contract = defineWorkflow({ name: 'closedown', schema, workflow: () => binding }); + + await expect(contract.run(env, { observatory: 'greenwich' }, { id })).rejects.toThrow( + RangeError, + ); + + expect(consulted).toStrictEqual([]); + + expect(created).toStrictEqual([]); + }); + + it('names the rule an id broke', async () => { + await expect(echo.run(env, {}, { id: 'greenwich:sunrise' })).rejects.toThrow( + 'a Workflow instance id must match ^[A-Za-z0-9_][A-Za-z0-9_-]*$ and be at most 100 characters long', + ); + }); +}); + +/*****************************************************************************************************************/ + +describe('defineWorkflow refuses before starting a run', () => { + it('creates nothing for params that fail the schema, with the issues as the cause', async () => { + const { binding, created } = stubbed(); + + const contract = defineWorkflow({ + name: 'closedown', + schema: closedown, + workflow: () => binding, + }); + + const error: unknown = await contract + .run(env, { observatory: ' ' }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(Error); + + expect(error).toHaveProperty('message', 'the params failed validation and no run was started'); + + expect(error).toHaveProperty('cause', [{ message: 'observatory must name an observatory' }]); + + expect(created).toStrictEqual([]); + }); + + it('refuses a selector that throws before the schema is consulted', async () => { + const { schema, consulted } = watched(); + + const contract = defineWorkflow({ + name: 'closedown', + schema, + workflow: () => { + throw new Error('no such binding'); + }, + }); + + await expect(contract.run(env, { observatory: 'greenwich' })).rejects.toThrow( + 'no such binding', + ); + + expect(consulted).toStrictEqual([]); + }); +}); + +/*****************************************************************************************************************/ diff --git a/tests/workflow-once.spec.ts b/tests/workflow-once.spec.ts new file mode 100644 index 0000000..0198cbb --- /dev/null +++ b/tests/workflow-once.spec.ts @@ -0,0 +1,75 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { env } from 'cloudflare:workers'; + +import { describe, expect, it } from 'vitest'; + +import { defineWorkflow } from '../src/index'; + +import { settled } from './fixtures/settled'; + +import { echoing, type Existing, stubbed } from './fixtures/workflow'; + +/*****************************************************************************************************************/ + +const echo = defineWorkflow({ name: 'echo', workflow: echoing }); + +/*****************************************************************************************************************/ + +// What production does with a create by an id that already exists: the platform's types say it throws. +const duplicate = new Error('instance.already_exists'); + +/*****************************************************************************************************************/ + +describe('defineWorkflow runs by an id once', () => { + it('starts nothing for a run by an id already started, and keeps the first params', async () => { + await expect(echo.run(env, { first: 1 }, { id: 'once' })).resolves.toBe('once'); + + await expect(echo.run(env, { second: 'two' }, { id: 'once' })).resolves.toBe('once'); + + await expect(settled(await env.ECHO.get('once'))).resolves.toMatchObject({ + status: 'complete', + output: { seen: { first: 'number' } }, + }); + }); +}); + +/*****************************************************************************************************************/ + +describe('defineWorkflow after a refused create', () => { + it.each<[string, Existing]>([ + ['running', 'running'], + ['complete', 'complete'], + ['errored', 'errored'], + ])('resolves with the id when an instance %s stands behind it', async (_, existing) => { + const { binding } = stubbed({ refusal: duplicate, existing }); + + const contract = defineWorkflow({ name: 'closedown', workflow: () => binding }); + + await expect(contract.run(env, { observatory: 'greenwich' }, { id: 'taken' })).resolves.toBe( + 'taken', + ); + }); + + it.each<[string, Existing]>([ + ['no instance stands behind the id', 'missing'], + ['the instance reads unknown', 'unknown'], + ['the status cannot be read', 'status throws'], + ])('rejects with the original error when %s', async (_, existing) => { + const refusal = new Error('the platform refused'); + + const { binding } = stubbed({ refusal, existing }); + + const contract = defineWorkflow({ name: 'closedown', workflow: () => binding }); + + await expect(contract.run(env, { observatory: 'greenwich' })).rejects.toBe(refusal); + }); +}); + +/*****************************************************************************************************************/ diff --git a/tests/workflow-params.spec.ts b/tests/workflow-params.spec.ts new file mode 100644 index 0000000..fd3d968 --- /dev/null +++ b/tests/workflow-params.spec.ts @@ -0,0 +1,146 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import type { WorkflowEvent } from 'cloudflare:workers'; + +import { describe, expectTypeOf, it } from 'vitest'; + +import { + defineQueue, + defineWorkflow, + type WorkflowContract, + type WorkflowParams, +} from '../src/index'; + +import { type Closedown, closedown, echoing } from './fixtures/workflow'; + +/*****************************************************************************************************************/ + +// A project's own environment, as `wrangler types` would write it, with a binding of unknown params. +interface ProjectEnv { + CLOSEDOWN: Workflow; +} + +/*****************************************************************************************************************/ + +// A selector annotated with the project's own environment. +// The environment is read and never written: its bindings are what make it a mutable type. +// oxlint-disable-next-line typescript/prefer-readonly-parameter-types +const project = (env: ProjectEnv): Workflow => env.CLOSEDOWN; + +/*****************************************************************************************************************/ + +const closedowns = defineWorkflow({ + name: 'closedown', + schema: closedown, + // Left unannotated, so the environment is inferred. It is read and never written: its bindings are what make + // it a mutable type. + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + workflow: env => env.ECHO, +}); + +/*****************************************************************************************************************/ + +describe('WorkflowParams', () => { + it('reads the params from a contract defined with a schema, with no type argument given', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('reads the params from a contract defined with a parse function', () => { + const contract = defineWorkflow({ + name: 'closedown', + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + schema: (value: unknown) => value as Closedown, + workflow: echoing, + }); + + expectTypeOf>().toEqualTypeOf(); + }); + + it('reads the params from a contract given its type argument outright', () => { + const contract = defineWorkflow({ name: 'closedown', workflow: echoing }); + + expectTypeOf>().toEqualTypeOf(); + }); + + it('is unknown for a contract that names no params at all', () => { + const contract = defineWorkflow({ name: 'anything', workflow: echoing }); + + expectTypeOf>().toEqualTypeOf(); + }); + + it("reads the params from a contract over a project's own environment", () => { + const contract = defineWorkflow({ + name: 'closedown', + schema: closedown, + workflow: project, + }); + + expectTypeOf>().toEqualTypeOf(); + }); +}); + +/*****************************************************************************************************************/ + +describe('WorkflowParams agrees with both ends', () => { + it('agrees with what run() takes', () => { + expectTypeOf(closedowns.run).parameter(1).toEqualTypeOf>(); + }); + + it("agrees with the payload the Workflow's entrypoint receives", () => { + expectTypeOf>['payload']>().toEqualTypeOf< + Readonly + >(); + }); +}); + +/*****************************************************************************************************************/ + +describe('WorkflowParams refuses what is not one contract', () => { + it('is never for something that is not a contract', () => { + expectTypeOf>().toBeNever(); + + const queue = defineQueue({ name: 'closedowns', schema: closedown }); + + expectTypeOf>().toBeNever(); + }); + + it('is never for a contract joined with undefined', () => { + expectTypeOf>().toBeNever(); + }); + + it('is never for a union of two contracts', () => { + type Either = WorkflowContract | WorkflowContract<{ telescope: number }>; + + expectTypeOf>().toBeNever(); + }); +}); + +/*****************************************************************************************************************/ + +describe('defineWorkflow infers the environment', () => { + it('reads an unannotated selector as taking Cloudflare.Env', () => { + expectTypeOf(closedowns).toEqualTypeOf>(); + + expectTypeOf(closedowns.run).parameter(0).toEqualTypeOf(); + }); + + it("reads an annotated selector as taking the project's own environment", () => { + const contract = defineWorkflow({ + name: 'closedown', + schema: closedown, + workflow: project, + }); + + expectTypeOf(contract).toEqualTypeOf>(); + + expectTypeOf(contract.run).parameter(0).toEqualTypeOf(); + }); +}); + +/*****************************************************************************************************************/