yieldstar 0.4.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -64
- package/dist/exports/errors.d.ts.map +1 -1
- package/dist/exports/sdk-http.d.ts +8 -0
- package/dist/exports/sdk-http.d.ts.map +1 -1
- package/dist/exports/sdk-local.d.ts +19 -2
- package/dist/exports/sdk-local.d.ts.map +1 -1
- package/dist/exports/workflow.d.ts +14 -2
- package/dist/exports/workflow.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +791 -137
- package/dist/internal/effect-call.d.ts +60 -0
- package/dist/internal/effect-call.d.ts.map +1 -0
- package/dist/internal/runtime-call.d.ts +39 -0
- package/dist/internal/runtime-call.d.ts.map +1 -0
- package/dist/internal/serialise.d.ts.map +1 -1
- package/dist/internal/step-record.d.ts +13 -0
- package/dist/internal/step-record.d.ts.map +1 -0
- package/dist/internal/step-runner.d.ts +93 -9
- package/dist/internal/step-runner.d.ts.map +1 -1
- package/dist/internal/utils.d.ts +11 -0
- package/dist/internal/utils.d.ts.map +1 -1
- package/package.json +8 -4
- package/src/exports/errors.ts +4 -0
- package/src/exports/sdk-http.ts +60 -27
- package/src/exports/sdk-local.ts +41 -15
- package/src/exports/workflow.ts +159 -99
- package/src/index.ts +51 -1
- package/src/internal/effect-call.ts +217 -0
- package/src/internal/runtime-call.ts +95 -0
- package/src/internal/step-record.ts +110 -0
- package/src/internal/step-runner.ts +748 -53
- package/src/internal/utils.ts +24 -10
- package/dist/exports/scheduler.d.ts +0 -49
- package/dist/exports/scheduler.d.ts.map +0 -1
- package/dist/exports/simplified-types.d.ts +0 -31
- package/dist/exports/simplified-types.d.ts.map +0 -1
- package/dist/exports/types.d.ts +0 -6
- package/dist/exports/types.d.ts.map +0 -1
- package/dist/internal/types.d.ts +0 -9
- package/dist/internal/types.d.ts.map +0 -1
- package/src/internal/serialise.ts +0 -44
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
import {
|
|
2
|
+
assertSynchronousClaim,
|
|
3
|
+
assertSynchronousUpdater,
|
|
4
|
+
cloneStoreState,
|
|
5
|
+
defineStore,
|
|
6
|
+
type Draft,
|
|
7
|
+
type DurableObject,
|
|
8
|
+
type DurableStoreSchema,
|
|
9
|
+
type IfDurable,
|
|
10
|
+
type ExecutionStartRequest,
|
|
11
|
+
type ExecutionStartResult,
|
|
12
|
+
InfrastructureError,
|
|
13
|
+
type StandardSchemaV1,
|
|
2
14
|
StepResponse,
|
|
3
15
|
StepKey,
|
|
4
16
|
StepError,
|
|
5
17
|
StepResult,
|
|
6
18
|
StepDelay,
|
|
7
19
|
StepCacheCheck,
|
|
20
|
+
StepStoreWait,
|
|
21
|
+
type StoreDefinition,
|
|
22
|
+
type StoreDeleteFromResult,
|
|
23
|
+
type StoreKey,
|
|
24
|
+
type StoreSelector,
|
|
25
|
+
type StoreSnapshot,
|
|
26
|
+
type StoreState,
|
|
27
|
+
type StoreTakeResult,
|
|
28
|
+
type StoreUpdateFromResult,
|
|
29
|
+
type StoreUpdateResult,
|
|
30
|
+
type StoreWaiter,
|
|
31
|
+
trackStoreSelector,
|
|
32
|
+
type Durable,
|
|
33
|
+
type StepSchemaVerdict,
|
|
34
|
+
durableValueCodec,
|
|
35
|
+
StepEffectWait,
|
|
36
|
+
validateEffectRequest,
|
|
37
|
+
WorkflowEffectError,
|
|
38
|
+
WorkflowStartError,
|
|
39
|
+
WorkflowValueError,
|
|
8
40
|
} from "@yieldstar/core";
|
|
9
41
|
import { RetryableError } from "../exports/errors";
|
|
10
42
|
import { getCallSiteHash } from "./utils";
|
|
43
|
+
import {
|
|
44
|
+
StepRuntimeCall,
|
|
45
|
+
workflowCallback,
|
|
46
|
+
type RuntimeCallResult,
|
|
47
|
+
} from "./runtime-call";
|
|
48
|
+
import type { WorkflowEvent } from "@yieldstar/core";
|
|
49
|
+
import {
|
|
50
|
+
StepEffectCall,
|
|
51
|
+
type EffectCallOutcome,
|
|
52
|
+
type EffectLookup,
|
|
53
|
+
} from "./effect-call";
|
|
54
|
+
|
|
55
|
+
export type { EffectLookup };
|
|
11
56
|
|
|
12
57
|
/**
|
|
13
58
|
* @description A library of step generators, each of which:
|
|
@@ -17,93 +62,499 @@ import { getCallSiteHash } from "./utils";
|
|
|
17
62
|
* @throws any error caught when running the user-defined function,
|
|
18
63
|
* once retries have been exhausted, or if there are no retry semantics
|
|
19
64
|
*/
|
|
20
|
-
export
|
|
65
|
+
export { defineStore };
|
|
21
66
|
|
|
22
|
-
export type
|
|
67
|
+
export type WorkflowStore<T> = {
|
|
68
|
+
readonly definition: StoreDefinition<any>;
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly key: StoreKey;
|
|
71
|
+
get(key?: string): AsyncGenerator<StepResponse, StoreSnapshot<T>>;
|
|
72
|
+
select<R>(
|
|
73
|
+
key: string,
|
|
74
|
+
selector: StoreSelector<T, R> & DurableSelector<T, R>
|
|
75
|
+
): AsyncGenerator<StepResponse, R>;
|
|
76
|
+
update(
|
|
77
|
+
key: string,
|
|
78
|
+
updater: (draft: Draft<T>) => void | T
|
|
79
|
+
): AsyncGenerator<StepResponse, StoreUpdateResult<T>>;
|
|
80
|
+
updateFrom(
|
|
81
|
+
key: string,
|
|
82
|
+
snapshot: StoreSnapshot<T>,
|
|
83
|
+
updater: (draft: Draft<T>) => void | T
|
|
84
|
+
): AsyncGenerator<StepResponse, StoreUpdateFromResult<T>>;
|
|
85
|
+
deleteFrom(
|
|
86
|
+
key: string,
|
|
87
|
+
snapshot: StoreSnapshot<T>
|
|
88
|
+
): AsyncGenerator<StepResponse, StoreDeleteFromResult>;
|
|
89
|
+
when<R>(
|
|
90
|
+
selector: StoreSelector<T, R | undefined | null | false> &
|
|
91
|
+
DurableMatchSelector<T, R>
|
|
92
|
+
): AsyncGenerator<StepResponse, NonNullable<R>>;
|
|
93
|
+
when<R>(
|
|
94
|
+
key: string,
|
|
95
|
+
selector: StoreSelector<T, R | undefined | null | false> &
|
|
96
|
+
DurableMatchSelector<T, R>
|
|
97
|
+
): AsyncGenerator<StepResponse, NonNullable<R>>;
|
|
98
|
+
take<R>(
|
|
99
|
+
key: string,
|
|
100
|
+
selector: StoreSelector<T, R | undefined | null | false> &
|
|
101
|
+
DurableMatchSelector<T, R>,
|
|
102
|
+
claim: (draft: Draft<T>, selected: NonNullable<R>) => void
|
|
103
|
+
): AsyncGenerator<StepResponse, NonNullable<R>>;
|
|
104
|
+
};
|
|
23
105
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
): AsyncGenerator<StepResponse, T, StepResult | StepError>;
|
|
106
|
+
/** A store selector whose result, which the step records, is durable. */
|
|
107
|
+
type DurableSelector<T, R> = IfDurable<R, StoreSelector<T, Durable<R>>>;
|
|
27
108
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
109
|
+
/**
|
|
110
|
+
* A `when` or `take` selector whose match is durable. `false` is excluded
|
|
111
|
+
* before the check so that `R` is inferred as it would be without it.
|
|
112
|
+
*/
|
|
113
|
+
type DurableMatchSelector<T, R> = IfDurable<
|
|
114
|
+
Exclude<R, false>,
|
|
115
|
+
StoreSelector<T, Durable<Exclude<R, false>> | undefined | null | false>
|
|
116
|
+
>;
|
|
32
117
|
|
|
33
|
-
|
|
34
|
-
arg1: string | (() => T | Promise<T>),
|
|
35
|
-
arg2?: (() => T | Promise<T>) | string,
|
|
36
|
-
): AsyncGenerator<StepResponse, T, StepResult | StepError> {
|
|
37
|
-
let key: string | null = null;
|
|
38
|
-
let fn: () => T | Promise<T>;
|
|
118
|
+
export type StepRunner = ReturnType<typeof createStepRunner>;
|
|
39
119
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
120
|
+
export function createStepRunner(params: {
|
|
121
|
+
event: WorkflowEvent;
|
|
122
|
+
clock: () => number;
|
|
123
|
+
}) {
|
|
124
|
+
const { event, clock } = params;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Returns a handle to the store `definition.name` + `id`, where `id`
|
|
128
|
+
* defaults to this execution's ID. Obtaining a handle reads, creates and
|
|
129
|
+
* records nothing, so any caller may obtain the same handle again. Each
|
|
130
|
+
* operation on a missing store first creates it from `definition.initial`.
|
|
131
|
+
*/
|
|
132
|
+
function store<Schema extends StandardSchemaV1>(
|
|
133
|
+
definition: StoreDefinition<Schema> & {
|
|
134
|
+
schema: DurableStoreSchema<Schema>;
|
|
135
|
+
},
|
|
136
|
+
params?: { id?: string }
|
|
137
|
+
): WorkflowStore<StoreState<Schema>> {
|
|
138
|
+
return createWorkflowStore({
|
|
139
|
+
definition,
|
|
140
|
+
id: params?.id ?? event.executionId,
|
|
141
|
+
event,
|
|
142
|
+
});
|
|
45
143
|
}
|
|
46
144
|
|
|
47
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Starts another workflow as a durable step and returns its execution ID.
|
|
147
|
+
* The start is identified by this execution's ID and `stepKey`, so every
|
|
148
|
+
* replay of the step names the same target. A rejected start throws a
|
|
149
|
+
* `WorkflowStartError`.
|
|
150
|
+
*/
|
|
151
|
+
function start<Params extends Record<string, any>>(
|
|
152
|
+
stepKey: string,
|
|
153
|
+
target: { workflowId: string; params?: Params & Durable<Params> }
|
|
154
|
+
): AsyncGenerator<
|
|
155
|
+
StepResponse,
|
|
156
|
+
{ executionId: string },
|
|
157
|
+
StepResult | StepError
|
|
158
|
+
> {
|
|
159
|
+
if (stepKey === "") {
|
|
160
|
+
throw new Error("step.start requires a non-empty step key");
|
|
161
|
+
}
|
|
162
|
+
const request: ExecutionStartRequest = {
|
|
163
|
+
requestId: { executionId: event.executionId, stepKey },
|
|
164
|
+
workflowId: target.workflowId,
|
|
165
|
+
};
|
|
166
|
+
// A missing params and an empty object are different requests.
|
|
167
|
+
if (target.params !== undefined) {
|
|
168
|
+
request.params = target.params as DurableObject;
|
|
169
|
+
}
|
|
170
|
+
return startStep(stepKey, request);
|
|
171
|
+
}
|
|
48
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Runs an external operation identified by `descriptor.effectId`, at most
|
|
175
|
+
* once in effect: after an uncertain attempt, `lookup` decides whether the
|
|
176
|
+
* operation already happened before `perform` runs again (RFC 005).
|
|
177
|
+
*/
|
|
178
|
+
function effect<T, Input extends Record<string, any> = DurableObject>(
|
|
179
|
+
stepKey: string,
|
|
180
|
+
descriptor: EffectDescriptor<T, Input> & DurableEffectResult<T>
|
|
181
|
+
): AsyncGenerator<StepResponse, T, StepResult | StepError> {
|
|
182
|
+
if (typeof stepKey !== "string" || stepKey === "") {
|
|
183
|
+
throw new Error("step.effect requires a non-empty step key");
|
|
184
|
+
}
|
|
185
|
+
return effectStep(stepKey, descriptor) as AsyncGenerator<
|
|
186
|
+
StepResponse,
|
|
187
|
+
T,
|
|
188
|
+
StepResult | StepError
|
|
189
|
+
>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { run, delay: createDelay(clock), poll, store, start, effect };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Describes one external operation for `step.effect`. `effectId` names the
|
|
197
|
+
* operation in the external system, so every caller that means it, in any
|
|
198
|
+
* execution or runtime, uses the same ID, and no caller reuses it for another
|
|
199
|
+
* operation. The external system must guarantee that repeated `perform` calls
|
|
200
|
+
* for one ID make one operation with one result, that `not-found` proves no
|
|
201
|
+
* call exists or can still finish, and that `settled` never changes.
|
|
202
|
+
*/
|
|
203
|
+
export type EffectDescriptor<T, Input = DurableObject> = {
|
|
204
|
+
effectId: string;
|
|
205
|
+
/** Names the version of the behaviour of `input`, `lookup` and `perform`. */
|
|
206
|
+
operation: string;
|
|
207
|
+
input: Input & Durable<Input>;
|
|
208
|
+
/** Milliseconds before an uncertain outcome is looked up again. */
|
|
209
|
+
retryIn: number;
|
|
210
|
+
/** Reports what the external system knows about `effectId`. */
|
|
211
|
+
lookup(effectId: string): Promise<EffectLookup<T>>;
|
|
212
|
+
/** Performs the operation under `effectId`. */
|
|
213
|
+
perform(effectId: string): Promise<T>;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Checks the effect's result type without taking part in its inference. A
|
|
218
|
+
* `perform` that resolves with nothing settles the effect with `undefined`.
|
|
219
|
+
*/
|
|
220
|
+
type DurableEffectResult<T> = [T] extends [void]
|
|
221
|
+
? unknown
|
|
222
|
+
: IfDurable<T, { perform(effectId: string): Promise<Durable<T>> }>;
|
|
223
|
+
|
|
224
|
+
async function* effectStep(
|
|
225
|
+
key: string,
|
|
226
|
+
descriptor: EffectDescriptor<unknown, unknown>
|
|
227
|
+
): AsyncGenerator<StepResponse, unknown, any> {
|
|
49
228
|
yield new StepKey(key);
|
|
50
229
|
|
|
51
230
|
const cached = yield new StepCacheCheck();
|
|
52
231
|
|
|
53
|
-
if (cached)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
232
|
+
if (cached) return yield* replay(cached);
|
|
233
|
+
|
|
234
|
+
const { effectId, operation, input, retryIn, lookup, perform } =
|
|
235
|
+
(descriptor ?? {}) as Partial<EffectDescriptor<unknown, unknown>>;
|
|
236
|
+
const invalid = invalidEffectRequest({
|
|
237
|
+
effectId,
|
|
238
|
+
operation,
|
|
239
|
+
input,
|
|
240
|
+
retryIn,
|
|
241
|
+
lookup,
|
|
242
|
+
perform,
|
|
243
|
+
});
|
|
244
|
+
if (invalid) {
|
|
245
|
+
const err = new WorkflowEffectError(
|
|
246
|
+
"INVALID_EFFECT_REQUEST",
|
|
247
|
+
`step.effect "${key}": ${invalid}`
|
|
248
|
+
);
|
|
249
|
+
yield new StepError(err);
|
|
250
|
+
// unreachable – consumer calls throw() on the generator first
|
|
251
|
+
throw err;
|
|
60
252
|
}
|
|
61
253
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
254
|
+
const outcome: EffectCallOutcome = yield new StepEffectCall({
|
|
255
|
+
stepKey: key,
|
|
256
|
+
effectId: effectId!,
|
|
257
|
+
operation: operation!,
|
|
258
|
+
input: input as DurableObject,
|
|
259
|
+
retryIn: retryIn!,
|
|
260
|
+
lookup: (id) => lookup!.call(descriptor, id),
|
|
261
|
+
perform: (id) => perform!.call(descriptor, id),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
switch (outcome.status) {
|
|
265
|
+
case "settled":
|
|
266
|
+
return yield* record(outcome.result);
|
|
267
|
+
case "rejected": {
|
|
268
|
+
const err = new WorkflowEffectError(
|
|
269
|
+
outcome.code,
|
|
270
|
+
`Effect "${effectId}" was begun with a different operation or input`
|
|
271
|
+
);
|
|
73
272
|
yield new StepError(err);
|
|
273
|
+
throw err;
|
|
74
274
|
}
|
|
275
|
+
case "suspended":
|
|
276
|
+
yield new StepEffectWait(outcome.wakeAt);
|
|
277
|
+
throw new Error("Effect wait yielded control unexpectedly");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function invalidEffectRequest(request: {
|
|
282
|
+
effectId: unknown;
|
|
283
|
+
operation: unknown;
|
|
284
|
+
input: unknown;
|
|
285
|
+
retryIn: unknown;
|
|
286
|
+
lookup: unknown;
|
|
287
|
+
perform: unknown;
|
|
288
|
+
}): string | undefined {
|
|
289
|
+
const validated = validateEffectRequest(request);
|
|
290
|
+
if (!validated.ok) return validated.message;
|
|
291
|
+
const { retryIn } = request;
|
|
292
|
+
if (typeof retryIn !== "number" || !Number.isFinite(retryIn) || retryIn < 0) {
|
|
293
|
+
return "retryIn must be a finite, non-negative number";
|
|
294
|
+
}
|
|
295
|
+
if (typeof request.lookup !== "function") return "lookup must be a function";
|
|
296
|
+
if (typeof request.perform !== "function") return "perform must be a function";
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function* startStep(
|
|
301
|
+
key: string,
|
|
302
|
+
request: ExecutionStartRequest
|
|
303
|
+
): AsyncGenerator<StepResponse, { executionId: string }, any> {
|
|
304
|
+
yield new StepKey(key);
|
|
305
|
+
|
|
306
|
+
const cached = yield new StepCacheCheck();
|
|
307
|
+
|
|
308
|
+
if (cached) return yield* replay(cached);
|
|
309
|
+
|
|
310
|
+
const started: RuntimeCallResult<ExecutionStartResult> =
|
|
311
|
+
yield new StepRuntimeCall(async ({ executions }) => {
|
|
312
|
+
// The client's failures are never the workflow's to catch: whatever it
|
|
313
|
+
// throws, the delivery ends and the next attempt calls it again.
|
|
314
|
+
try {
|
|
315
|
+
return await executions.start(request);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
throw InfrastructureError.is(err) ? err : new InfrastructureError(err);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
if (!started.ok) {
|
|
322
|
+
throw new Error("ExecutionClient.start failed without an infrastructure error");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const outcome = started.value;
|
|
326
|
+
|
|
327
|
+
if (outcome.status === "rejected") {
|
|
328
|
+
const err = new WorkflowStartError(outcome.code, outcome.message);
|
|
329
|
+
yield new StepError(err);
|
|
75
330
|
// unreachable – consumer calls throw() on the generator first
|
|
76
331
|
throw err;
|
|
77
332
|
}
|
|
333
|
+
|
|
334
|
+
return yield* record({ executionId: outcome.executionId });
|
|
78
335
|
}
|
|
79
336
|
|
|
80
|
-
function
|
|
81
|
-
|
|
337
|
+
function createWorkflowStore<T>(params: {
|
|
338
|
+
definition: StoreDefinition<any>;
|
|
339
|
+
id: string;
|
|
340
|
+
event: WorkflowEvent;
|
|
341
|
+
}): WorkflowStore<T> {
|
|
342
|
+
const { id, event } = params;
|
|
343
|
+
const key = { storeName: params.definition.name, storeId: id };
|
|
344
|
+
// Any operation may run `initial` on a missing store, so its errors must
|
|
345
|
+
// reach workflow code instead of ending the delivery as infrastructure.
|
|
346
|
+
const definition: StoreDefinition<any> = {
|
|
347
|
+
...params.definition,
|
|
348
|
+
initial: workflowCallback(params.definition.initial),
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
definition: params.definition,
|
|
353
|
+
id,
|
|
354
|
+
key,
|
|
355
|
+
get(stepKey?: string) {
|
|
356
|
+
return runtimeStep(
|
|
357
|
+
stepKey ?? getCallSiteHash(this.get),
|
|
358
|
+
async ({ stores }) =>
|
|
359
|
+
(await stores.getStore({ definition, id })) as StoreSnapshot<T>
|
|
360
|
+
);
|
|
361
|
+
},
|
|
362
|
+
select<R>(stepKey: string, selector: StoreSelector<T, R>) {
|
|
363
|
+
return runtimeStep(stepKey, async ({ stores }) => {
|
|
364
|
+
const snapshot = await stores.getStore({ definition, id });
|
|
365
|
+
// Run the selector against a clone so accidental mutation cannot
|
|
366
|
+
// corrupt shared state (consistent with when, which throws via
|
|
367
|
+
// its tracking proxy).
|
|
368
|
+
return workflowCallback(selector)(cloneStoreState(snapshot.state as T));
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
update(stepKey, updater) {
|
|
372
|
+
return runtimeStep(
|
|
373
|
+
stepKey,
|
|
374
|
+
async ({ stores }) =>
|
|
375
|
+
(await stores.updateStore({
|
|
376
|
+
definition,
|
|
377
|
+
id,
|
|
378
|
+
updater: workflowCallback(updater, assertSynchronousUpdater) as any,
|
|
379
|
+
// Exactly-once across the store-commit/heap-write gap: on replay
|
|
380
|
+
// after a crash, the store's applied-steps ledger returns the
|
|
381
|
+
// recorded result instead of re-running the updater.
|
|
382
|
+
stepId: { executionId: event.executionId, stepKey },
|
|
383
|
+
})) as StoreUpdateResult<T>
|
|
384
|
+
);
|
|
385
|
+
},
|
|
386
|
+
updateFrom(stepKey, snapshot, updater) {
|
|
387
|
+
return runtimeStep(
|
|
388
|
+
stepKey,
|
|
389
|
+
async ({ stores }) =>
|
|
390
|
+
(await stores.updateStoreFrom({
|
|
391
|
+
definition,
|
|
392
|
+
id,
|
|
393
|
+
snapshot,
|
|
394
|
+
updater: workflowCallback(updater, assertSynchronousUpdater) as any,
|
|
395
|
+
stepId: { executionId: event.executionId, stepKey },
|
|
396
|
+
})) as StoreUpdateFromResult<T>
|
|
397
|
+
);
|
|
398
|
+
},
|
|
399
|
+
deleteFrom(stepKey, snapshot) {
|
|
400
|
+
return runtimeStep<StoreDeleteFromResult>(stepKey, ({ stores }) =>
|
|
401
|
+
stores.deleteStoreFrom({
|
|
402
|
+
definition,
|
|
403
|
+
id,
|
|
404
|
+
snapshot,
|
|
405
|
+
stepId: { executionId: event.executionId, stepKey },
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
},
|
|
409
|
+
when<R>(
|
|
410
|
+
arg1:
|
|
411
|
+
| string
|
|
412
|
+
| StoreSelector<T, R | undefined | null | false>,
|
|
413
|
+
arg2?: StoreSelector<T, R | undefined | null | false>
|
|
414
|
+
) {
|
|
415
|
+
const stepKey =
|
|
416
|
+
typeof arg1 === "string" ? arg1 : getCallSiteHash(this.when);
|
|
417
|
+
const selector = (
|
|
418
|
+
typeof arg1 === "string" ? arg2 : arg1
|
|
419
|
+
) as StoreSelector<T, R | undefined | null | false>;
|
|
82
420
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
421
|
+
return whenStep({
|
|
422
|
+
definition,
|
|
423
|
+
id,
|
|
424
|
+
event,
|
|
425
|
+
stepKey,
|
|
426
|
+
selector,
|
|
427
|
+
});
|
|
428
|
+
},
|
|
429
|
+
take<R>(
|
|
430
|
+
stepKey: string,
|
|
431
|
+
selector: StoreSelector<T, R | undefined | null | false>,
|
|
432
|
+
claim: (draft: Draft<T>, selected: NonNullable<R>) => void
|
|
433
|
+
) {
|
|
434
|
+
return takeStep({
|
|
435
|
+
definition,
|
|
436
|
+
id,
|
|
437
|
+
event,
|
|
438
|
+
stepKey,
|
|
439
|
+
selector,
|
|
440
|
+
claim,
|
|
441
|
+
});
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export type StepRunOptions<S extends StandardSchemaV1> = {
|
|
447
|
+
/**
|
|
448
|
+
* Validates the step's decoded value, on the first run and on every
|
|
449
|
+
* replay; the step returns the schema's output. The step function must
|
|
450
|
+
* return the schema's input as a durable value.
|
|
451
|
+
*/
|
|
452
|
+
schema: S;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* A step function whose result, or the value its promise resolves to, is a
|
|
457
|
+
* durable value. Checking the whole function type keeps the result's type as
|
|
458
|
+
* TypeScript infers it, and names the offending property in the error.
|
|
459
|
+
*/
|
|
460
|
+
export type DurableStepFn<F> = F extends () => infer R
|
|
461
|
+
? () => R extends PromiseLike<infer U> ? PromiseLike<DurableUnion<U>> : DurableUnion<R>
|
|
462
|
+
: never;
|
|
463
|
+
|
|
464
|
+
type DurableUnion<T> = T extends unknown ? Durable<T> : never;
|
|
465
|
+
|
|
466
|
+
function run<F extends () => unknown>(
|
|
467
|
+
fn: F & DurableStepFn<F>
|
|
468
|
+
): AsyncGenerator<StepResponse, Awaited<ReturnType<F>>, StepResponse>;
|
|
469
|
+
|
|
470
|
+
function run<F extends () => unknown>(
|
|
471
|
+
key: string,
|
|
472
|
+
fn: F & DurableStepFn<F>
|
|
473
|
+
): AsyncGenerator<StepResponse, Awaited<ReturnType<F>>, StepResponse>;
|
|
474
|
+
|
|
475
|
+
function run<S extends StandardSchemaV1>(
|
|
476
|
+
fn: () => StepSchemaInput<S> | PromiseLike<StepSchemaInput<S>>,
|
|
477
|
+
options: StepRunOptions<S>
|
|
478
|
+
): AsyncGenerator<StepResponse, StandardSchemaV1.InferOutput<S>, StepResponse>;
|
|
479
|
+
|
|
480
|
+
function run<S extends StandardSchemaV1>(
|
|
481
|
+
key: string,
|
|
482
|
+
fn: () => StepSchemaInput<S> | PromiseLike<StepSchemaInput<S>>,
|
|
483
|
+
options: StepRunOptions<S>
|
|
484
|
+
): AsyncGenerator<StepResponse, StandardSchemaV1.InferOutput<S>, StepResponse>;
|
|
485
|
+
|
|
486
|
+
// NOTE: run/delay/poll are plain (synchronous) functions that delegate to
|
|
487
|
+
// async generators. This matters for keyless steps: the call-site hash must be
|
|
488
|
+
// captured while the user's call site is still on the stack. An async
|
|
489
|
+
// generator body doesn't execute until its first .next(), which happens inside
|
|
490
|
+
// the workflow driver, so hashing there would give every keyless step the same
|
|
491
|
+
// key.
|
|
492
|
+
function run(
|
|
493
|
+
arg1: string | (() => unknown),
|
|
494
|
+
arg2?: (() => unknown) | StepRunOptions<StandardSchemaV1>,
|
|
495
|
+
arg3?: StepRunOptions<StandardSchemaV1>
|
|
496
|
+
): AsyncGenerator<StepResponse, any, StepResponse> {
|
|
87
497
|
let key: string | null = null;
|
|
88
|
-
let
|
|
498
|
+
let fn: () => unknown;
|
|
499
|
+
let options: StepRunOptions<StandardSchemaV1> | undefined;
|
|
89
500
|
|
|
90
501
|
if (typeof arg1 === "string") {
|
|
91
502
|
key = arg1;
|
|
92
|
-
|
|
503
|
+
fn = arg2 as () => unknown;
|
|
504
|
+
options = arg3;
|
|
93
505
|
} else {
|
|
94
|
-
|
|
506
|
+
fn = arg1;
|
|
507
|
+
options = arg2 as StepRunOptions<StandardSchemaV1> | undefined;
|
|
95
508
|
}
|
|
96
509
|
|
|
97
|
-
if (!key) key = getCallSiteHash(
|
|
510
|
+
if (!key) key = getCallSiteHash(run);
|
|
511
|
+
|
|
512
|
+
return durableStep(key, fn, options?.schema);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
type StepSchemaInput<S extends StandardSchemaV1> = Durable<
|
|
516
|
+
StandardSchemaV1.InferInput<S>
|
|
517
|
+
>;
|
|
518
|
+
|
|
519
|
+
/** `step.delay`, reading its wake time from the runtime clock. */
|
|
520
|
+
function createDelay(clock: () => number) {
|
|
521
|
+
function delay(retryInterval: number): any;
|
|
522
|
+
function delay(key: string, retryInterval: number): any;
|
|
523
|
+
function delay(
|
|
524
|
+
arg1: string | number,
|
|
525
|
+
arg2?: number
|
|
526
|
+
): AsyncGenerator<any, void, StepDelay> {
|
|
527
|
+
let key: string | null = null;
|
|
528
|
+
let retryInterval: number;
|
|
98
529
|
|
|
530
|
+
if (typeof arg1 === "string") {
|
|
531
|
+
key = arg1;
|
|
532
|
+
retryInterval = arg2!;
|
|
533
|
+
} else {
|
|
534
|
+
retryInterval = arg1;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!key) key = getCallSiteHash(delay);
|
|
538
|
+
|
|
539
|
+
return delayStep(key, retryInterval, clock);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return delay;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function* delayStep(
|
|
546
|
+
key: string,
|
|
547
|
+
retryInterval: number,
|
|
548
|
+
clock: () => number
|
|
549
|
+
): AsyncGenerator<any, void, StepDelay> {
|
|
99
550
|
yield new StepKey(key);
|
|
100
551
|
|
|
101
552
|
const cached = yield new StepCacheCheck();
|
|
102
553
|
|
|
103
554
|
if (cached) {
|
|
104
|
-
yield new StepDelay(cached.
|
|
555
|
+
yield new StepDelay(cached.wakeAt);
|
|
105
556
|
} else {
|
|
106
|
-
yield new StepDelay(
|
|
557
|
+
yield new StepDelay(clock() + retryInterval);
|
|
107
558
|
}
|
|
108
559
|
}
|
|
109
560
|
|
|
@@ -113,7 +564,7 @@ type PollPredicate = () => boolean | Promise<boolean>;
|
|
|
113
564
|
function poll(opts: PollOpts, predicate: PollPredicate): any;
|
|
114
565
|
function poll(key: string, opts: PollOpts, predicate: PollPredicate): any;
|
|
115
566
|
|
|
116
|
-
|
|
567
|
+
function poll(
|
|
117
568
|
arg1: string | PollOpts,
|
|
118
569
|
arg2: PollOpts | PollPredicate,
|
|
119
570
|
arg3?: PollPredicate
|
|
@@ -142,5 +593,249 @@ async function* poll(
|
|
|
142
593
|
}
|
|
143
594
|
};
|
|
144
595
|
|
|
145
|
-
|
|
596
|
+
return run(key, task);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function* durableStep(
|
|
600
|
+
key: string,
|
|
601
|
+
fn: () => unknown,
|
|
602
|
+
schema: StandardSchemaV1 | undefined
|
|
603
|
+
): AsyncGenerator<StepResponse, any, any> {
|
|
604
|
+
yield new StepKey(key);
|
|
605
|
+
|
|
606
|
+
const cached = yield new StepCacheCheck();
|
|
607
|
+
|
|
608
|
+
if (cached) {
|
|
609
|
+
const value = yield* replay(cached);
|
|
610
|
+
if (!schema) return value;
|
|
611
|
+
const validated = await validateStepValue(key, schema, value);
|
|
612
|
+
if (cached.schema === "rejected" && validated.ok) {
|
|
613
|
+
throw new WorkflowValueError(
|
|
614
|
+
"STEP_SCHEMA_INVALID",
|
|
615
|
+
key,
|
|
616
|
+
`Step "${key}" schema rejected its value on the first run`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return unwrapValidated(validated);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let result: unknown;
|
|
623
|
+
try {
|
|
624
|
+
result = await fn();
|
|
625
|
+
} catch (err: unknown) {
|
|
626
|
+
if (err instanceof RetryableError) {
|
|
627
|
+
yield new StepError(err, {
|
|
628
|
+
maxAttempts: err.maxAttempts,
|
|
629
|
+
retryInterval: err.retryInterval,
|
|
630
|
+
});
|
|
631
|
+
} else {
|
|
632
|
+
yield new StepError(err);
|
|
633
|
+
}
|
|
634
|
+
// unreachable – consumer calls throw() on the generator first
|
|
635
|
+
throw err;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!schema) return yield* record(result);
|
|
639
|
+
|
|
640
|
+
// The schema validates the value as replay will decode it. A value the
|
|
641
|
+
// codec rejects is left for the driver to record as INVALID_STEP_VALUE.
|
|
642
|
+
const encoding = durableValueCodec.encode(result);
|
|
643
|
+
if (encoding.status === "invalid") return yield* record(result);
|
|
644
|
+
const decoding = durableValueCodec.decode(encoding.canonical);
|
|
645
|
+
if (decoding.status === "invalid") throw new Error(decoding.message);
|
|
646
|
+
const validated = await validateStepValue(key, schema, decoding.value);
|
|
647
|
+
yield* record(result, validated.ok ? "accepted" : "rejected");
|
|
648
|
+
return unwrapValidated(validated);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
type ValidatedStepValue =
|
|
652
|
+
| { ok: true; value: unknown }
|
|
653
|
+
| { ok: false; error: WorkflowValueError };
|
|
654
|
+
|
|
655
|
+
async function validateStepValue(
|
|
656
|
+
key: string,
|
|
657
|
+
schema: StandardSchemaV1,
|
|
658
|
+
value: unknown
|
|
659
|
+
): Promise<ValidatedStepValue> {
|
|
660
|
+
const result = await schema["~standard"].validate(value);
|
|
661
|
+
if (result.issues) {
|
|
662
|
+
const messages = result.issues.map((issue) => issue.message).join("; ");
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
error: new WorkflowValueError(
|
|
666
|
+
"STEP_SCHEMA_INVALID",
|
|
667
|
+
key,
|
|
668
|
+
`Step "${key}" value failed its schema: ${messages}`
|
|
669
|
+
),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
return { ok: true, value: result.value };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function unwrapValidated(validated: ValidatedStepValue) {
|
|
676
|
+
if (!validated.ok) throw validated.error;
|
|
677
|
+
return validated.value;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Yields a step's result for the driver to record, and returns the decoded
|
|
682
|
+
* record the driver resumes the step with, so that the first run returns
|
|
683
|
+
* what replay will.
|
|
684
|
+
*/
|
|
685
|
+
async function* record<T>(
|
|
686
|
+
result: T,
|
|
687
|
+
schema?: StepSchemaVerdict
|
|
688
|
+
): AsyncGenerator<StepResponse, T, any> {
|
|
689
|
+
const recorded: StepResult = yield new StepResult(result, schema);
|
|
690
|
+
return recorded.result;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Yields a recorded response; the driver throws a recorded error into the step. */
|
|
694
|
+
async function* replay(cached: StepResponse): AsyncGenerator<StepResponse, any, any> {
|
|
695
|
+
const recorded: StepResult = yield cached;
|
|
696
|
+
return recorded.result;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Yields a store step's error for the driver to record and throw. A value
|
|
701
|
+
* error raised by a store client is given this step's key.
|
|
702
|
+
*/
|
|
703
|
+
async function* fail(key: string, error: unknown): AsyncGenerator<StepResponse, never, any> {
|
|
704
|
+
const err =
|
|
705
|
+
error instanceof WorkflowValueError && error.stepKey !== key
|
|
706
|
+
? new WorkflowValueError(error.code, key, error.message)
|
|
707
|
+
: error;
|
|
708
|
+
yield new StepError(err);
|
|
709
|
+
// unreachable – consumer calls throw() on the generator first
|
|
710
|
+
throw err;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* A durable step whose work is a runtime client call. The driver makes the
|
|
715
|
+
* call and resumes the step with its result.
|
|
716
|
+
*/
|
|
717
|
+
async function* runtimeStep<T>(
|
|
718
|
+
key: string,
|
|
719
|
+
call: StepRuntimeCall<T>["call"]
|
|
720
|
+
): AsyncGenerator<StepResponse, T, any> {
|
|
721
|
+
yield new StepKey(key);
|
|
722
|
+
|
|
723
|
+
const cached = yield new StepCacheCheck();
|
|
724
|
+
|
|
725
|
+
if (cached) return yield* replay(cached);
|
|
726
|
+
|
|
727
|
+
const outcome: RuntimeCallResult<T> = yield new StepRuntimeCall(call);
|
|
728
|
+
|
|
729
|
+
if (!outcome.ok) return yield* fail(key, outcome.error);
|
|
730
|
+
|
|
731
|
+
return yield* record(outcome.value);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function* whenStep<T, R>(params: {
|
|
735
|
+
definition: StoreDefinition<any>;
|
|
736
|
+
id: string;
|
|
737
|
+
event: WorkflowEvent;
|
|
738
|
+
stepKey: string;
|
|
739
|
+
selector: StoreSelector<T, R | undefined | null | false>;
|
|
740
|
+
}): AsyncGenerator<StepResponse, NonNullable<R>, any> {
|
|
741
|
+
const { definition, id, event, stepKey, selector } = params;
|
|
742
|
+
|
|
743
|
+
yield new StepKey(stepKey);
|
|
744
|
+
|
|
745
|
+
const cached = yield new StepCacheCheck();
|
|
746
|
+
|
|
747
|
+
if (cached) return yield* replay(cached);
|
|
748
|
+
|
|
749
|
+
const read: RuntimeCallResult<StoreSnapshot<unknown>> =
|
|
750
|
+
yield new StepRuntimeCall(({ stores }) =>
|
|
751
|
+
stores.getStore({ definition, id })
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
if (!read.ok) return yield* fail(stepKey, read.error);
|
|
755
|
+
|
|
756
|
+
const snapshot = read.value;
|
|
757
|
+
const { result, readPaths } = trackStoreSelector(
|
|
758
|
+
cloneStoreState(snapshot.state as T),
|
|
759
|
+
selector
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
if (result !== undefined && result !== null && result !== false) {
|
|
763
|
+
return yield* record(result as NonNullable<R>);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
yield* registerWaiter({
|
|
767
|
+
workflowId: event.workflowId,
|
|
768
|
+
executionId: event.executionId,
|
|
769
|
+
stepKey,
|
|
770
|
+
storeName: definition.name,
|
|
771
|
+
storeId: id,
|
|
772
|
+
instanceId: snapshot.instanceId,
|
|
773
|
+
sinceVersion: snapshot.version,
|
|
774
|
+
readPaths,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
throw new Error("Store wait yielded control unexpectedly");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function* takeStep<T, R>(params: {
|
|
781
|
+
definition: StoreDefinition<any>;
|
|
782
|
+
id: string;
|
|
783
|
+
event: WorkflowEvent;
|
|
784
|
+
stepKey: string;
|
|
785
|
+
selector: StoreSelector<T, R | undefined | null | false>;
|
|
786
|
+
claim: (draft: Draft<T>, selected: NonNullable<R>) => void;
|
|
787
|
+
}): AsyncGenerator<StepResponse, NonNullable<R>, any> {
|
|
788
|
+
const { definition, id, event, stepKey, selector, claim } = params;
|
|
789
|
+
|
|
790
|
+
yield new StepKey(stepKey);
|
|
791
|
+
|
|
792
|
+
const cached = yield new StepCacheCheck();
|
|
793
|
+
|
|
794
|
+
if (cached) return yield* replay(cached);
|
|
795
|
+
|
|
796
|
+
const taken: RuntimeCallResult<StoreTakeResult<R>> =
|
|
797
|
+
yield new StepRuntimeCall(({ stores }) =>
|
|
798
|
+
stores.takeFromStore({
|
|
799
|
+
definition,
|
|
800
|
+
id,
|
|
801
|
+
selector: workflowCallback(selector) as any,
|
|
802
|
+
claim: workflowCallback(claim, assertSynchronousClaim) as any,
|
|
803
|
+
// Exactly-once across the store-commit/heap-write gap: on replay
|
|
804
|
+
// after a crash, the store's applied-steps ledger returns the
|
|
805
|
+
// recorded (matched) outcome instead of re-running selector/claim.
|
|
806
|
+
stepId: { executionId: event.executionId, stepKey },
|
|
807
|
+
})
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
if (!taken.ok) return yield* fail(stepKey, taken.error);
|
|
811
|
+
|
|
812
|
+
const outcome = taken.value;
|
|
813
|
+
|
|
814
|
+
if (outcome.matched) return yield* record(outcome.selected as NonNullable<R>);
|
|
815
|
+
|
|
816
|
+
yield* registerWaiter({
|
|
817
|
+
workflowId: event.workflowId,
|
|
818
|
+
executionId: event.executionId,
|
|
819
|
+
stepKey,
|
|
820
|
+
storeName: definition.name,
|
|
821
|
+
storeId: id,
|
|
822
|
+
instanceId: outcome.instanceId,
|
|
823
|
+
sinceVersion: outcome.version,
|
|
824
|
+
readPaths: outcome.readPaths,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
throw new Error("Store take yielded control unexpectedly");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Registers a waiter, then suspends the step until a store change wakes it. */
|
|
831
|
+
async function* registerWaiter(
|
|
832
|
+
waiter: StoreWaiter
|
|
833
|
+
): AsyncGenerator<StepResponse, void, any> {
|
|
834
|
+
const registered: RuntimeCallResult<void> = yield new StepRuntimeCall(
|
|
835
|
+
({ stores }) => stores.registerWaiter(waiter)
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
if (!registered.ok) return yield* fail(waiter.stepKey, registered.error);
|
|
839
|
+
|
|
840
|
+
yield new StepStoreWait();
|
|
146
841
|
}
|