lightflow-engine 0.2.10 → 0.2.12

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/BENCHMARKS.md CHANGED
@@ -156,6 +156,10 @@ same Postgres (16, local) and the same harness (200 runs x 5 steps @ conc 20;
156
156
  | 0.2.4 | 456.1 | 2,280 | 8,280 |
157
157
  | 0.2.5 | 459.1 | 2,295 | 9,068 |
158
158
  | 0.2.6 | 494.9–525.1 | 2,474–2,626 | 8,379–9,737 |
159
+ | 0.2.7 | 416.0 | 2,080 | 9,180 |
160
+ | 0.2.8 | 455.2 | 2,276 | 8,695 |
161
+ | 0.2.9 | 474.1 | 2,371 | 9,008 |
162
+ | 0.2.10 | 455.3 | 2,277 | 9,368 |
159
163
 
160
164
  ### What changed per version (all perf-neutral by design)
161
165
 
@@ -178,6 +182,14 @@ same Postgres (16, local) and the same harness (200 runs x 5 steps @ conc 20;
178
182
  resolved the package root, but the types were broken).
179
183
  - **0.2.6** — `export { workflowFetch as fetch }` (entry imports both
180
184
  names), `WorkflowFn` accepts typed args, `run.exists: true`.
185
+ - **0.2.7** — lazy `returnValue` on run handles: no unhandled rejection
186
+ when a status-only handle outlives a failed run. Added
187
+ `test/compat-runtime.ts` (makeStep args+retries, FatalError, ghost runs).
188
+ - **0.2.8–0.2.10** — cross-bundle runtime state for Next.js: engine/store,
189
+ workflow/step registries, and the execution context moved to globalThis
190
+ because instrumentation and route handlers load as separate module
191
+ instances. Found by a live end-to-end Entry chat run; zero measurable
192
+ cost (globalThis lookup vs module global).
181
193
 
182
194
  ### Spread notes
183
195
 
@@ -40,6 +40,8 @@ function makeRunHandle(runId) {
40
40
  return "pending";
41
41
  if (row.status === "completed")
42
42
  return "completed";
43
+ if (row.status === "cancelled")
44
+ return "cancelled";
43
45
  if (row.status === "failed") {
44
46
  return row.output?.error === "cancelled"
45
47
  ? "cancelled"
@@ -51,10 +51,14 @@ export function getWritable() {
51
51
  const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
52
52
  if (already)
53
53
  return;
54
+ // Monotonic chunk index — using ctx.writes here could collide with a
55
+ // real chunk's index and the unique-index no-op would silently drop
56
+ // the done marker (stream never terminates).
57
+ const index = await ctx.store.nextChunkIndex(ctx.runId);
54
58
  ctx.seq += 1;
55
59
  ctx.append({
56
60
  runId: ctx.runId, seq: ctx.seq, type: "chunk",
57
- payload: { value: null, done: true, key: "close", index: ctx.writes },
61
+ payload: { value: null, done: true, key: "close", index },
58
62
  createdAt: ctx.now(),
59
63
  });
60
64
  },
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ export declare class FatalError extends Error {
19
19
  readonly fatal = true;
20
20
  constructor(message: string);
21
21
  }
22
- export type RunStatus = "running" | "completed" | "failed";
22
+ export type RunStatus = "running" | "completed" | "failed" | "cancelled";
23
23
  export type StepEvent = {
24
24
  runId: string;
25
25
  seq: number;
package/dist/index.js CHANGED
@@ -48,19 +48,20 @@ export function getCurrent() {
48
48
  function setCurrent(v) {
49
49
  globalThis.__lightflowCurrent = v;
50
50
  }
51
- // Rewrite plain assignments on `current` below to setCurrent(...)
52
51
  export function getWorkflowMetadata() {
53
- if (!getCurrent())
52
+ const ctx = getCurrent();
53
+ if (!ctx)
54
54
  throw new Error("getWorkflowMetadata() outside a workflow");
55
- return { runId: getCurrent().runId };
55
+ return { runId: ctx.runId };
56
56
  }
57
57
  /* ------------------------------------------------------------------ */
58
58
  /* The step id: derived from call order, so replay is deterministic */
59
59
  /* ------------------------------------------------------------------ */
60
60
  function nextStepKey() {
61
- if (!getCurrent())
61
+ const ctx = getCurrent();
62
+ if (!ctx)
62
63
  throw new Error("steps can only be called inside a workflow");
63
- return `step:${getCurrent().stepCalls}`;
64
+ return `step:${ctx.stepCalls}`;
64
65
  }
65
66
  /* ------------------------------------------------------------------ */
66
67
  /* Public API inside a workflow */
@@ -70,9 +71,9 @@ function nextStepKey() {
70
71
  * the function body is NOT re-executed.
71
72
  */
72
73
  export async function step(fn) {
73
- if (!getCurrent())
74
- return fn(); // plain call outside a workflow
75
74
  const ctx = getCurrent();
75
+ if (!ctx)
76
+ return fn(); // plain call outside a workflow
76
77
  const key = nextStepKey();
77
78
  ctx.stepCalls += 1;
78
79
  // Replay: memoized result for this call position? (O(1) map lookup)
@@ -163,11 +164,11 @@ async function maybeSnapshot(ctx) {
163
164
  }
164
165
  /** Durable sleep. Accepts milliseconds or an absolute Date. */
165
166
  export async function sleep(until) {
166
- if (!getCurrent()) {
167
+ const ctx = getCurrent();
168
+ if (!ctx) {
167
169
  const ms = until instanceof Date ? until.getTime() - Date.now() : until;
168
170
  return new Promise((r) => setTimeout(r, Math.max(0, ms)));
169
171
  }
170
- const ctx = getCurrent();
171
172
  const wakeAt = until instanceof Date ? until.getTime() : ctx.now() + until;
172
173
  const key = `sleep:${ctx.sleepCalls}`;
173
174
  ctx.sleepCalls += 1;
@@ -193,7 +194,7 @@ export class SuspendSignal {
193
194
  /** Ordered output stream for a run. Chunks are persisted and replayable. */
194
195
  export function getWritable() {
195
196
  const ctx = getCurrent();
196
- if (!getCurrent())
197
+ if (!ctx)
197
198
  throw new Error("getWritable() outside a workflow");
198
199
  return {
199
200
  async write(chunk) {
@@ -273,8 +274,7 @@ export class Engine {
273
274
  getReadable: () => this.readable(runId),
274
275
  /** Entry calls this to kill a duplicate stream (route.ts:172). */
275
276
  cancel: async () => {
276
- await this.store.cancel?.(runId);
277
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
277
+ await this.store.cancel?.(runId); // terminal: cancelled=true + status='cancelled'
278
278
  },
279
279
  };
280
280
  }
@@ -333,7 +333,7 @@ export class Engine {
333
333
  if (claim && !claim.ok)
334
334
  return;
335
335
  if (claim?.cancelled) {
336
- await this.store.setStatus(runId, "failed", { error: "cancelled" });
336
+ await this.store.setStatus(runId, "cancelled", { error: "cancelled" });
337
337
  return;
338
338
  }
339
339
  const log = await this.store.getEvents(runId);
@@ -401,6 +401,11 @@ export class Engine {
401
401
  };
402
402
  if (err instanceof CancelledError)
403
403
  return fail("cancelled");
404
+ if (await this.store.isCancelled?.(runId)) {
405
+ // A concurrent cancel() already wrote the terminal status — don't
406
+ // resurrect it as 'failed'.
407
+ return;
408
+ }
404
409
  if (err instanceof FatalError)
405
410
  return fail(err.message);
406
411
  return fail(err instanceof Error ? err.message : String(err));
@@ -533,21 +538,23 @@ export class CancelledError extends Error {
533
538
  export function defineHook() {
534
539
  return {
535
540
  async create() {
536
- if (!getCurrent())
541
+ const ctx = getCurrent();
542
+ if (!ctx)
537
543
  throw new Error("hooks only inside a workflow");
538
544
  const token = `hook_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
539
- const key = `hook:${getCurrent().hookCalls}`;
540
- getCurrent().hookCalls += 1;
541
- await getCurrent().store.createHook?.(getCurrent().runId, token, key);
545
+ const key = `hook:${ctx.hookCalls}`;
546
+ ctx.hookCalls += 1;
547
+ await ctx.store.createHook?.(ctx.runId, token, key);
542
548
  return { token };
543
549
  },
544
550
  };
545
551
  }
546
552
  /** Await a previously created hook until an external caller resolves it. */
547
553
  export async function hookResult(token) {
548
- if (!getCurrent())
554
+ const ctx = getCurrent();
555
+ if (!ctx)
549
556
  throw new Error("hooks only inside a workflow");
550
- const existing = await getCurrent().store.getHook?.(token);
557
+ const existing = await ctx.store.getHook?.(token);
551
558
  if (existing && existing.payload?.resolved) {
552
559
  return existing.payload.value;
553
560
  }
package/dist/pg-store.js CHANGED
@@ -63,6 +63,10 @@ export async function createPostgresStore(url, opts = {}) {
63
63
  resolved BOOLEAN NOT NULL DEFAULT false,
64
64
  created_at BIGINT NOT NULL
65
65
  );
66
+ `);
67
+ // Stream chunk counter: O(1) nextChunkIndex without scanning all chunks.
68
+ await pool.query(`
69
+ ALTER TABLE lightflow_runs ADD COLUMN IF NOT EXISTS chunk_count INTEGER NOT NULL DEFAULT 0;
66
70
  `);
67
71
  // Durable timers: expression index matching the dueTimers query exactly
68
72
  // (bigint cast on the payload value, partial on pending timers).
@@ -228,9 +232,11 @@ export async function createPostgresStore(url, opts = {}) {
228
232
  },
229
233
  /** Terminal status + lease release in one round trip. */
230
234
  async finishRun(runId, status, output) {
235
+ // A cancelled run is terminal: the cancel path already wrote the final
236
+ // status, and a still-running replay must not resurrect it.
231
237
  await pool.query(`UPDATE lightflow_runs
232
238
  SET status=$2, output=$3, claimed_until=0, updated_at=$4
233
- WHERE run_id=$1`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
239
+ WHERE run_id=$1 AND cancelled=false`, [runId, status, output === undefined ? null : JSON.stringify(output), Date.now()]);
234
240
  },
235
241
  /** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
236
242
  async notifyWake() {
@@ -252,17 +258,25 @@ export async function createPostgresStore(url, opts = {}) {
252
258
  };
253
259
  },
254
260
  async nextChunkIndex(runId) {
255
- const r = await pool.query(`SELECT COALESCE(max((payload->>'index')::int),-1)+1 AS n
256
- FROM lightflow_events WHERE run_id=$1 AND type='chunk'`, [runId]);
261
+ // Atomic fetch-and-increment on the run row: O(1), race-free across
262
+ // replays (the chunk-key unique index still guards duplicates).
263
+ const r = await pool.query(`UPDATE lightflow_runs SET chunk_count=chunk_count+1, updated_at=$2
264
+ WHERE run_id=$1 RETURNING chunk_count-1 AS n`, [runId, Date.now()]);
257
265
  return Number(r.rows[0].n);
258
266
  },
259
267
  async staleRuns(cutoff) {
268
+ // Only runs whose lease has EXPIRED are orphaned. A run inside a long
269
+ // (>staleMs) step still holds a live lease and must not be double-run.
260
270
  const r = await pool.query(`SELECT run_id FROM lightflow_runs
261
- WHERE status='running' AND updated_at < $1`, [cutoff]);
271
+ WHERE status='running' AND updated_at < $1 AND claimed_until < $2`, [cutoff, Date.now()]);
262
272
  return r.rows.map((x) => x.run_id);
263
273
  },
264
274
  async cancel(runId) {
265
- await pool.query(`UPDATE lightflow_runs SET cancelled=true, updated_at=$2 WHERE run_id=$1`, [runId, Date.now()]);
275
+ // Terminal in one statement: flag + final status together, so a racing
276
+ // replay can never observe cancel-flag-without-terminal-status.
277
+ await pool.query(`UPDATE lightflow_runs
278
+ SET cancelled=true, status='cancelled', output='{"error":"cancelled"}', claimed_until=0, updated_at=$2
279
+ WHERE run_id=$1`, [runId, Date.now()]);
266
280
  },
267
281
  async isCancelled(runId) {
268
282
  const r = await pool.query(`SELECT cancelled FROM lightflow_runs WHERE run_id=$1`, [runId]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightflow-engine",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "A tiny durable workflow engine for Node.js and Postgres — with a Vercel Workflow drop-in compat layer.",
5
5
  "license": "MIT",
6
6
  "type": "module",