@yieldstar/core 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.
Files changed (64) hide show
  1. package/dist/base/durable-value.d.ts +71 -0
  2. package/dist/base/durable-value.d.ts.map +1 -0
  3. package/dist/base/durable-value.test.d.ts +2 -0
  4. package/dist/base/durable-value.test.d.ts.map +1 -0
  5. package/dist/base/effect.d.ts +136 -0
  6. package/dist/base/effect.d.ts.map +1 -0
  7. package/dist/base/errors.d.ts +23 -0
  8. package/dist/base/errors.d.ts.map +1 -0
  9. package/dist/base/errors.test.d.ts +2 -0
  10. package/dist/base/errors.test.d.ts.map +1 -0
  11. package/dist/base/event.d.ts +6 -1
  12. package/dist/base/event.d.ts.map +1 -1
  13. package/dist/base/execution.d.ts +244 -0
  14. package/dist/base/execution.d.ts.map +1 -0
  15. package/dist/base/heap.d.ts +15 -3
  16. package/dist/base/heap.d.ts.map +1 -1
  17. package/dist/base/invoker.d.ts +47 -2
  18. package/dist/base/invoker.d.ts.map +1 -1
  19. package/dist/base/runtime.d.ts +27 -4
  20. package/dist/base/runtime.d.ts.map +1 -1
  21. package/dist/base/step.d.ts +21 -5
  22. package/dist/base/step.d.ts.map +1 -1
  23. package/dist/base/store.d.ts +135 -21
  24. package/dist/base/store.d.ts.map +1 -1
  25. package/dist/base/workflow.d.ts +15 -0
  26. package/dist/base/workflow.d.ts.map +1 -1
  27. package/dist/index.d.ts +23 -6
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1647 -172
  30. package/dist/lib/effect-state.d.ts +78 -0
  31. package/dist/lib/effect-state.d.ts.map +1 -0
  32. package/dist/lib/execution-dispatcher.d.ts +102 -0
  33. package/dist/lib/execution-dispatcher.d.ts.map +1 -0
  34. package/dist/lib/execution-state.d.ts +248 -0
  35. package/dist/lib/execution-state.d.ts.map +1 -0
  36. package/dist/lib/execution-state.test.d.ts +2 -0
  37. package/dist/lib/execution-state.test.d.ts.map +1 -0
  38. package/dist/lib/wait-for-outcome.d.ts +15 -0
  39. package/dist/lib/wait-for-outcome.d.ts.map +1 -0
  40. package/dist/lib/workflow-runner.d.ts +14 -9
  41. package/dist/lib/workflow-runner.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/base/durable-value.test.ts +285 -0
  44. package/src/base/durable-value.ts +442 -0
  45. package/src/base/effect.ts +165 -0
  46. package/src/base/errors.test.ts +210 -0
  47. package/src/base/errors.ts +72 -0
  48. package/src/base/event.ts +6 -1
  49. package/src/base/execution.ts +303 -0
  50. package/src/base/heap.ts +17 -3
  51. package/src/base/invoker.ts +60 -2
  52. package/src/base/runtime.ts +28 -7
  53. package/src/base/step.ts +29 -7
  54. package/src/base/store.ts +339 -49
  55. package/src/base/workflow.ts +15 -0
  56. package/src/index.ts +137 -3
  57. package/src/lib/effect-state.ts +254 -0
  58. package/src/lib/execution-dispatcher.ts +393 -0
  59. package/src/lib/execution-state.test.ts +249 -0
  60. package/src/lib/execution-state.ts +650 -0
  61. package/src/lib/wait-for-outcome.ts +40 -0
  62. package/src/lib/workflow-runner.ts +75 -43
  63. package/src/base/scheduler.ts +0 -8
  64. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { durableValueCodec, encodeThrownValue } from "./durable-value";
3
+ import { WorkflowStartError } from "./execution";
4
+ import { WorkflowValueError, registerErrorClass } from "./errors";
5
+
6
+ function encode(value: unknown) {
7
+ const encoding = durableValueCodec.encode(value);
8
+ if (encoding.status !== "encoded") {
9
+ throw new Error(`expected encoded, got: ${encoding.message}`);
10
+ }
11
+ return encoding.canonical;
12
+ }
13
+
14
+ function decode(canonical: string) {
15
+ const decoding = durableValueCodec.decode(canonical);
16
+ if (decoding.status !== "decoded") {
17
+ throw new Error(`expected decoded, got: ${decoding.message}`);
18
+ }
19
+ return decoding.value;
20
+ }
21
+
22
+ function roundTrip<T>(value: T) {
23
+ return decode(encode(value)) as T;
24
+ }
25
+
26
+ class CourierError extends Error {
27
+ code = "COURIER_DOWN";
28
+ courier: string;
29
+ constructor(courier: string, options?: ErrorOptions) {
30
+ if (typeof courier !== "string") throw new Error("courier is required");
31
+ super(`${courier} is down`, options);
32
+ this.name = "CourierError";
33
+ this.courier = courier;
34
+ }
35
+ describe() {
36
+ return `${this.courier}: ${this.message}`;
37
+ }
38
+ }
39
+ registerErrorClass("CourierError", CourierError);
40
+
41
+ class UnregisteredError extends Error {
42
+ code = "NOPE";
43
+ attempts = 3;
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "UnregisteredError";
47
+ }
48
+ }
49
+
50
+ describe("error encoding", () => {
51
+ test("is tagged and canonical", () => {
52
+ const error = new Error("boom");
53
+ error.stack = "stack";
54
+ expect(encode(error)).toBe(
55
+ '{"$type":"error","message":"boom","name":"Error","stack":"stack"}'
56
+ );
57
+ });
58
+
59
+ test("keeps name, message, stack, a string code, cause and durable fields", () => {
60
+ const error = new CourierError("dhl", { cause: new TypeError("socket") });
61
+ const revived = roundTrip(error);
62
+ expect(revived).toBeInstanceOf(CourierError);
63
+ expect(revived.name).toBe("CourierError");
64
+ expect(revived.message).toBe("dhl is down");
65
+ expect(revived.stack).toBe(error.stack);
66
+ expect(revived.code).toBe("COURIER_DOWN");
67
+ expect(revived.courier).toBe("dhl");
68
+ expect(revived.describe()).toBe("dhl: dhl is down");
69
+ expect(revived.cause).toBeInstanceOf(TypeError);
70
+ expect((revived.cause as Error).message).toBe("socket");
71
+ });
72
+
73
+ test("revives without calling the constructor", () => {
74
+ const revived = roundTrip(new CourierError("ups"));
75
+ expect(revived).toBeInstanceOf(CourierError);
76
+ });
77
+
78
+ test("matches the original's own property layout", () => {
79
+ const error = new CourierError("dhl", { cause: "flaky" });
80
+ const revived = roundTrip(error);
81
+ expect(Object.keys(revived).sort()).toEqual(Object.keys(error).sort());
82
+ expect(Object.getOwnPropertyDescriptor(revived, "message")?.enumerable).toBe(false);
83
+ expect(Object.getOwnPropertyDescriptor(revived, "cause")?.enumerable).toBe(false);
84
+ expect(revived).toEqual(error);
85
+ });
86
+
87
+ test("revives native errors", () => {
88
+ const revived = roundTrip(new CourierError("dhl"));
89
+ expect(Object.prototype.toString.call(revived)).toBe("[object Error]");
90
+ });
91
+
92
+ test("revives an error without a stack without one", () => {
93
+ const error = new Error("boom");
94
+ delete error.stack;
95
+ expect(roundTrip(error).stack).toBeUndefined();
96
+ });
97
+
98
+ test("omits non-durable properties", () => {
99
+ const error = Object.assign(new Error("boom"), {
100
+ at: new Date(0),
101
+ handler: () => {},
102
+ ok: 1,
103
+ });
104
+ const revived = roundTrip(error);
105
+ expect(Object.keys(revived)).toEqual(["ok"]);
106
+ });
107
+
108
+ test("ignores a non-string code", () => {
109
+ const revived = roundTrip(Object.assign(new Error("boom"), { code: 42 }));
110
+ expect(revived).not.toHaveProperty("code");
111
+ });
112
+
113
+ test("keeps a cause of undefined distinct from no cause", () => {
114
+ expect(Object.hasOwn(roundTrip(new Error("a", { cause: undefined })), "cause")).toBe(
115
+ true
116
+ );
117
+ expect(Object.hasOwn(roundTrip(new Error("a")), "cause")).toBe(false);
118
+ });
119
+
120
+ test("omits a cause that cycles back to the error", () => {
121
+ const error = new Error("loop");
122
+ error.cause = error;
123
+ expect(Object.hasOwn(roundTrip(error), "cause")).toBe(false);
124
+ });
125
+
126
+ test("wraps fields with a $type key", () => {
127
+ const revived = roundTrip(Object.assign(new Error("boom"), { $type: "undefined" }));
128
+ expect((revived as unknown as { $type: string }).$type).toBe("undefined");
129
+ });
130
+
131
+ test("keeps AggregateError's errors", () => {
132
+ const revived = roundTrip(new AggregateError([new RangeError("r"), "two"], "all"));
133
+ expect(revived).toBeInstanceOf(AggregateError);
134
+ expect(revived.errors).toHaveLength(2);
135
+ expect(revived.errors[0]).toBeInstanceOf(RangeError);
136
+ expect(revived.errors[1]).toBe("two");
137
+ expect(Object.getOwnPropertyDescriptor(revived, "errors")?.enumerable).toBe(false);
138
+ });
139
+
140
+ test("works anywhere a value can appear", () => {
141
+ const revived = roundTrip({ ok: false, errors: [new TypeError("t")] });
142
+ expect(revived.errors[0]).toBeInstanceOf(TypeError);
143
+ });
144
+ });
145
+
146
+ describe("error revival", () => {
147
+ test("revives an unregistered class as an Error with the same name, message, code and fields", () => {
148
+ const revived = roundTrip(new UnregisteredError("nope"));
149
+ expect(Object.getPrototypeOf(revived)).toBe(Error.prototype);
150
+ expect(revived.name).toBe("UnregisteredError");
151
+ expect(revived.message).toBe("nope");
152
+ expect(revived.code).toBe("NOPE");
153
+ expect(revived.attempts).toBe(3);
154
+ });
155
+
156
+ test.each([
157
+ TypeError,
158
+ RangeError,
159
+ SyntaxError,
160
+ ReferenceError,
161
+ EvalError,
162
+ URIError,
163
+ ])("revives %o by default", (errorClass) => {
164
+ const revived = roundTrip(new errorClass("m"));
165
+ expect(Object.getPrototypeOf(revived)).toBe(errorClass.prototype);
166
+ expect(revived.name).toBe(errorClass.name);
167
+ expect(Object.hasOwn(revived, "name")).toBe(false);
168
+ });
169
+
170
+ test("revives the Yieldstar error classes by default", () => {
171
+ const start = roundTrip(new WorkflowStartError("START_REQUEST_MISMATCH", "m"));
172
+ expect(start).toBeInstanceOf(WorkflowStartError);
173
+ expect(start.code).toBe("START_REQUEST_MISMATCH");
174
+
175
+ const value = roundTrip(new WorkflowValueError("INVALID_STEP_VALUE", "now", "m"));
176
+ expect(value).toBeInstanceOf(WorkflowValueError);
177
+ expect(value.code).toBe("INVALID_STEP_VALUE");
178
+ expect(value.stepKey).toBe("now");
179
+ });
180
+
181
+ test("registration replaces the class for a name", () => {
182
+ class First extends Error {}
183
+ class Second extends Error {}
184
+ registerErrorClass("ReplacedError", First);
185
+ registerErrorClass("ReplacedError", Second);
186
+ const error = new First("m");
187
+ error.name = "ReplacedError";
188
+ expect(roundTrip(error)).toBeInstanceOf(Second);
189
+ });
190
+
191
+ test("rejects invalid registrations", () => {
192
+ expect(() => registerErrorClass("", Error)).toThrow(TypeError);
193
+ expect(() => registerErrorClass("Point", class Point {} as never)).toThrow(TypeError);
194
+ });
195
+ });
196
+
197
+ describe("encodeThrownValue", () => {
198
+ test("keeps a durable thrown value as thrown", () => {
199
+ expect(decode(encodeThrownValue("boom"))).toBe("boom");
200
+ expect(decode(encodeThrownValue({ code: 1 }))).toEqual({ code: 1 });
201
+ expect(decode(encodeThrownValue(new RangeError("r")))).toBeInstanceOf(RangeError);
202
+ });
203
+
204
+ test("records a non-durable thrown value as Error(String(value))", () => {
205
+ const revived = decode(encodeThrownValue(new Date(0))) as Error;
206
+ expect(Object.getPrototypeOf(revived)).toBe(Error.prototype);
207
+ expect(revived.message).toBe(String(new Date(0)));
208
+ expect((decode(encodeThrownValue(10n)) as Error).message).toBe("10");
209
+ });
210
+ });
@@ -0,0 +1,72 @@
1
+ import {
2
+ EffectClientInvariantError,
3
+ EffectDeliveryError,
4
+ WorkflowEffectError,
5
+ } from "./effect";
6
+ import { WorkflowStartError } from "./execution";
7
+
8
+ export type WorkflowValueErrorCode = "INVALID_STEP_VALUE" | "STEP_SCHEMA_INVALID";
9
+
10
+ /** The `stepKey` of a `WorkflowValueError` raised by an invalid workflow result. */
11
+ export const WORKFLOW_RESULT_STEP_KEY = "$$workflow-result$$";
12
+
13
+ /**
14
+ * Thrown to workflow code when a step produces a value the durable value codec
15
+ * rejects, or a step's schema rejects its decoded value. It is recorded as the
16
+ * step's error, so replay throws it again.
17
+ */
18
+ export class WorkflowValueError extends Error {
19
+ readonly code: WorkflowValueErrorCode;
20
+ readonly stepKey: string;
21
+
22
+ constructor(code: WorkflowValueErrorCode, stepKey: string, message: string) {
23
+ super(message);
24
+ this.name = "WorkflowValueError";
25
+ this.code = code;
26
+ this.stepKey = stepKey;
27
+ }
28
+ }
29
+
30
+ export type ErrorClass = abstract new (...args: any[]) => Error;
31
+
32
+ const errorClasses = new Map<string, ErrorClass>();
33
+
34
+ /**
35
+ * Registers the class that decoding revives errors named `name` as, replacing
36
+ * any class registered under that name. Registration is per process: a worker
37
+ * that has not registered a class revives its errors as plain `Error`s with
38
+ * the same name, message, code, cause and fields.
39
+ */
40
+ export function registerErrorClass(name: string, errorClass: ErrorClass): void {
41
+ if (typeof name !== "string" || name === "") {
42
+ throw new TypeError("Error class name must be a non-empty string");
43
+ }
44
+ if (
45
+ typeof errorClass !== "function" ||
46
+ !(errorClass.prototype instanceof Error || errorClass === Error)
47
+ ) {
48
+ throw new TypeError(`Error class "${name}" must extend Error`);
49
+ }
50
+ errorClasses.set(name, errorClass);
51
+ }
52
+
53
+ export function registeredErrorClass(name: string): ErrorClass | undefined {
54
+ return errorClasses.get(name);
55
+ }
56
+
57
+ // Names are spelled out so a minifying bundler cannot change them.
58
+ const defaults: [string, ErrorClass][] = [
59
+ ["TypeError", TypeError],
60
+ ["RangeError", RangeError],
61
+ ["SyntaxError", SyntaxError],
62
+ ["ReferenceError", ReferenceError],
63
+ ["EvalError", EvalError],
64
+ ["URIError", URIError],
65
+ ["AggregateError", AggregateError],
66
+ ["WorkflowStartError", WorkflowStartError],
67
+ ["WorkflowValueError", WorkflowValueError],
68
+ ["WorkflowEffectError", WorkflowEffectError],
69
+ ["EffectClientInvariantError", EffectClientInvariantError],
70
+ ["EffectDeliveryError", EffectDeliveryError],
71
+ ];
72
+ for (const [name, errorClass] of defaults) registerErrorClass(name, errorClass);
package/src/base/event.ts CHANGED
@@ -38,9 +38,14 @@ export type WorkflowEvent<
38
38
  context: Context;
39
39
  };
40
40
 
41
+ /**
42
+ * The event a trigger route passes through middleware before admission.
43
+ * `executionId` is absent when the caller lets the runtime choose one.
44
+ */
41
45
  export type MiddlewareEvent<
42
46
  Params extends EventParams = EventParams,
43
47
  Context extends MiddlewareEventContext = MiddlewareEventContext
44
- > = ExecutionEvent<Params> & {
48
+ > = Omit<ExecutionEvent<Params>, "executionId" | "context"> & {
49
+ executionId?: string;
45
50
  context: Context;
46
51
  };
@@ -0,0 +1,303 @@
1
+ import type { DurableObject } from "./durable-value";
2
+ import type { EventContext, EventParams } from "./event";
3
+
4
+ export type ExecutionStatus =
5
+ | "admitted"
6
+ | "running"
7
+ | "suspended"
8
+ | "completed"
9
+ | "failed";
10
+
11
+ export type ExecutionOutcome =
12
+ | { status: "completed"; result: unknown }
13
+ | { status: "failed"; error: Error };
14
+
15
+ /** The execution and step that started an execution with `step.start`. */
16
+ export type ExecutionParent = { executionId: string; stepKey: string };
17
+
18
+ /**
19
+ * The durable record of one workflow execution. Admission creates it and
20
+ * stores the event once; every delivery reads the event from here.
21
+ *
22
+ * - `admitted`: no delivery has been acknowledged yet.
23
+ * - `running`: the execution has a live lease. When the lease ends without a
24
+ * terminal acknowledgement, the status returns to `suspended` if any
25
+ * earlier delivery was acknowledged as suspended, and to `admitted`
26
+ * otherwise.
27
+ * - `suspended`: the latest acknowledged delivery suspended the workflow.
28
+ * - `completed` and `failed` are terminal. `outcome` is present only in these
29
+ * states.
30
+ *
31
+ * `parent` is present, and immutable, only on executions admitted by `start`.
32
+ */
33
+ export interface ExecutionRecord {
34
+ executionId: string;
35
+ workflowId: string;
36
+ params: EventParams;
37
+ context: EventContext;
38
+ status: ExecutionStatus;
39
+ outcome?: ExecutionOutcome;
40
+ admittedAt: number;
41
+ updatedAt: number;
42
+ /** Consecutive deliveries that ended without an acknowledgement. */
43
+ unacknowledgedDeliveryCount: number;
44
+ parent?: ExecutionParent;
45
+ }
46
+
47
+ export type ExecutionAdmissionErrorCode =
48
+ | "EXECUTION_ID_CONFLICT"
49
+ | "INVALID_EXECUTION_EVENT";
50
+
51
+ /**
52
+ * A lease on one execution, granted by `claimDue`. `token` is a fencing token:
53
+ * every claim of the execution issues a token greater than every earlier one,
54
+ * and only the current token's fenced writes are accepted. It also identifies
55
+ * the wakes the delivery covers. `expiresAt` is read from the runtime clock;
56
+ * clocks decide only when the execution becomes claimable again, never whose
57
+ * writes count.
58
+ */
59
+ export type ExecutionLease = {
60
+ executionId: string;
61
+ token: number;
62
+ expiresAt: number;
63
+ };
64
+
65
+ /**
66
+ * Returned by a fenced write whose lease is no longer current: a higher token
67
+ * has been issued, or the lease has ended. The write changed nothing, and its
68
+ * holder must stop.
69
+ */
70
+ export type LeaseLost = { status: "lost" };
71
+
72
+ export function isLeaseLost(value: unknown): value is LeaseLost {
73
+ return (
74
+ typeof value === "object" &&
75
+ value !== null &&
76
+ (value as { status?: unknown }).status === "lost"
77
+ );
78
+ }
79
+
80
+ /**
81
+ * What a delivery reports once its durable work is finished:
82
+ * - `completed`: the workflow returned a result;
83
+ * - `suspended`: the unfinished step is recorded, and `wakeAt`, when present,
84
+ * is the earliest time a paused step needs the execution to run again;
85
+ * - `failed`: workflow code threw an uncaught error.
86
+ *
87
+ * Infrastructure errors are never outcomes: the delivery rejects instead.
88
+ */
89
+ export type WorkflowRunOutcome =
90
+ | { status: "completed"; result: unknown }
91
+ | { status: "suspended"; wakeAt?: number }
92
+ | { status: "failed"; error: Error };
93
+
94
+ export type ExecutionAdmissionRequest = {
95
+ executionId?: string;
96
+ workflowId: string;
97
+ params: EventParams;
98
+ context: EventContext;
99
+ };
100
+
101
+ export type ExecutionAdmissionResult =
102
+ | { status: "accepted"; executionId: string }
103
+ | {
104
+ status: "rejected";
105
+ code: ExecutionAdmissionErrorCode;
106
+ message: string;
107
+ };
108
+
109
+ export type WorkflowStartErrorCode =
110
+ | "INVALID_START_PARAMETERS"
111
+ | "START_REQUEST_MISMATCH";
112
+
113
+ export type ExecutionStartRequest = {
114
+ /** Identifies the request: the calling execution and its step key. */
115
+ requestId: { executionId: string; stepKey: string };
116
+ workflowId: string;
117
+ params?: DurableObject;
118
+ };
119
+
120
+ export type ExecutionStartResult =
121
+ | { status: "accepted"; executionId: string }
122
+ | {
123
+ status: "rejected";
124
+ code: WorkflowStartErrorCode;
125
+ message: string;
126
+ };
127
+
128
+ export type ExecutionWakeResult =
129
+ | { status: "requested" }
130
+ | { status: "ignored" }
131
+ | {
132
+ status: "rejected";
133
+ code: "EXECUTION_NOT_FOUND";
134
+ message: string;
135
+ };
136
+
137
+ export type ExecutionAcknowledgeResult =
138
+ | { status: "suspended" }
139
+ | ExecutionOutcome
140
+ | LeaseLost;
141
+
142
+ export type ExecutionRenewResult =
143
+ | { status: "renewed"; lease: ExecutionLease }
144
+ | LeaseLost;
145
+
146
+ export type ExecutionReleaseResult = { status: "released" } | LeaseLost;
147
+
148
+ export interface ExecutionClient {
149
+ /**
150
+ * Creates the execution record with status `admitted` and makes it due
151
+ * immediately, all-or-nothing. Execution IDs are never reused.
152
+ */
153
+ admit(request: ExecutionAdmissionRequest): Promise<ExecutionAdmissionResult>;
154
+
155
+ get(executionId: string): Promise<ExecutionRecord | undefined>;
156
+
157
+ /**
158
+ * Starts a workflow on behalf of the execution and step named by
159
+ * `requestId`, idempotently by that ID. A new start admits the target with
160
+ * the caller's context and a parent link to the caller, and records the
161
+ * start request, all-or-nothing. A repeated request returns the first
162
+ * target's ID if its workflow ID and canonical params match, and
163
+ * `START_REQUEST_MISMATCH` otherwise, whatever the target's status. Invalid
164
+ * params return `INVALID_START_PARAMETERS` and admit nothing. Not fenced.
165
+ */
166
+ start(request: ExecutionStartRequest): Promise<ExecutionStartResult>;
167
+
168
+ /**
169
+ * Requests a wake from outside a delivery, such as a store commit. Wakes not
170
+ * covered by a claimed delivery merge into one due time: the earliest.
171
+ */
172
+ requestWake(request: {
173
+ executionId: string;
174
+ wakeAt: number;
175
+ }): Promise<ExecutionWakeResult>;
176
+
177
+ /**
178
+ * Leases up to `limit` executions whose due time is at or before the runtime
179
+ * clock and that have no live lease, earliest due first. Each lease lasts
180
+ * `duration` milliseconds and carries a token greater than every earlier
181
+ * token for its execution. An execution has at most one live lease at a
182
+ * time, across every client sharing the backend.
183
+ */
184
+ claimDue(request: {
185
+ limit: number;
186
+ duration: number;
187
+ }): Promise<ExecutionLease[]>;
188
+
189
+ /**
190
+ * Extends a lease to `duration` milliseconds from now. Returns `lost` if a
191
+ * higher token has been issued or the lease has ended. An expired lease
192
+ * that has not been claimed again is still current.
193
+ */
194
+ renew(request: {
195
+ lease: ExecutionLease;
196
+ duration: number;
197
+ }): Promise<ExecutionRenewResult>;
198
+
199
+ /**
200
+ * Ends a lease without acknowledging an outcome. The delivery counts as
201
+ * unacknowledged, so the execution becomes claimable again only after the
202
+ * redelivery delay. Returns `lost` for a stale lease.
203
+ */
204
+ release(request: { lease: ExecutionLease }): Promise<ExecutionReleaseResult>;
205
+
206
+ /**
207
+ * Records a delivery's outcome and ends its lease, all-or-nothing. A
208
+ * `suspended` outcome clears the wakes the lease covered, leaving the
209
+ * execution due at the earliest of `wakeAt` and any wake requested since the
210
+ * claim. A stale lease returns `lost` and changes nothing.
211
+ */
212
+ acknowledge(request: {
213
+ lease: ExecutionLease;
214
+ outcome: WorkflowRunOutcome;
215
+ }): Promise<ExecutionAcknowledgeResult>;
216
+ }
217
+
218
+ /**
219
+ * Thrown by the driver when a fenced write returns `lost`: another claim has
220
+ * taken over the execution, so this delivery must stop without acknowledging.
221
+ */
222
+ export class LeaseLostError extends Error {
223
+ readonly executionId: string;
224
+
225
+ constructor(executionId: string) {
226
+ super(`Lease on execution "${executionId}" was lost`);
227
+ this.name = "LeaseLostError";
228
+ this.executionId = executionId;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Thrown by the driver when the current holder writes a step record that
234
+ * already exists with a different value. The recorded value stands: the
235
+ * delivery ends as a retry, and the next delivery replays the recorded value.
236
+ */
237
+ export class StepConflictError extends Error {
238
+ readonly executionId: string;
239
+ readonly stepKey: string;
240
+
241
+ constructor(executionId: string, stepKey: string) {
242
+ super(
243
+ `Step "${stepKey}" of execution "${executionId}" is already recorded with a different value`
244
+ );
245
+ this.name = "StepConflictError";
246
+ this.executionId = executionId;
247
+ this.stepKey = stepKey;
248
+ }
249
+ }
250
+
251
+ export class ExecutionAdmissionError extends Error {
252
+ readonly code: ExecutionAdmissionErrorCode;
253
+
254
+ constructor(code: ExecutionAdmissionErrorCode, message: string) {
255
+ super(message);
256
+ this.name = "ExecutionAdmissionError";
257
+ this.code = code;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Thrown to workflow code by `step.start` when the runtime rejects the start.
263
+ * The rejection is recorded as the step's result, so replay throws it again.
264
+ */
265
+ export class WorkflowStartError extends Error {
266
+ readonly code: WorkflowStartErrorCode;
267
+
268
+ constructor(code: WorkflowStartErrorCode, message: string) {
269
+ super(message);
270
+ this.name = "WorkflowStartError";
271
+ this.code = code;
272
+ }
273
+ }
274
+
275
+ const INFRASTRUCTURE_ERROR = Symbol.for("yieldstar.infrastructureError");
276
+
277
+ /**
278
+ * Wraps a failure of the runtime rather than of workflow code, such as a heap
279
+ * or store client rejecting. A delivery that hits one rejects without
280
+ * resuming workflow code, and is not acknowledged.
281
+ */
282
+ export class InfrastructureError extends Error {
283
+ readonly [INFRASTRUCTURE_ERROR] = true;
284
+
285
+ constructor(cause: unknown) {
286
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
287
+ this.name = "InfrastructureError";
288
+ }
289
+
290
+ static is(value: unknown): value is InfrastructureError {
291
+ return (
292
+ typeof value === "object" &&
293
+ value !== null &&
294
+ (value as Record<symbol, unknown>)[INFRASTRUCTURE_ERROR] === true
295
+ );
296
+ }
297
+ }
298
+
299
+ export function isTerminalExecutionStatus(
300
+ status: ExecutionStatus
301
+ ): status is "completed" | "failed" {
302
+ return status === "completed" || status === "failed";
303
+ }
package/src/base/heap.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ExecutionLease, LeaseLost } from "./execution";
2
+
1
3
  export type HeapRecord = {
2
4
  stepResponseJson: string;
3
5
  meta: { attempt: number; done: boolean };
@@ -8,11 +10,23 @@ export abstract class HeapClient {
8
10
  executionId: string;
9
11
  stepKey: string;
10
12
  }): Promise<HeapRecord | null>;
13
+
14
+ /**
15
+ * Records one attempt of a step, fenced by `lease`. The token check and the
16
+ * write are atomic: if the lease is not current, nothing changes and the
17
+ * write returns `lost`. A `done` record also deletes the step's store
18
+ * and effect waiters in the same write.
19
+ *
20
+ * Records are keyed by execution, step key and attempt, and never change
21
+ * once written. Rewriting an existing record succeeds without changing it,
22
+ * and resolves with the record as stored, which may hold a different value
23
+ * from the one written: callers compare it to detect a conflicting write.
24
+ */
11
25
  abstract writeStep(params: {
12
- executionId: string;
26
+ lease: ExecutionLease;
13
27
  stepKey: string;
14
28
  stepAttempt: number;
15
- stepDone: boolean;
29
+ done: boolean;
16
30
  stepResponseJson: string;
17
- }): Promise<void>;
31
+ }): Promise<HeapRecord | LeaseLost>;
18
32
  }