export type Step = { run(id: string, handler: () => T | Promise): Promise } export type MethodStepScope = { kind: 'method' ref: string owner: string method: string } export type FunctionStepScope = { kind: 'function' ref: string slug: string } export type StepScope = MethodStepScope | FunctionStepScope export type StepRun = { /** The author-provided id, normalized to the local id inside this scope. */ id: string /** Backend-facing durable key, qualified by the method/function scope. */ key: string /** Zero-based occurrence for repeated ids in one execution. */ occurrence: number scope?: StepScope } export function requireStepId(id: string): string { if (typeof id !== 'string' || id.trim() === '') { throw new TypeError('step.run: id must be a non-empty string') } return id } export function buildStepRun( id: string, scope: StepScope | undefined, occurrence: number, ): StepRun { const stepId = requireStepId(localStepId(id, scope)) const key = scope ? `${canonicalScope(scope)}.${stepId}` : stepId return { id: stepId, key, occurrence, ...(scope ? { scope } : {}), } } export function roundTripStepResult(id: string, value: T): T { if (value === undefined) return value let serialized: string | undefined try { serialized = JSON.stringify(value) } catch (err) { throw new TypeError( `step.run("${id}"): result must be JSON-serializable: ${ err instanceof Error ? err.message : String(err) }`, ) } if (serialized === undefined) { throw new TypeError(`step.run("${id}"): result must be JSON-serializable`) } return JSON.parse(serialized) as T } function localStepId(id: string, scope: StepScope | undefined): string { if (!scope) return id for (const prefix of scopePrefixes(scope)) { const scoped = `${prefix}.` if (id.startsWith(scoped) && id.length > scoped.length) return id.slice(scoped.length) } return id } function scopePrefixes(scope: StepScope): string[] { switch (scope.kind) { case 'method': return [scope.ref, `${scope.owner}.${scope.method}`] case 'function': return [scope.ref, `function.${scope.slug}`] } } function canonicalScope(scope: StepScope): string { return scope.ref }