@yieldstar/core 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 (79) 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 +26 -5
  22. package/dist/base/step.d.ts.map +1 -1
  23. package/dist/base/store.d.ts +431 -0
  24. package/dist/base/store.d.ts.map +1 -0
  25. package/dist/base/store.test.d.ts +2 -0
  26. package/dist/base/store.test.d.ts.map +1 -0
  27. package/dist/base/workflow.d.ts +17 -0
  28. package/dist/base/workflow.d.ts.map +1 -1
  29. package/dist/index.d.ts +24 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1986 -111
  32. package/dist/lib/effect-state.d.ts +78 -0
  33. package/dist/lib/effect-state.d.ts.map +1 -0
  34. package/dist/lib/execution-dispatcher.d.ts +102 -0
  35. package/dist/lib/execution-dispatcher.d.ts.map +1 -0
  36. package/dist/lib/execution-state.d.ts +248 -0
  37. package/dist/lib/execution-state.d.ts.map +1 -0
  38. package/dist/lib/execution-state.test.d.ts +2 -0
  39. package/dist/lib/execution-state.test.d.ts.map +1 -0
  40. package/dist/lib/wait-for-outcome.d.ts +15 -0
  41. package/dist/lib/wait-for-outcome.d.ts.map +1 -0
  42. package/dist/lib/workflow-runner.d.ts +14 -7
  43. package/dist/lib/workflow-runner.d.ts.map +1 -1
  44. package/dist/utils/map.d.ts +1 -1
  45. package/dist/utils/map.d.ts.map +1 -1
  46. package/package.json +13 -3
  47. package/src/base/durable-value.test.ts +285 -0
  48. package/src/base/durable-value.ts +442 -0
  49. package/src/base/effect.ts +165 -0
  50. package/src/base/errors.test.ts +210 -0
  51. package/src/base/errors.ts +72 -0
  52. package/src/base/event.ts +6 -1
  53. package/src/base/execution.ts +303 -0
  54. package/src/base/heap.ts +17 -3
  55. package/src/base/invoker.ts +60 -2
  56. package/src/base/runtime.ts +28 -7
  57. package/src/base/step.ts +37 -7
  58. package/src/base/store.test.ts +176 -0
  59. package/src/base/store.ts +1123 -0
  60. package/src/base/workflow.ts +17 -0
  61. package/src/index.ts +174 -3
  62. package/src/lib/effect-state.ts +254 -0
  63. package/src/lib/execution-dispatcher.ts +393 -0
  64. package/src/lib/execution-state.test.ts +249 -0
  65. package/src/lib/execution-state.ts +650 -0
  66. package/src/lib/wait-for-outcome.ts +40 -0
  67. package/src/lib/workflow-runner.ts +78 -34
  68. package/src/utils/map.test.ts +1 -1
  69. package/src/utils/map.ts +8 -2
  70. package/dist/base/map.d.ts +0 -21
  71. package/dist/base/map.d.ts.map +0 -1
  72. package/dist/base/map.test.d.ts +0 -2
  73. package/dist/base/map.test.d.ts.map +0 -1
  74. package/dist/base/schedule.d.ts +0 -71
  75. package/dist/base/schedule.d.ts.map +0 -1
  76. package/dist/base/types.d.ts +0 -24
  77. package/dist/base/types.d.ts.map +0 -1
  78. package/src/base/scheduler.ts +0 -8
  79. package/tsconfig.tsbuildinfo +0 -1
@@ -1,7 +1,65 @@
1
1
  import type { EventEmitter } from "node:events";
2
- import type { ExecutionEvent } from "./event";
2
+ import type { WorkflowEvent } from "./event";
3
+ import type { ExecutionLease, WorkflowRunOutcome } from "./execution";
4
+
5
+ export type DeliveryOptions = {
6
+ /** Every fenced write the delivery makes carries this lease. */
7
+ lease: ExecutionLease;
8
+ /**
9
+ * Aborted when the delivery must stop: the dispatcher aborts it, with a
10
+ * `LeaseLostError` as its reason, when a renewal returns `lost`. The driver
11
+ * checks it before each step function and each fenced write, and ends an
12
+ * aborted delivery without running more workflow code.
13
+ */
14
+ signal: AbortSignal;
15
+ };
3
16
 
4
17
  export type WorkflowInvoker = {
18
+ /**
19
+ * @deprecated The execution record is the source of truth for outcomes.
20
+ * The dispatcher still emits terminal outcomes here as an in-process fast
21
+ * path; no guarantee depends on it.
22
+ */
5
23
  workflowEndEmitter: EventEmitter;
6
- execute(event: ExecutionEvent): Promise<void>;
24
+ /**
25
+ * Runs one delivery of an execution and resolves with its outcome. Rejects,
26
+ * without an outcome, when the delivery hits an infrastructure error or the
27
+ * worker exits before replying. Once `options.signal` aborts, the delivery runs
28
+ * no more workflow code and makes no more fenced writes; it rejects with an
29
+ * `InfrastructureError` whose cause is the signal's reason. An invoker that
30
+ * can stop a step function already running, as the worker invoker does by
31
+ * killing its worker, should do so and reject at once. The dispatcher
32
+ * abandons a delivery that has not settled within its `abortGracePeriod`
33
+ * of the abort, and frees its slot.
34
+ *
35
+ * A worker that exits before replying rejects with a `WorkerExitedError`:
36
+ * the dispatcher then leaves the lease to expire, because the worker's last
37
+ * writes may still be landing. Every other rejection is a retry reply, and
38
+ * the dispatcher releases the lease.
39
+ */
40
+ deliver(event: WorkflowEvent, options: DeliveryOptions): Promise<WorkflowRunOutcome>;
7
41
  };
42
+
43
+ const WORKER_EXITED = Symbol.for("yieldstar.workerExited");
44
+
45
+ /**
46
+ * Rejects a delivery whose worker exited, or was killed, before replying.
47
+ * The dispatcher does not release the lease: it expires, and the execution is
48
+ * delivered again after the redelivery delay.
49
+ */
50
+ export class WorkerExitedError extends Error {
51
+ readonly [WORKER_EXITED] = true;
52
+
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = "WorkerExitedError";
56
+ }
57
+
58
+ static is(value: unknown): value is WorkerExitedError {
59
+ return (
60
+ typeof value === "object" &&
61
+ value !== null &&
62
+ (value as Record<symbol, unknown>)[WORKER_EXITED] === true
63
+ );
64
+ }
65
+ }
@@ -1,8 +1,29 @@
1
- import type { Logger } from "pino";
2
- import type { WorkflowResult } from "./step";
3
- import type { WorkflowEvent } from "./event";
1
+ import type { EffectClient } from "./effect";
2
+ import type { ExecutionClient } from "./execution";
3
+ import type { HeapClient } from "./heap";
4
+ import type { StoreClient } from "./store";
4
5
 
5
- export type EventProcessor = (
6
- event: WorkflowEvent,
7
- logger: Logger
8
- ) => Promise<void | WorkflowResult<any>>;
6
+ export interface RuntimeOptions {
7
+ /** Every client of the runtime reads time from this clock. Defaults to `Date.now`. */
8
+ clock?: () => number;
9
+ }
10
+
11
+ /**
12
+ * A runtime is defined by the guarantees its clients keep, not by how many
13
+ * databases it uses:
14
+ *
15
+ * - Where one write must accompany another, such as the wakes for a store
16
+ * commit, the second is durable once the first commits and is applied
17
+ * eventually (in one transaction or through an outbox).
18
+ * - A store or effect waiter is deleted atomically with its step's result.
19
+ * - An effect's settlement and the wakes of its waiters are one atomic write.
20
+ * - Every client reads time from the runtime's clock.
21
+ */
22
+ export abstract class Runtime {
23
+ /** The clock every client, and every step's wake time, reads. */
24
+ abstract readonly clock: () => number;
25
+ abstract readonly executions: ExecutionClient;
26
+ abstract readonly effects: EffectClient;
27
+ abstract readonly heap: HeapClient;
28
+ abstract readonly stores: StoreClient;
29
+ }
package/src/base/step.ts CHANGED
@@ -22,13 +22,18 @@ export class StepInvalid extends StepResponse {
22
22
  readonly type = "step-invalid";
23
23
  }
24
24
 
25
+ export type StepSchemaVerdict = "accepted" | "rejected";
26
+
25
27
  export class StepResult extends StepResponse {
26
28
  static type = "step-result";
27
29
  readonly type = "step-result";
28
30
  result: any;
29
- constructor(result: any) {
31
+ /** Whether the step's schema accepted its value on the first run. */
32
+ schema?: StepSchemaVerdict;
33
+ constructor(result: any, schema?: StepSchemaVerdict) {
30
34
  super();
31
35
  this.result = result;
36
+ if (schema !== undefined) this.schema = schema;
32
37
  }
33
38
  }
34
39
 
@@ -45,10 +50,34 @@ export class WorkflowResult<T extends any> extends StepResponse {
45
50
  export class WorkflowDelay extends StepResponse {
46
51
  static type = "workflow-delay";
47
52
  readonly type = "workflow-delay";
48
- resumeIn: number;
49
- constructor(resumeIn: number) {
53
+ /** The absolute time the step's pause ends, read from the runtime clock. */
54
+ wakeAt: number;
55
+ constructor(wakeAt: number) {
56
+ super();
57
+ this.wakeAt = wakeAt;
58
+ }
59
+ }
60
+
61
+ export class StepStoreWait extends StepResponse {
62
+ static type = "store-wait";
63
+ readonly type = "store-wait";
64
+ constructor() {
65
+ super();
66
+ }
67
+ }
68
+
69
+ /**
70
+ * An effect step whose outcome is uncertain. The driver records it as an
71
+ * unfinished step after watching the effect, so the next delivery begins the
72
+ * effect again; `wakeAt` is when that delivery is due.
73
+ */
74
+ export class StepEffectWait extends StepResponse {
75
+ static type = "effect-wait";
76
+ readonly type = "effect-wait";
77
+ wakeAt: number;
78
+ constructor(wakeAt: number) {
50
79
  super();
51
- this.resumeIn = resumeIn;
80
+ this.wakeAt = wakeAt;
52
81
  }
53
82
  }
54
83
 
@@ -88,9 +117,10 @@ export class StepError extends StepResponse {
88
117
  export class StepDelay extends StepResponse {
89
118
  static type = "step-delay";
90
119
  readonly type = "step-delay";
91
- resumeIn: number;
92
- constructor(resumeIn: number) {
120
+ /** The absolute time the step's pause ends, read from the runtime clock. */
121
+ wakeAt: number;
122
+ constructor(wakeAt: number) {
93
123
  super();
94
- this.resumeIn = resumeIn;
124
+ this.wakeAt = wakeAt;
95
125
  }
96
126
  }
@@ -0,0 +1,176 @@
1
+ import { expect, test } from "vitest";
2
+ import {
3
+ cloneStoreState,
4
+ storePathsIntersect,
5
+ trackStoreSelector,
6
+ trackStoreUpdater,
7
+ unwrapTrackedValue,
8
+ type StorePath,
9
+ } from "./store";
10
+
11
+ type State = {
12
+ messages: { id: string; processed?: boolean; claimedBy?: string }[];
13
+ status: string;
14
+ profile: { name: string; tags: Record<string, string> };
15
+ };
16
+
17
+ function makeState(): State {
18
+ return {
19
+ messages: [
20
+ { id: "msg-1" },
21
+ { id: "msg-2" },
22
+ { id: "msg-3" },
23
+ { id: "msg-4" },
24
+ ],
25
+ status: "idle",
26
+ profile: { name: "dan", tags: {} },
27
+ };
28
+ }
29
+
30
+ function wakes(readPath: StorePath, writePaths: StorePath[]): boolean {
31
+ return storePathsIntersect([readPath], writePaths);
32
+ }
33
+
34
+ test("trackStoreUpdater: push onto an array records index paths that wake array waiters", () => {
35
+ const draft = makeState();
36
+ const tracked = trackStoreUpdater(draft);
37
+
38
+ tracked.draft.messages.push({ id: "msg-5" });
39
+
40
+ const paths = tracked.writePaths();
41
+ expect(paths).toContainEqual(["messages", 4]);
42
+ // A waiter on ["messages"] wakes via prefix intersection
43
+ expect(wakes(["messages"], paths)).toBe(true);
44
+ // The mutation landed on the raw draft
45
+ expect(draft.messages).toHaveLength(5);
46
+ expect(draft.messages[4]).toEqual({ id: "msg-5" });
47
+ });
48
+
49
+ test("trackStoreUpdater: splice and shift record the shifted indices", () => {
50
+ const draft = makeState();
51
+ const tracked = trackStoreUpdater(draft);
52
+
53
+ tracked.draft.messages.splice(1, 1);
54
+
55
+ const paths = tracked.writePaths();
56
+ // Elements shift down: indices 1..2 rewritten, index 3 deleted
57
+ expect(wakes(["messages", 1], paths)).toBe(true);
58
+ expect(wakes(["messages", 2], paths)).toBe(true);
59
+ expect(wakes(["messages", 3], paths)).toBe(true);
60
+ expect(draft.messages.map((m) => m.id)).toEqual(["msg-1", "msg-3", "msg-4"]);
61
+
62
+ const shifted = trackStoreUpdater(makeState());
63
+ shifted.draft.messages.shift();
64
+ const shiftPaths = shifted.writePaths();
65
+ expect(wakes(["messages", 0], shiftPaths)).toBe(true);
66
+ expect(wakes(["messages", 2], shiftPaths)).toBe(true);
67
+ });
68
+
69
+ test("trackStoreUpdater: nested field set records the full path", () => {
70
+ const draft = makeState();
71
+ const tracked = trackStoreUpdater(draft);
72
+
73
+ tracked.draft.profile.name = "grant";
74
+ tracked.draft.messages[0]!.processed = true;
75
+
76
+ const paths = tracked.writePaths();
77
+ expect(paths).toContainEqual(["profile", "name"]);
78
+ expect(paths).toContainEqual(["messages", 0, "processed"]);
79
+ // Unrelated paths do not intersect
80
+ expect(wakes(["status"], paths)).toBe(false);
81
+ expect(draft.profile.name).toBe("grant");
82
+ expect(draft.messages[0]!.processed).toBe(true);
83
+ });
84
+
85
+ test("trackStoreUpdater: Object.assign onto a nested object records each assigned key", () => {
86
+ const draft = makeState();
87
+ const tracked = trackStoreUpdater(draft);
88
+
89
+ Object.assign(tracked.draft.profile.tags, { a: "1", b: "2" });
90
+
91
+ const paths = tracked.writePaths();
92
+ expect(paths).toContainEqual(["profile", "tags", "a"]);
93
+ expect(paths).toContainEqual(["profile", "tags", "b"]);
94
+ expect(draft.profile.tags).toEqual({ a: "1", b: "2" });
95
+ });
96
+
97
+ test("trackStoreUpdater: delete records the deleted path", () => {
98
+ const draft = makeState();
99
+ draft.profile.tags = { a: "1" };
100
+ const tracked = trackStoreUpdater(draft);
101
+
102
+ delete tracked.draft.profile.tags["a"];
103
+
104
+ expect(tracked.writePaths()).toContainEqual(["profile", "tags", "a"]);
105
+ expect(draft.profile.tags).toEqual({});
106
+ });
107
+
108
+ test("trackStoreUpdater: defineProperty records conservatively", () => {
109
+ const draft = makeState();
110
+ const tracked = trackStoreUpdater(draft);
111
+
112
+ Object.defineProperty(tracked.draft.profile, "name", {
113
+ value: "defined",
114
+ writable: true,
115
+ enumerable: true,
116
+ configurable: true,
117
+ });
118
+
119
+ expect(tracked.writePaths()).toContainEqual(["profile", "name"]);
120
+ expect(draft.profile.name).toBe("defined");
121
+ });
122
+
123
+ test("trackStoreUpdater: duplicate write paths are collapsed", () => {
124
+ const draft = makeState();
125
+ const tracked = trackStoreUpdater(draft);
126
+
127
+ tracked.draft.status = "working";
128
+ tracked.draft.status = "idle";
129
+ tracked.draft.status = "working";
130
+
131
+ expect(
132
+ tracked.writePaths().filter((p) => p.join(".") === "status")
133
+ ).toHaveLength(1);
134
+ });
135
+
136
+ test("trackStoreUpdater: values assigned through the proxy are unwrapped onto the raw draft", () => {
137
+ const draft = makeState();
138
+ const tracked = trackStoreUpdater(draft);
139
+
140
+ // Read a child (returns a proxy) and store it elsewhere in the draft –
141
+ // the raw draft must receive the raw object, not the proxy wrapper.
142
+ const first = tracked.draft.messages[0]!;
143
+ (tracked.draft as any).lastMessage = first;
144
+
145
+ const stored = (draft as any).lastMessage;
146
+ expect(stored).toBe(draft.messages[0]);
147
+ expect(unwrapTrackedValue(stored)).toBe(stored);
148
+ // Proxy-free: structuredClone rejects proxies, so this must not throw
149
+ expect(() => structuredClone(draft)).not.toThrow();
150
+ });
151
+
152
+ test("trackStoreUpdater: mutations through a value selected via trackStoreSelector are recorded", () => {
153
+ const draft = makeState();
154
+ const tracked = trackStoreUpdater(draft);
155
+
156
+ const { result } = trackStoreSelector(tracked.draft, (s) =>
157
+ s.messages.find((m) => !m.claimedBy)
158
+ );
159
+ const selected = unwrapTrackedValue(result)!;
160
+ selected.claimedBy = "exec-1";
161
+
162
+ expect(tracked.writePaths()).toContainEqual(["messages", 0, "claimedBy"]);
163
+ expect(draft.messages[0]!.claimedBy).toBe("exec-1");
164
+ });
165
+
166
+ test("cloneStoreState launders proxy wrappers into plain objects", () => {
167
+ const tracked = trackStoreUpdater(makeState());
168
+ // A fresh object holding a nested proxy (the shallow set-unwrap cannot
169
+ // reach it) – commit-time cloning must still produce a proxy-free state.
170
+ const dirty = { wrapper: { inner: tracked.draft.messages } };
171
+
172
+ const clean = cloneStoreState(dirty);
173
+
174
+ expect(() => structuredClone(clean)).not.toThrow();
175
+ expect(clean.wrapper.inner).toEqual(makeState().messages);
176
+ });