@pylonsync/functions 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workflows.ts CHANGED
@@ -53,7 +53,7 @@ export interface WorkflowRunRequest {
53
53
  export type WorkflowRunnerResponse =
54
54
  | { action: "step_complete"; step_name: string; output: unknown; duration_ms: number }
55
55
  | { action: "sleep"; duration: string }
56
- | { action: "wait_event"; event: string }
56
+ | { action: "wait_event"; event: string; timeout?: string }
57
57
  | { action: "complete"; output: unknown }
58
58
  | { action: "fail"; error: string; step_name?: string };
59
59
 
@@ -72,12 +72,43 @@ export interface WorkflowRun<TInput = unknown> {
72
72
  /** Pause the workflow for a duration ("30s", "5m", "24h", "7d"). */
73
73
  sleep(duration: string): Promise<void>;
74
74
  /**
75
- * Pause until `POST /api/workflows/<id>/event` delivers this event.
76
- * Resolves with the event's data payload.
75
+ * Pause until the event arrives through `ctx.workflows.sendEvent` or
76
+ * `POST /api/workflows/<id>/event`. Resolves with the event's data
77
+ * (`{}` when it was sent without data).
78
+ *
79
+ * Events sent while the run is not waiting for them are buffered. The
80
+ * next `waitForEvent` with that name consumes the oldest buffered
81
+ * event, so an event that arrives during a step is not lost.
82
+ *
83
+ * The same event name can be awaited more than once in a run; each
84
+ * call consumes one event.
77
85
  */
78
86
  waitForEvent<T = unknown>(eventName: string): Promise<T>;
87
+ /**
88
+ * Same as above, but resolves with `null` if no event arrives within
89
+ * `timeout` ("60s", "5m", "24h", "7d"). The timeout is durable: it
90
+ * survives restarts, like `sleep`.
91
+ */
92
+ waitForEvent<T = unknown>(
93
+ eventName: string,
94
+ opts: { timeout: string },
95
+ ): Promise<T | null>;
96
+ }
97
+
98
+ const DURATION_RE = /^\s*\d+\s*[smhd]?\s*$/;
99
+
100
+ /** Throw unless `value` is a duration the engine accepts. */
101
+ function assertDuration(what: string, value: unknown): asserts value is string {
102
+ if (typeof value !== "string" || !DURATION_RE.test(value)) {
103
+ throw new Error(
104
+ `${what}: invalid duration ${JSON.stringify(value)} — use a string like "30s", "5m", "24h", or "7d"`,
105
+ );
106
+ }
79
107
  }
80
108
 
109
+ /** Step names the engine uses for recorded event waits. */
110
+ const RESERVED_STEP_PREFIXES = ["event:", "timeout:"];
111
+
81
112
  export interface WorkflowDefinition<TInput = unknown> {
82
113
  readonly __pylonWorkflow: true;
83
114
  name: string;
@@ -159,12 +190,13 @@ export async function executeWorkflowSlice(
159
190
  ): Promise<WorkflowRunnerResponse> {
160
191
  let index = 0;
161
192
  let currentStepName = "";
162
- // Name uniqueness is enforced, not just documented: the replay cache
163
- // is name-keyed, so a duplicate step name would silently replay the
164
- // FIRST record's output into the second call — wrong data in a
165
- // durability primitive. Same for repeated waitForEvent names.
193
+ // Step name uniqueness is enforced, not just documented: the replay
194
+ // cache is name-keyed, so a duplicate step name would silently replay
195
+ // the FIRST record's output into the second call. Event waits replay
196
+ // by order instead, so one event name can be awaited many times.
166
197
  const seenNames = new Set<string>();
167
- const claimName = (kind: "step" | "event", name: string) => {
198
+ const eventOccurrences = new Map<string, number>();
199
+ const claimName = (kind: "step", name: string) => {
168
200
  const key = `${kind}:${name}`;
169
201
  if (seenNames.has(key)) {
170
202
  throw new Error(
@@ -179,6 +211,11 @@ export async function executeWorkflowSlice(
179
211
  input: request.input,
180
212
 
181
213
  async step<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
214
+ if (RESERVED_STEP_PREFIXES.some((p) => name.startsWith(p))) {
215
+ throw new Error(
216
+ `step name "${name}" is reserved — names starting with "event:" or "timeout:" record event waits`,
217
+ );
218
+ }
182
219
  claimName("step", name);
183
220
  const myIndex = index++;
184
221
  if (myIndex < request.current_step) {
@@ -215,25 +252,50 @@ export async function executeWorkflowSlice(
215
252
  },
216
253
 
217
254
  async sleep(duration: string): Promise<void> {
255
+ assertDuration("wf.sleep", duration);
218
256
  const myIndex = index++;
219
257
  if (myIndex < request.current_step) return; // already slept
220
258
  throw new WorkflowPaused({ action: "sleep", duration });
221
259
  },
222
260
 
223
- async waitForEvent<T>(eventName: string): Promise<T> {
224
- claimName("event", eventName);
261
+ async waitForEvent<T>(
262
+ eventName: string,
263
+ opts?: { timeout: string },
264
+ ): Promise<T | null> {
265
+ if (typeof eventName !== "string" || eventName.length === 0) {
266
+ throw new Error("wf.waitForEvent: event name must be a non-empty string");
267
+ }
268
+ const timeout = opts?.timeout;
269
+ if (timeout !== undefined) assertDuration("wf.waitForEvent timeout", timeout);
225
270
  const myIndex = index++;
226
- // A delivered event is recorded as a completed step named
227
- // `event:<name>` (the Rust engine's send_event writes it).
228
- const delivered = request.completed_steps.find(
229
- (s) => s.name === `event:${eventName}` && s.status === "completed",
230
- );
231
- if (myIndex < request.current_step && delivered) {
232
- return delivered.output as T;
271
+ // The engine records each resolved wait as a completed step named
272
+ // `event:<name>` (with the event data) or `timeout:<name>` (no
273
+ // output). The k-th wait on a name replays the k-th record for it.
274
+ const occurrence = eventOccurrences.get(eventName) ?? 0;
275
+ eventOccurrences.set(eventName, occurrence + 1);
276
+ if (myIndex < request.current_step) {
277
+ const records = request.completed_steps.filter(
278
+ (s) =>
279
+ s.status === "completed" &&
280
+ (s.name === `event:${eventName}` || s.name === `timeout:${eventName}`),
281
+ );
282
+ const record = records[occurrence];
283
+ if (!record) {
284
+ throw new Error(
285
+ `workflow replay mismatch: waitForEvent("${eventName}") #${occurrence + 1} (index ${myIndex}) has no recorded result — ` +
286
+ "the step sequence must be deterministic across replays",
287
+ );
288
+ }
289
+ if (record.name.startsWith("timeout:")) return null;
290
+ return record.output as T;
233
291
  }
234
- throw new WorkflowPaused({ action: "wait_event", event: eventName });
292
+ throw new WorkflowPaused({
293
+ action: "wait_event",
294
+ event: eventName,
295
+ ...(timeout !== undefined ? { timeout } : {}),
296
+ });
235
297
  },
236
- };
298
+ } as WorkflowRun;
237
299
 
238
300
  try {
239
301
  const output = await def.fn(wf, ctx);