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.
Files changed (43) hide show
  1. package/README.md +57 -64
  2. package/dist/exports/errors.d.ts.map +1 -1
  3. package/dist/exports/sdk-http.d.ts +8 -0
  4. package/dist/exports/sdk-http.d.ts.map +1 -1
  5. package/dist/exports/sdk-local.d.ts +19 -2
  6. package/dist/exports/sdk-local.d.ts.map +1 -1
  7. package/dist/exports/workflow.d.ts +14 -2
  8. package/dist/exports/workflow.d.ts.map +1 -1
  9. package/dist/index.d.ts +5 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +791 -137
  12. package/dist/internal/effect-call.d.ts +60 -0
  13. package/dist/internal/effect-call.d.ts.map +1 -0
  14. package/dist/internal/runtime-call.d.ts +39 -0
  15. package/dist/internal/runtime-call.d.ts.map +1 -0
  16. package/dist/internal/serialise.d.ts.map +1 -1
  17. package/dist/internal/step-record.d.ts +13 -0
  18. package/dist/internal/step-record.d.ts.map +1 -0
  19. package/dist/internal/step-runner.d.ts +93 -9
  20. package/dist/internal/step-runner.d.ts.map +1 -1
  21. package/dist/internal/utils.d.ts +11 -0
  22. package/dist/internal/utils.d.ts.map +1 -1
  23. package/package.json +8 -4
  24. package/src/exports/errors.ts +4 -0
  25. package/src/exports/sdk-http.ts +60 -27
  26. package/src/exports/sdk-local.ts +41 -15
  27. package/src/exports/workflow.ts +159 -99
  28. package/src/index.ts +51 -1
  29. package/src/internal/effect-call.ts +217 -0
  30. package/src/internal/runtime-call.ts +95 -0
  31. package/src/internal/step-record.ts +110 -0
  32. package/src/internal/step-runner.ts +748 -53
  33. package/src/internal/utils.ts +24 -10
  34. package/dist/exports/scheduler.d.ts +0 -49
  35. package/dist/exports/scheduler.d.ts.map +0 -1
  36. package/dist/exports/simplified-types.d.ts +0 -31
  37. package/dist/exports/simplified-types.d.ts.map +0 -1
  38. package/dist/exports/types.d.ts +0 -6
  39. package/dist/exports/types.d.ts.map +0 -1
  40. package/dist/internal/types.d.ts +0 -9
  41. package/dist/internal/types.d.ts.map +0 -1
  42. package/src/internal/serialise.ts +0 -44
  43. package/tsconfig.tsbuildinfo +0 -1
@@ -1,17 +1,22 @@
1
1
  import type { Logger } from "pino";
2
2
  import type {
3
+ Durable,
3
4
  WorkflowEvent,
4
5
  WorkflowGenerator,
5
6
  EventParams,
6
7
  EventContext,
8
+ IfDurable,
7
9
  } from "@yieldstar/core";
8
10
  import type { StepRunner } from "../internal/step-runner";
9
11
  import { isIterable } from "../internal/utils";
12
+ import { decodeStepRecord, encodeStepRecord } from "../internal/step-record";
13
+ import { createStepRunner } from "../internal/step-runner";
10
14
  import {
11
- deserializeStepResponse,
12
- serializeStepResponse,
13
- } from "../internal/serialise";
14
- import { stepRunner } from "../internal/step-runner";
15
+ StepRuntimeCall,
16
+ executeRuntimeCall,
17
+ infrastructure,
18
+ } from "../internal/runtime-call";
19
+ import { StepEffectCall, executeEffectCall } from "../internal/effect-call";
15
20
  import {
16
21
  StepResponse,
17
22
  StepKey,
@@ -19,8 +24,15 @@ import {
19
24
  StepResult,
20
25
  StepDelay,
21
26
  StepCacheCheck,
27
+ StepEffectWait,
22
28
  StepInvalid,
29
+ StepStoreWait,
23
30
  WorkflowResult,
31
+ InfrastructureError,
32
+ LeaseLostError,
33
+ StepConflictError,
34
+ isLeaseLost,
35
+ WORKFLOW_RESULT_STEP_KEY,
24
36
  } from "@yieldstar/core";
25
37
 
26
38
  export type WorkflowFn<
@@ -33,50 +45,70 @@ export type WorkflowFn<
33
45
  logger: Logger
34
46
  ) => AsyncGenerator<any, Result>;
35
47
 
48
+ /**
49
+ * A workflow function whose params and result are durable values. Params are
50
+ * an input, so a function type cannot check them; a missing property named
51
+ * for the problem does instead.
52
+ */
53
+ export type DurableWorkflowFn<Params, Result> = IfDurable<
54
+ Params,
55
+ { "Workflow params must be durable values": Durable<Params> }
56
+ > &
57
+ IfDurable<
58
+ Result,
59
+ (step: any, event: any, logger: any) => AsyncGenerator<any, Durable<Result>>
60
+ >;
61
+
62
+ /**
63
+ * Wraps a workflow function in a generator that advances its steps and yields
64
+ * control to the executor whenever a step needs async work.
65
+ */
36
66
  export function workflow<
37
67
  Params extends EventParams,
38
68
  Result,
39
69
  Context extends EventContext
40
70
  >(
41
- workflowFn: WorkflowFn<Params, Result, Context>
71
+ workflowFn: WorkflowFn<Params, Result, Context> &
72
+ DurableWorkflowFn<Params, Result>
42
73
  ): WorkflowGenerator<Params, Result, Context> {
43
- /**
44
- * @description Advances workflow steps, handling any workflow logic, and
45
- * yielding control to a workflow executor to do async work
46
- * @yields {StepResponse}
47
- */
48
- return async function* workflowGenerator({ event, heapClient, logger }) {
74
+ return async function* workflowGenerator({
75
+ event,
76
+ lease,
77
+ signal,
78
+ heapClient,
79
+ storeClient,
80
+ executionClient,
81
+ effectClient,
82
+ clock,
83
+ logger,
84
+ }) {
85
+ const stepRunner = createStepRunner({ event, clock });
49
86
  const workflowIterator = workflowFn(stepRunner, event, logger);
50
87
  const { executionId } = event;
51
88
 
52
89
  let iteratorResult: IteratorResult<any> | null = null;
53
90
  let nextIteratorResult: IteratorResult<any> | null = null;
91
+ // A step that produced a result resumes with its decoded record, which
92
+ // is what it returns to workflow code.
93
+ let resumeWith: StepResult | undefined;
54
94
  const stepKeys = new Set<string>();
55
95
 
56
96
  while (true) {
57
97
  let stepAttempt = 0;
58
98
  let stepKey: string;
59
99
 
60
- /**
61
- * If we already have the next iterator result from a previous iteration
62
- * (e.g. it threw an error and advanced the workflow), use that, otherwise,
63
- * advance the workflow generator
64
- */
100
+ // Throwing a step error into the workflow already advanced it.
65
101
  if (nextIteratorResult) {
66
102
  iteratorResult = nextIteratorResult;
67
103
  nextIteratorResult = null;
68
104
  } else {
69
- iteratorResult = await workflowIterator.next();
105
+ stopIfAborted(signal);
106
+ iteratorResult = await workflowIterator.next(resumeWith);
70
107
  }
108
+ resumeWith = undefined;
71
109
 
72
- /**
73
- * Get the StepKey from the step runner.
74
- * If the generator is done, the workflow has returned, so we set a
75
- * special key for that.
76
- * Finally, check that this call site wasn't reached before.
77
- */
78
110
  if (iteratorResult.done) {
79
- stepKey = "$$workflow-result$$";
111
+ stepKey = WORKFLOW_RESULT_STEP_KEY;
80
112
  } else if (!(iteratorResult.value instanceof StepKey)) {
81
113
  throw new Error("Step runners must yield a StepKey object");
82
114
  } else {
@@ -87,41 +119,26 @@ export function workflow<
87
119
  stepKeys.add(stepKey)
88
120
  }
89
121
 
90
- /**
91
- * Using the StepKey, retrieve the previous response for this step from
92
- * the cache
93
- */
94
- let cached = await heapClient.readStep({
95
- executionId,
96
- stepKey,
97
- });
122
+ let cached = await infrastructure(() =>
123
+ heapClient.readStep({
124
+ executionId,
125
+ stepKey,
126
+ })
127
+ );
98
128
 
99
- /**
100
- * Increment attempt counter. We'll save this after execution.
101
- */
102
129
  if (cached?.meta) {
103
130
  stepAttempt = cached.meta.attempt + 1;
104
131
  }
105
132
 
106
- /**
107
- * If the step needs to be retried, ignore the cached value
108
- */
133
+ // An unfinished record is a failed attempt: run the step again.
109
134
  if (cached?.meta.done === false) {
110
135
  cached = null;
111
136
  }
112
137
 
113
- /**
114
- * If the workflow generator hasn't returned, advance the step runner
115
- */
116
138
  if (!iteratorResult.done) {
117
139
  iteratorResult = await workflowIterator.next();
118
140
  }
119
141
 
120
- /**
121
- * We can now get a StepResponse object from the iterator result.
122
- * The step runner generator always yields a StepResponse, whereas
123
- * the workflow generator return value needs to be wrapped.
124
- */
125
142
  let stepResponse: StepResponse;
126
143
 
127
144
  if (iteratorResult.done) {
@@ -130,26 +147,45 @@ export function workflow<
130
147
  stepResponse = iteratorResult.value;
131
148
  }
132
149
 
133
- /**
134
- * If the first yielded value is a cache check, advance to actual step,
135
- * passing it the cached value
136
- */
137
150
  if (stepResponse instanceof StepCacheCheck) {
138
151
  const cachedResponse = cached
139
- ? deserializeStepResponse(cached.stepResponseJson)
152
+ ? decodeStepRecord(cached.stepResponseJson)
140
153
  : null;
154
+ // Resuming the step runs the step function unless it is cached.
155
+ stopIfAborted(signal);
141
156
  iteratorResult = await workflowIterator.next(cachedResponse);
142
157
  stepResponse = iteratorResult.value;
158
+
159
+ // A step that needs a runtime client yields a request. Make the call
160
+ // here, outside workflow code, and resume the step with its result.
161
+ // An infrastructure error rejects the delivery without resuming it.
162
+ while (
163
+ stepResponse instanceof StepRuntimeCall ||
164
+ stepResponse instanceof StepEffectCall
165
+ ) {
166
+ stopIfAborted(signal);
167
+ const callResult =
168
+ stepResponse instanceof StepEffectCall
169
+ ? await executeEffectCall(stepResponse.request, {
170
+ effects: effectClient,
171
+ executionId,
172
+ clock,
173
+ assertLease: () => stopIfAborted(signal),
174
+ logger,
175
+ })
176
+ : await executeRuntimeCall(stepResponse, {
177
+ stores: storeClient,
178
+ executions: executionClient,
179
+ });
180
+ iteratorResult = await workflowIterator.next(callResult);
181
+ stepResponse = iteratorResult.value;
182
+ }
143
183
  } else if (!(stepResponse instanceof WorkflowResult)) {
144
184
  throw new Error("Step runners must yield a CacheCheck object");
145
185
  }
146
186
 
147
- /**
148
- * Check for invalid iterators e.g. developer didn't use yield* or didn't
149
- * use a step runner
150
- * @todo: iterable check should be before StepKey check
151
- * @todo: not throwing here causes a critical error – should workflow break?
152
- */
187
+ // TODO: the iterable check should come before the StepKey check.
188
+ // TODO: not throwing here causes a critical error; should the workflow break?
153
189
  if (isIterable(stepResponse)) {
154
190
  console.log("[ERR] Steps must be yielded using yield*\n");
155
191
  stepResponse = new StepInvalid();
@@ -162,82 +198,106 @@ export function workflow<
162
198
  stepResponse = new StepInvalid();
163
199
  }
164
200
 
165
- /**
166
- * Determine if step needs to be retried
167
- */
201
+ let stepResponseJson: string | undefined;
202
+ // The workflow result is encoded on every delivery, so that an invalid
203
+ // result fails the same way whether or not it is already recorded.
204
+ if (!cached || stepResponse instanceof WorkflowResult) {
205
+ const record = encodeStepRecord(stepResponse, stepKey);
206
+ stepResponseJson = record.json;
207
+ stepResponse = record.response;
208
+ }
209
+
168
210
  const needsRetry =
169
211
  !cached?.meta.done &&
170
212
  stepResponse instanceof StepError &&
171
213
  // 1-indexed vs 0-indexed
172
214
  stepResponse.maxAttempts > stepAttempt + 1;
215
+ // A watched effect stays unfinished, so the next delivery begins it
216
+ // again; its waiter is deleted when the step's result is recorded.
217
+ const needsWake =
218
+ !cached?.meta.done &&
219
+ (stepResponse instanceof StepStoreWait ||
220
+ stepResponse instanceof StepEffectWait);
173
221
 
174
- /**
175
- * If this step attempt is not already cached, cache it
176
- */
177
- if (!cached) {
178
- await heapClient.writeStep({
179
- executionId,
180
- stepKey,
181
- stepAttempt,
182
- stepDone: !needsRetry,
183
- stepResponseJson: serializeStepResponse(stepResponse),
184
- });
222
+ if (!cached && stepResponseJson !== undefined) {
223
+ const done = !needsRetry && !needsWake;
224
+ stopIfAborted(signal);
225
+ const written = await infrastructure(() =>
226
+ heapClient.writeStep({
227
+ lease,
228
+ stepKey,
229
+ stepAttempt,
230
+ done,
231
+ stepResponseJson: stepResponseJson!,
232
+ })
233
+ );
234
+ // Another claim has taken over: stop without an outcome.
235
+ if (isLeaseLost(written)) {
236
+ throw new InfrastructureError(new LeaseLostError(executionId));
237
+ }
238
+ // The step was already recorded with a different value. The record
239
+ // stands, so this delivery must not continue with its own value: end
240
+ // it as a retry, and the next delivery replays the recorded one.
241
+ if (
242
+ written.stepResponseJson !== stepResponseJson ||
243
+ written.meta.done !== done
244
+ ) {
245
+ throw new InfrastructureError(
246
+ new StepConflictError(executionId, stepKey)
247
+ );
248
+ }
185
249
  }
186
250
 
187
- /**
188
- * Once the WorkflowResult is cached, return the WorkflowResult to the
189
- * upstream consumer e.g the worker
190
- */
191
251
  if (stepResponse instanceof WorkflowResult) {
192
252
  return stepResponse;
193
253
  }
194
254
 
195
- /**
196
- * Once invalid iterator steps have been cached, stop execution
197
- */
255
+ // Only an invalid workflow result is recorded as a step error under
256
+ // the workflow result's key. The workflow has finished, so it fails.
257
+ if (iteratorResult.done && stepResponse instanceof StepError) {
258
+ throw stepResponse.err;
259
+ }
260
+
198
261
  if (stepResponse instanceof StepInvalid) {
199
262
  break;
200
263
  }
201
264
 
202
- /**
203
- * If step runner yielded a step error, either throw the error back for
204
- * user to handle, or yield to worker, if it should be retried.
205
- * (Throwing also advances the workflow generator, so, in that case, we
206
- * store the next iterator result, and continue to the next step).
207
- */
208
265
  if (stepResponse instanceof StepError) {
209
266
  if (needsRetry) {
210
- yield new StepDelay(Date.now() + stepResponse.retryInterval);
267
+ yield new StepDelay(clock() + stepResponse.retryInterval);
211
268
  } else {
269
+ stopIfAborted(signal);
212
270
  nextIteratorResult = await workflowIterator.throw(stepResponse.err);
213
271
  continue;
214
272
  }
215
273
  }
216
274
 
217
- /**
218
- * If the delay has already elapsed, continue straight to next step
219
- */
220
275
  if (stepResponse instanceof StepDelay) {
221
- if (stepResponse.resumeIn <= Date.now()) {
276
+ if (stepResponse.wakeAt <= clock()) {
222
277
  continue;
223
278
  }
224
279
  }
225
280
 
226
- /**
227
- * Yield control back to the worker if the stepResponse is not a result
228
- * i.e. async work is required. (Whenever we receive a StepResult, it's
229
- * always possible to continue straight to the next step).
230
- */
231
- if (!(stepResponse instanceof StepResult)) {
281
+ if (stepResponse instanceof StepResult) {
282
+ resumeWith = stepResponse;
283
+ } else {
232
284
  yield stepResponse;
233
285
  }
234
286
  }
235
287
 
236
- /**
237
- * If we arrive here, something has gone wrong internally
238
- */
239
- throw new Error("Critical error");
288
+ throw new Error(
289
+ "Workflow yielded an invalid step. Steps must be yielded with yield* from a step runner."
290
+ );
240
291
  };
241
292
  }
242
293
 
243
294
  export const createWorkflow = workflow;
295
+
296
+ /**
297
+ * Ends the delivery once its signal has aborted, before it runs more workflow
298
+ * code or makes another fenced write. Like a lost lease found by a fenced
299
+ * write, it rejects with an infrastructure error, which is not acknowledged.
300
+ */
301
+ function stopIfAborted(signal: AbortSignal) {
302
+ if (signal.aborted) throw new InfrastructureError(signal.reason);
303
+ }
package/src/index.ts CHANGED
@@ -2,5 +2,55 @@ export { createLocalSdk } from "./exports/sdk-local";
2
2
  export { createHttpSdkFactory } from "./exports/sdk-http";
3
3
  export { createWorkflowRouter } from "./exports/router";
4
4
  export { RetryableError } from "./exports/errors";
5
+ export {
6
+ EffectClientInvariantError,
7
+ EffectDeliveryError,
8
+ ExecutionAdmissionError,
9
+ WORKFLOW_RESULT_STEP_KEY,
10
+ WorkflowEffectError,
11
+ WorkflowStartError,
12
+ WorkflowValueError,
13
+ registerErrorClass,
14
+ } from "@yieldstar/core";
5
15
  export { createWorkflow, workflow } from "./exports/workflow";
6
- export type { WorkflowFn } from "./exports/workflow";
16
+ export type { DurableWorkflowFn, WorkflowFn } from "./exports/workflow";
17
+ export { defineStore } from "./internal/step-runner";
18
+ export type {
19
+ DurableStepFn,
20
+ EffectDescriptor,
21
+ EffectLookup,
22
+ StepRunner,
23
+ StepRunOptions,
24
+ WorkflowStore,
25
+ } from "./internal/step-runner";
26
+ export type {
27
+ Draft,
28
+ RuntimeStore,
29
+ StandardSchemaV1,
30
+ StoreDefinition,
31
+ StoreDeleteFromResult,
32
+ StoreKey,
33
+ StorePath,
34
+ StoreSelector,
35
+ StoreSnapshot,
36
+ StoreState,
37
+ DurableStoreSchema,
38
+ StoreUpdateFromResult,
39
+ StoreUpdateResult,
40
+ StoreVersion,
41
+ WorkflowEvent,
42
+ ExecutionAdmissionErrorCode,
43
+ ExecutionOutcome,
44
+ ExecutionRecord,
45
+ ExecutionStatus,
46
+ ExecutionParent,
47
+ Durable,
48
+ DurableObject,
49
+ DurableValue,
50
+ ErrorClass,
51
+ WorkflowValueErrorCode,
52
+ WorkflowStartErrorCode,
53
+ EffectDeliveryErrorCode,
54
+ EffectRecord,
55
+ WorkflowEffectErrorCode,
56
+ } from "@yieldstar/core";
@@ -0,0 +1,217 @@
1
+ import type { Logger } from "pino";
2
+ import {
3
+ EffectClientInvariantError,
4
+ EffectDeliveryError,
5
+ InfrastructureError,
6
+ StepResponse,
7
+ durableValueCodec,
8
+ type DurableObject,
9
+ type DurableValue,
10
+ type EffectClient,
11
+ type EffectDeliveryErrorCode,
12
+ } from "@yieldstar/core";
13
+
14
+ /** What `lookup` reports about an effect ID (RFC 005). */
15
+ export type EffectLookup<T> =
16
+ | { status: "settled"; result: T }
17
+ | { status: "not-found" }
18
+ | { status: "unknown" };
19
+
20
+ /** The effect request a step yields once its descriptor is valid. */
21
+ export type EffectCallRequest = {
22
+ stepKey: string;
23
+ effectId: string;
24
+ operation: string;
25
+ input: DurableObject;
26
+ retryIn: number;
27
+ lookup(effectId: string): Promise<unknown>;
28
+ perform(effectId: string): Promise<unknown>;
29
+ };
30
+
31
+ /**
32
+ * - `settled`: the effect's result, which the step records.
33
+ * - `rejected`: the effect exists with a different operation or input.
34
+ * - `suspended`: the step watches the effect and is due again at `wakeAt`.
35
+ */
36
+ export type EffectCallOutcome =
37
+ | { status: "settled"; result: DurableValue }
38
+ | { status: "rejected"; code: "EFFECT_REQUEST_MISMATCH" }
39
+ | { status: "suspended"; wakeAt: number };
40
+
41
+ /**
42
+ * Yielded by `step.effect`. The driver runs the effect with
43
+ * `executeEffectCall` and resumes the step with its outcome.
44
+ */
45
+ export class StepEffectCall extends StepResponse {
46
+ static type = "effect-call";
47
+ readonly type = "effect-call";
48
+ readonly request: EffectCallRequest;
49
+
50
+ constructor(request: EffectCallRequest) {
51
+ super();
52
+ this.request = request;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Runs an effect step's protocol against the runtime's `EffectClient`. Every
58
+ * failure that must not be recorded rejects with an `InfrastructureError`
59
+ * whose cause is an `EffectDeliveryError`, so the delivery is retried.
60
+ */
61
+ export async function executeEffectCall(
62
+ request: EffectCallRequest,
63
+ context: {
64
+ effects: EffectClient;
65
+ executionId: string;
66
+ clock: () => number;
67
+ /** Checked before every callback, so a lost lease makes no external call. */
68
+ assertLease: () => void;
69
+ logger: Logger;
70
+ }
71
+ ): Promise<EffectCallOutcome> {
72
+ const { effects, executionId, clock, assertLease, logger } = context;
73
+ const { stepKey, effectId, operation, input, retryIn } = request;
74
+
75
+ const fail = (code: EffectDeliveryErrorCode, message: string, cause?: unknown) =>
76
+ new InfrastructureError(
77
+ new EffectDeliveryError(
78
+ code,
79
+ { effectId, stepKey, message },
80
+ cause === undefined ? undefined : { cause }
81
+ )
82
+ );
83
+
84
+ async function client<T>(method: string, call: () => Promise<T>): Promise<T> {
85
+ try {
86
+ return await call();
87
+ } catch (err) {
88
+ const message = `EffectClient.${method} failed for effect "${effectId}": ${errorMessage(err)}`;
89
+ if (isInvariantError(err)) throw fail("EFFECT_RUNTIME_INVARIANT", message, err);
90
+ throw fail("EFFECT_CLIENT_UNAVAILABLE", message, err);
91
+ }
92
+ }
93
+
94
+ const malformedClient = (method: string) =>
95
+ fail(
96
+ "EFFECT_RUNTIME_INVARIANT",
97
+ `EffectClient.${method} returned a malformed response for effect "${effectId}"`
98
+ );
99
+
100
+ async function suspend(): Promise<EffectCallOutcome> {
101
+ await client("watch", () => effects.watch({ effectId, executionId, stepKey }));
102
+ return { status: "suspended", wakeAt: clock() + retryIn };
103
+ }
104
+
105
+ async function settle(result: unknown): Promise<EffectCallOutcome> {
106
+ const encoding = durableValueCodec.encode(result);
107
+ if (encoding.status === "invalid") {
108
+ throw fail(
109
+ "EFFECT_HANDLER_INVALID",
110
+ `Effect "${effectId}" result is not a durable value: ${encoding.message}`
111
+ );
112
+ }
113
+ const settled = await client("settle", () =>
114
+ effects.settle({ operation, effectId, result: result as DurableValue })
115
+ );
116
+ if (!isObject(settled) || !("result" in settled)) throw malformedClient("settle");
117
+ if (settled.status === "conflict") {
118
+ logger.warn(
119
+ {
120
+ executionId,
121
+ stepKey,
122
+ effectId,
123
+ result: settled.result,
124
+ conflictingResult: settled.conflictingResult,
125
+ },
126
+ `Effect "${effectId}" settled with a different result; the first result stands`
127
+ );
128
+ } else if (settled.status !== "settled") {
129
+ throw malformedClient("settle");
130
+ }
131
+ return { status: "settled", result: settled.result };
132
+ }
133
+
134
+ const begun = await client("begin", () =>
135
+ effects.begin({ operation, effectId, input, executionId, stepKey })
136
+ );
137
+ if (!isObject(begun)) throw malformedClient("begin");
138
+
139
+ let next: "lookup" | "perform";
140
+ switch (begun.status) {
141
+ case "settled":
142
+ if (!("result" in begun)) throw malformedClient("begin");
143
+ return { status: "settled", result: begun.result };
144
+ case "rejected":
145
+ if (begun.code !== "EFFECT_REQUEST_MISMATCH") throw malformedClient("begin");
146
+ return { status: "rejected", code: begun.code };
147
+ case "new":
148
+ next = "perform";
149
+ break;
150
+ case "pending":
151
+ // An earlier attempt may have started the operation, or finished it.
152
+ next = "lookup";
153
+ break;
154
+ default:
155
+ throw malformedClient("begin");
156
+ }
157
+
158
+ if (next === "lookup") {
159
+ assertLease();
160
+ let found: unknown;
161
+ try {
162
+ found = await request.lookup(effectId);
163
+ } catch {
164
+ return suspend();
165
+ }
166
+ if (!isObject(found)) throw malformedLookup(fail, effectId);
167
+ switch (found.status) {
168
+ case "settled":
169
+ if (!("result" in found)) throw malformedLookup(fail, effectId);
170
+ return settle(found.result);
171
+ case "unknown":
172
+ return suspend();
173
+ case "not-found":
174
+ break;
175
+ default:
176
+ throw malformedLookup(fail, effectId);
177
+ }
178
+ }
179
+
180
+ assertLease();
181
+ let performed: unknown;
182
+ try {
183
+ performed = await request.perform(effectId);
184
+ } catch {
185
+ return suspend();
186
+ }
187
+ return settle(performed);
188
+ }
189
+
190
+ function malformedLookup(
191
+ fail: (code: EffectDeliveryErrorCode, message: string) => InfrastructureError,
192
+ effectId: string
193
+ ) {
194
+ return fail(
195
+ "EFFECT_HANDLER_INVALID",
196
+ `lookup for effect "${effectId}" returned a malformed response: expected { status: "settled", result }, { status: "not-found" } or { status: "unknown" }`
197
+ );
198
+ }
199
+
200
+ // Matches by code too, so an invariant error from another copy of
201
+ // @yieldstar/core, or one revived without its class, is still recognised.
202
+ function isInvariantError(err: unknown) {
203
+ return (
204
+ err instanceof EffectClientInvariantError ||
205
+ (typeof err === "object" &&
206
+ err !== null &&
207
+ (err as { code?: unknown }).code === "EFFECT_RUNTIME_INVARIANT")
208
+ );
209
+ }
210
+
211
+ function isObject(value: unknown): value is Record<string, unknown> {
212
+ return typeof value === "object" && value !== null;
213
+ }
214
+
215
+ function errorMessage(err: unknown) {
216
+ return err instanceof Error ? err.message : String(err);
217
+ }