Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-define-workflow.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,13 @@ export type {
RetryPolicy,
} from './retry/index';

export { defineWorkflow } from './workflows/index';

export type {
DefineWorkflowOptions,
RunOptions,
WorkflowContract,
WorkflowParams,
} from './workflows/index';

/*****************************************************************************************************************/
107 changes: 107 additions & 0 deletions src/workflows/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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<Params, Env = Cloudflare.Env> {
// 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<Params>;
// 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<NoInfer<Params>>;
}

/*****************************************************************************************************************/

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<Params, Env = Cloudflare.Env> {
// 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<string>;
}

/*****************************************************************************************************************/

// 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> = [Contract] extends [
WorkflowContract<infer Params, infer Env>,
]
? [WorkflowContract<Params, Env>] 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 = <Params, Env = Cloudflare.Env>(
// 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<Params, Env>,
): WorkflowContract<Params, Env> => {
const { name, schema, workflow } = options;

const parse = async (params: Params): Promise<Params> => {
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));
},
};
};

/*****************************************************************************************************************/
47 changes: 47 additions & 0 deletions src/workflows/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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;
};

/*****************************************************************************************************************/
18 changes: 18 additions & 0 deletions src/workflows/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @package @observerly/orderly/workflows
// @license Copyright © 2026 observerly

/*****************************************************************************************************************/

export { defineWorkflow } from './contract';

export type {
DefineWorkflowOptions,
RunOptions,
WorkflowContract,
WorkflowParams,
} from './contract';

/*****************************************************************************************************************/
55 changes: 55 additions & 0 deletions src/workflows/start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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 <Params>(
// 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<Params>,
id: string,
): Promise<boolean> => {
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 <Params>(
// 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<Params>,
id: string,
params: Params,
): Promise<string> => {
try {
await binding.create({ id, params });
} catch (error) {
if (await exists(binding, id)) {
return id;
}

throw error;
}

return id;
};

/*****************************************************************************************************************/
113 changes: 113 additions & 0 deletions tests/fixtures/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @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<unknown, Closedown> = {
'~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<unknown, Closedown> = {
'~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 = <Params = Closedown>(options: StubbedOptions = {}) => {
const { refusal, existing = 'missing' } = options;

const created: WorkflowInstanceCreateOptions<Params>[] = [];

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<Params>) => {
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<Params>;

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;

/*****************************************************************************************************************/
Loading
Loading