yieldstar 0.5.0 → 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.
@@ -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";
10
- import {
11
- deserializeStepResponse,
12
- serializeStepResponse,
13
- } from "../internal/serialise";
12
+ import { decodeStepRecord, encodeStepRecord } from "../internal/step-record";
14
13
  import { createStepRunner } from "../internal/step-runner";
14
+ import {
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,9 +24,15 @@ import {
19
24
  StepResult,
20
25
  StepDelay,
21
26
  StepCacheCheck,
27
+ StepEffectWait,
22
28
  StepInvalid,
23
29
  StepStoreWait,
24
30
  WorkflowResult,
31
+ InfrastructureError,
32
+ LeaseLostError,
33
+ StepConflictError,
34
+ isLeaseLost,
35
+ WORKFLOW_RESULT_STEP_KEY,
25
36
  } from "@yieldstar/core";
26
37
 
27
38
  export type WorkflowFn<
@@ -34,51 +45,70 @@ export type WorkflowFn<
34
45
  logger: Logger
35
46
  ) => AsyncGenerator<any, Result>;
36
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
+ */
37
66
  export function workflow<
38
67
  Params extends EventParams,
39
68
  Result,
40
69
  Context extends EventContext
41
70
  >(
42
- workflowFn: WorkflowFn<Params, Result, Context>
71
+ workflowFn: WorkflowFn<Params, Result, Context> &
72
+ DurableWorkflowFn<Params, Result>
43
73
  ): WorkflowGenerator<Params, Result, Context> {
44
- /**
45
- * @description Advances workflow steps, handling any workflow logic, and
46
- * yielding control to a workflow executor to do async work
47
- * @yields {StepResponse}
48
- */
49
- return async function* workflowGenerator({ event, heapClient, storeClient, logger }) {
50
- const stepRunner = createStepRunner({ event, storeClient });
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 });
51
86
  const workflowIterator = workflowFn(stepRunner, event, logger);
52
87
  const { executionId } = event;
53
88
 
54
89
  let iteratorResult: IteratorResult<any> | null = null;
55
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;
56
94
  const stepKeys = new Set<string>();
57
95
 
58
96
  while (true) {
59
97
  let stepAttempt = 0;
60
98
  let stepKey: string;
61
99
 
62
- /**
63
- * If we already have the next iterator result from a previous iteration
64
- * (e.g. it threw an error and advanced the workflow), use that, otherwise,
65
- * advance the workflow generator
66
- */
100
+ // Throwing a step error into the workflow already advanced it.
67
101
  if (nextIteratorResult) {
68
102
  iteratorResult = nextIteratorResult;
69
103
  nextIteratorResult = null;
70
104
  } else {
71
- iteratorResult = await workflowIterator.next();
105
+ stopIfAborted(signal);
106
+ iteratorResult = await workflowIterator.next(resumeWith);
72
107
  }
108
+ resumeWith = undefined;
73
109
 
74
- /**
75
- * Get the StepKey from the step runner.
76
- * If the generator is done, the workflow has returned, so we set a
77
- * special key for that.
78
- * Finally, check that this call site wasn't reached before.
79
- */
80
110
  if (iteratorResult.done) {
81
- stepKey = "$$workflow-result$$";
111
+ stepKey = WORKFLOW_RESULT_STEP_KEY;
82
112
  } else if (!(iteratorResult.value instanceof StepKey)) {
83
113
  throw new Error("Step runners must yield a StepKey object");
84
114
  } else {
@@ -89,41 +119,26 @@ export function workflow<
89
119
  stepKeys.add(stepKey)
90
120
  }
91
121
 
92
- /**
93
- * Using the StepKey, retrieve the previous response for this step from
94
- * the cache
95
- */
96
- let cached = await heapClient.readStep({
97
- executionId,
98
- stepKey,
99
- });
122
+ let cached = await infrastructure(() =>
123
+ heapClient.readStep({
124
+ executionId,
125
+ stepKey,
126
+ })
127
+ );
100
128
 
101
- /**
102
- * Increment attempt counter. We'll save this after execution.
103
- */
104
129
  if (cached?.meta) {
105
130
  stepAttempt = cached.meta.attempt + 1;
106
131
  }
107
132
 
108
- /**
109
- * If the step needs to be retried, ignore the cached value
110
- */
133
+ // An unfinished record is a failed attempt: run the step again.
111
134
  if (cached?.meta.done === false) {
112
135
  cached = null;
113
136
  }
114
137
 
115
- /**
116
- * If the workflow generator hasn't returned, advance the step runner
117
- */
118
138
  if (!iteratorResult.done) {
119
139
  iteratorResult = await workflowIterator.next();
120
140
  }
121
141
 
122
- /**
123
- * We can now get a StepResponse object from the iterator result.
124
- * The step runner generator always yields a StepResponse, whereas
125
- * the workflow generator return value needs to be wrapped.
126
- */
127
142
  let stepResponse: StepResponse;
128
143
 
129
144
  if (iteratorResult.done) {
@@ -132,26 +147,45 @@ export function workflow<
132
147
  stepResponse = iteratorResult.value;
133
148
  }
134
149
 
135
- /**
136
- * If the first yielded value is a cache check, advance to actual step,
137
- * passing it the cached value
138
- */
139
150
  if (stepResponse instanceof StepCacheCheck) {
140
151
  const cachedResponse = cached
141
- ? deserializeStepResponse(cached.stepResponseJson)
152
+ ? decodeStepRecord(cached.stepResponseJson)
142
153
  : null;
154
+ // Resuming the step runs the step function unless it is cached.
155
+ stopIfAborted(signal);
143
156
  iteratorResult = await workflowIterator.next(cachedResponse);
144
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
+ }
145
183
  } else if (!(stepResponse instanceof WorkflowResult)) {
146
184
  throw new Error("Step runners must yield a CacheCheck object");
147
185
  }
148
186
 
149
- /**
150
- * Check for invalid iterators e.g. developer didn't use yield* or didn't
151
- * use a step runner
152
- * @todo: iterable check should be before StepKey check
153
- * @todo: not throwing here causes a critical error – should workflow break?
154
- */
187
+ // TODO: the iterable check should come before the StepKey check.
188
+ // TODO: not throwing here causes a critical error; should the workflow break?
155
189
  if (isIterable(stepResponse)) {
156
190
  console.log("[ERR] Steps must be yielded using yield*\n");
157
191
  stepResponse = new StepInvalid();
@@ -164,84 +198,106 @@ export function workflow<
164
198
  stepResponse = new StepInvalid();
165
199
  }
166
200
 
167
- /**
168
- * Determine if step needs to be retried
169
- */
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
+
170
210
  const needsRetry =
171
211
  !cached?.meta.done &&
172
212
  stepResponse instanceof StepError &&
173
213
  // 1-indexed vs 0-indexed
174
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.
175
217
  const needsWake =
176
- !cached?.meta.done && stepResponse instanceof StepStoreWait;
218
+ !cached?.meta.done &&
219
+ (stepResponse instanceof StepStoreWait ||
220
+ stepResponse instanceof StepEffectWait);
177
221
 
178
- /**
179
- * If this step attempt is not already cached, cache it
180
- */
181
- if (!cached) {
182
- await heapClient.writeStep({
183
- executionId,
184
- stepKey,
185
- stepAttempt,
186
- stepDone: !needsRetry && !needsWake,
187
- stepResponseJson: serializeStepResponse(stepResponse),
188
- });
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
+ }
189
249
  }
190
250
 
191
- /**
192
- * Once the WorkflowResult is cached, return the WorkflowResult to the
193
- * upstream consumer e.g the worker
194
- */
195
251
  if (stepResponse instanceof WorkflowResult) {
196
252
  return stepResponse;
197
253
  }
198
254
 
199
- /**
200
- * Once invalid iterator steps have been cached, stop execution
201
- */
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
+
202
261
  if (stepResponse instanceof StepInvalid) {
203
262
  break;
204
263
  }
205
264
 
206
- /**
207
- * If step runner yielded a step error, either throw the error back for
208
- * user to handle, or yield to worker, if it should be retried.
209
- * (Throwing also advances the workflow generator, so, in that case, we
210
- * store the next iterator result, and continue to the next step).
211
- */
212
265
  if (stepResponse instanceof StepError) {
213
266
  if (needsRetry) {
214
- yield new StepDelay(Date.now() + stepResponse.retryInterval);
267
+ yield new StepDelay(clock() + stepResponse.retryInterval);
215
268
  } else {
269
+ stopIfAborted(signal);
216
270
  nextIteratorResult = await workflowIterator.throw(stepResponse.err);
217
271
  continue;
218
272
  }
219
273
  }
220
274
 
221
- /**
222
- * If the delay has already elapsed, continue straight to next step
223
- */
224
275
  if (stepResponse instanceof StepDelay) {
225
- if (stepResponse.resumeIn <= Date.now()) {
276
+ if (stepResponse.wakeAt <= clock()) {
226
277
  continue;
227
278
  }
228
279
  }
229
280
 
230
- /**
231
- * Yield control back to the worker if the stepResponse is not a result
232
- * i.e. async work is required. (Whenever we receive a StepResult, it's
233
- * always possible to continue straight to the next step).
234
- */
235
- if (!(stepResponse instanceof StepResult)) {
281
+ if (stepResponse instanceof StepResult) {
282
+ resumeWith = stepResponse;
283
+ } else {
236
284
  yield stepResponse;
237
285
  }
238
286
  }
239
287
 
240
- /**
241
- * If we arrive here, something has gone wrong internally
242
- */
243
- 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
+ );
244
291
  };
245
292
  }
246
293
 
247
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,10 +2,27 @@ 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";
7
17
  export { defineStore } from "./internal/step-runner";
8
- export type { WorkflowStore } from "./internal/step-runner";
18
+ export type {
19
+ DurableStepFn,
20
+ EffectDescriptor,
21
+ EffectLookup,
22
+ StepRunner,
23
+ StepRunOptions,
24
+ WorkflowStore,
25
+ } from "./internal/step-runner";
9
26
  export type {
10
27
  Draft,
11
28
  RuntimeStore,
@@ -17,8 +34,23 @@ export type {
17
34
  StoreSelector,
18
35
  StoreSnapshot,
19
36
  StoreState,
37
+ DurableStoreSchema,
20
38
  StoreUpdateFromResult,
21
39
  StoreUpdateResult,
22
40
  StoreVersion,
23
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,
24
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
+ }