lightflow-engine 0.1.3 → 0.2.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/BENCHMARKS.md +158 -0
- package/README.md +21 -0
- package/dist/src/compat/api.js +157 -0
- package/dist/src/compat/fetch.js +16 -0
- package/dist/src/compat/metadata.js +9 -0
- package/dist/src/compat/next.js +19 -0
- package/dist/src/compat/workflow.js +71 -0
- package/dist/src/index.js +37 -18
- package/dist/src/pg-store.js +19 -3
- package/dist/test/compat.js +86 -0
- package/dist/test/dbg.mjs +28 -0
- package/dist/test/dbg2.mjs +34 -0
- package/package.json +8 -4
package/BENCHMARKS.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# lightflow — Benchmarks
|
|
2
|
+
|
|
3
|
+
All numbers measured locally on the dev machine: Node 22, Postgres 16,
|
|
4
|
+
localhost socket (no network hop). Reproduce with:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm run build
|
|
8
|
+
export LIGHTFLOW_PG_URL=postgres://user:***@localhost:5432/lightflow
|
|
9
|
+
node dist/test/bench.js # short-workflow throughput
|
|
10
|
+
node dist/test/bench-long.mjs # long-workflow throughput (RN=<steps>)
|
|
11
|
+
node --test dist/test/race.js # concurrency correctness
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Benchmarks live in [`test/bench.ts`](test/bench.ts) and
|
|
15
|
+
[`test/bench-long.mjs`](test/bench-long.mjs). SQL-statement counts come from
|
|
16
|
+
`pg_stat_statements`.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## v0.1.0 — baseline
|
|
21
|
+
|
|
22
|
+
The starting point: advisory-locked, statement-per-concern persistence.
|
|
23
|
+
|
|
24
|
+
| Metric | Value |
|
|
25
|
+
|---|---|
|
|
26
|
+
| Runs / sec (200 runs × 5 steps, conc 20) | **184** |
|
|
27
|
+
| Steps / sec | **922** |
|
|
28
|
+
| SQL statements per event append | 5 (BEGIN, advisory lock, max(seq), INSERT, COMMIT) |
|
|
29
|
+
| Advisory lock avg time in hot path | **7.8 ms** (40× every other query) |
|
|
30
|
+
| `writable.write` | re-fetches full event log per write |
|
|
31
|
+
| Racing resumes | can double-execute a memoized step (latent) |
|
|
32
|
+
| `wakeAt` timer index | present but **silently unused** (text/bigint cast mismatch) |
|
|
33
|
+
| Worker timer wake | ~4 queries + full event-log fetch per due timer |
|
|
34
|
+
| Replay memo lookup | O(n) linear scan → O(n²) replay |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## v0.1.1 — event-append round
|
|
39
|
+
|
|
40
|
+
**Focus: per-event write cost.** Profiled with `pg_stat_statements`, then:
|
|
41
|
+
|
|
42
|
+
- `appendEvent` fast path: single `INSERT … ON CONFLICT DO NOTHING` (caller
|
|
43
|
+
supplies seq; the `(run_id, seq)` unique constraint arbitrates races).
|
|
44
|
+
Advisory lock only on rare collisions.
|
|
45
|
+
- **Run lease** (`claimed_until`): atomic claim/release around each replay —
|
|
46
|
+
concurrent `resume()` calls can no longer re-execute a memoized step.
|
|
47
|
+
- Dropped `step_started` events (replay never reads them).
|
|
48
|
+
- `writable.write` uses the in-memory replay log, not a re-fetch.
|
|
49
|
+
- `createRun`: single CTE round trip. `waitFor`: adaptive poll 5→250 ms.
|
|
50
|
+
|
|
51
|
+
| Metric | v0.1.0 | v0.1.1 | Δ |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| Runs / sec (200×5, conc 20) | 184 | **456–556** | **~2.7×** |
|
|
54
|
+
| Steps / sec | 922 | **~2,300–2,800** | **~2.7×** |
|
|
55
|
+
| SQL per event append | 5 | 1 | −80% |
|
|
56
|
+
| Advisory lock in hot path | every event | removed | — |
|
|
57
|
+
| Double-execution under 20 racing resumes | possible | **0 in 200 races** | fixed |
|
|
58
|
+
|
|
59
|
+
Sustained (500 runs × 10 steps, conc 100): **445 runs/s, 4,450 steps/s**.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## v0.1.2 — replay & timer round
|
|
64
|
+
|
|
65
|
+
**Focus: replay cost and timer dispatch.** Researched Temporal's "replay
|
|
66
|
+
debt", PgQue's notify pattern, and Absurd's minimal-query design, then:
|
|
67
|
+
|
|
68
|
+
- **O(1) replay memo**: event log indexed once into a `Map` at replay start.
|
|
69
|
+
- **Fixed the timer index**: expression index on `((payload->>'wakeAt')::bigint)`.
|
|
70
|
+
- **`dueTimers` anti-join** against `sleep_completed` (returns key too) —
|
|
71
|
+
worker completes a timer in 2 queries instead of ~4 + full-log fetch.
|
|
72
|
+
- **Adaptive worker poll** (50 ms → configured, backs off when idle) plus an
|
|
73
|
+
optional LISTEN/NOTIFY wakeup nudge (lossy; polling remains authoritative).
|
|
74
|
+
|
|
75
|
+
| Metric | v0.1.1 | v0.1.2 | Δ |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| Steps / sec, 400-step workflows (50 runs) | 8,230 | **9,070** | +10% |
|
|
78
|
+
| Runs / sec, 100-step workflows (50 runs) | — | 65 (6,470 steps/s) | new |
|
|
79
|
+
| Queries per timer wake | ~4 + full log fetch | 2 | −60%+ |
|
|
80
|
+
| Replay memo lookup | O(n) | O(1) | — |
|
|
81
|
+
| Worker idle poll floor | 500 ms fixed | 50 ms adaptive + notify | — |
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## v0.1.3 — compaction & client round
|
|
86
|
+
|
|
87
|
+
**Focus: replay-from-scratch cost, client overhead, polling waste.**
|
|
88
|
+
|
|
89
|
+
- **Snapshot compaction** (the Temporal `ContinueAsNew` problem, designed out):
|
|
90
|
+
every 200 completed steps, memoized state folds into a single `snapshot`
|
|
91
|
+
event; replay resumes from the latest snapshot instead of seq 0. A 700-step
|
|
92
|
+
run mid-way through sleeps and resumes correctly through snapshots.
|
|
93
|
+
- **pg pipelining** (`pg >= 8.23`, one option): queries batch per connection —
|
|
94
|
+
biggest wins under concurrency and on networked Postgres.
|
|
95
|
+
- **Zero-poll `returnValue`**: in-process callers await a deferred promise;
|
|
96
|
+
the DB is only polled by cross-process callers (adaptive 5→250 ms).
|
|
97
|
+
|
|
98
|
+
| Metric | v0.1.2 | v0.1.3 |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| Runs / sec, 500×10 @ conc 100 | — | **497** |
|
|
101
|
+
| Steps / sec, 400-step workflows | 9,070 | 8,956–10,658* |
|
|
102
|
+
| Steps / sec, 600-step workflows | — | 10,055 |
|
|
103
|
+
| `returnValue` DB polls (same process) | every 5–250 ms | **0** |
|
|
104
|
+
| Replay cost for an old run | O(total events) | O(events since snapshot) |
|
|
105
|
+
|
|
106
|
+
\* range across runs; the long-workflow number varies with how many
|
|
107
|
+
snapshots land before each resume.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## v0.1.4 — round-trip & fsync round
|
|
112
|
+
|
|
113
|
+
**Focus: query-count per run and the commit durability dial.**
|
|
114
|
+
|
|
115
|
+
- **Merged control round trips**: `claimRun` returns the cancelled flag with
|
|
116
|
+
the lease; new `finishRun` writes terminal status and releases the lease in
|
|
117
|
+
one UPDATE. Control queries per run: 6 → 4.
|
|
118
|
+
- **Pipelined step appends**: consecutive `step_completed` events share one
|
|
119
|
+
flush (buffered in `ctx.inflight`, awaited at suspension/terminal/snapshot
|
|
120
|
+
boundaries). Verified: 700-step sleep-resume path stays correct.
|
|
121
|
+
- **Opt-in async commit** (`sessionOptions: { synchronous_commit: 'off' }`):
|
|
122
|
+
documented tradeoff — the last few transactions may be lost on a server
|
|
123
|
+
crash (never corruption). Off by default; durability remains the default.
|
|
124
|
+
|
|
125
|
+
| Metric | v0.1.3 | v0.1.4 | Δ |
|
|
126
|
+
|---|---|---|---|
|
|
127
|
+
| Runs / sec (200×5, conc 20) | 437–480 | **502–512** | +8% |
|
|
128
|
+
| Runs / sec (500×10, conc 100) | 497 | **503–512** | +2% |
|
|
129
|
+
| Steps / sec (400-step) | 8,956 | **9,017–9,939** | +5% |
|
|
130
|
+
| Steps / sec (400-step, async commit opt-in) | — | **11,004** | +23% vs durable |
|
|
131
|
+
| Control queries per run | 6 | 4 | −33% |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Reproducing
|
|
136
|
+
|
|
137
|
+
`test/bench.ts` honours `BENCH_N` (runs), `BENCH_M` (steps per run),
|
|
138
|
+
`BENCH_CONC` (in-flight). `bench-long.mjs` honours `RN` (steps) and runs 50
|
|
139
|
+
sequential-ish workflows through `start()` + terminal-poll.
|
|
140
|
+
|
|
141
|
+
Every version's table above was produced on the same machine and database,
|
|
142
|
+
so cross-version rows are directly comparable.
|
|
143
|
+
|
|
144
|
+
## v0.2.0 — compat layer (perf-neutral)
|
|
145
|
+
|
|
146
|
+
Focus: Vercel Workflow drop-in compat (`lightflow-engine/compat/*`).
|
|
147
|
+
No perf regression vs v0.1.4 — two real bugs found and fixed along the way:
|
|
148
|
+
post-resume chunk loss (chunk keys now include the durable-timer position)
|
|
149
|
+
and chunks vanishing from streams after snapshot compaction (live chunks
|
|
150
|
+
now fold into snapshots).
|
|
151
|
+
|
|
152
|
+
| Scenario | v0.1.4 | v0.2.0 |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| 200 runs x 5 steps, conc 20 (runs/s) | 502–512 | 504.5 |
|
|
155
|
+
| 50 runs x 400 steps durable (steps/s) | 9,939 | 9,062–10,600 |
|
|
156
|
+
|
|
157
|
+
Verdict: perf-neutral within noise. Compat test (7/7) exercises the full
|
|
158
|
+
entry-agents surface end-to-end.
|
package/README.md
CHANGED
|
@@ -149,6 +149,27 @@ Kill-and-resume is a first-class path, not an edge case. The engine includes a
|
|
|
149
149
|
**reaper** that resumes runs wedged in `running` — a failure mode we hit in
|
|
150
150
|
other engines and designed out here.
|
|
151
151
|
|
|
152
|
+
## Vercel Workflow drop-in compat
|
|
153
|
+
|
|
154
|
+
lightflow ships a compatibility layer mirroring the exact Vercel Workflow
|
|
155
|
+
API surface (`start(fn, args)`, sync `getRun()`, `run.status` Promise,
|
|
156
|
+
`run.getReadable({ startIndex })` + `getTailIndex()`, `run.cancel()`,
|
|
157
|
+
`run.returnValue`, `getWritable()` as a web `WritableStream`,
|
|
158
|
+
`getWorkflowMetadata()`, `sleep(Date)`, `FatalError`, `withWorkflow`).
|
|
159
|
+
See [COMPAT.md](./COMPAT.md) for the 3-step swap guide and honest
|
|
160
|
+
differences list.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { createPostgresStore } from "lightflow-engine/pg";
|
|
164
|
+
import { Engine } from "lightflow-engine";
|
|
165
|
+
import { initWorkflowApi } from "lightflow-engine/compat/api";
|
|
166
|
+
|
|
167
|
+
const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL!);
|
|
168
|
+
const engine = new Engine(store, { pollMs: 50 });
|
|
169
|
+
await engine.startWorker();
|
|
170
|
+
initWorkflowApi(store, engine);
|
|
171
|
+
```
|
|
172
|
+
|
|
152
173
|
## Benchmarks
|
|
153
174
|
|
|
154
175
|
Full before/after numbers for every version live in [BENCHMARKS.md](BENCHMARKS.md).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Workflow compat — `workflow/api` module surface.
|
|
3
|
+
*
|
|
4
|
+
* Differences from our core Engine API:
|
|
5
|
+
* - start(fn, args): takes the workflow FUNCTION (not an id); returns the
|
|
6
|
+
* run handle directly (not a promise).
|
|
7
|
+
* - getRun(runId): returns the handle synchronously; `status` is a Promise;
|
|
8
|
+
* status includes "pending" and "cancelled".
|
|
9
|
+
* - getReadable({ startIndex }): chunk-indexed resumable stream with
|
|
10
|
+
* getTailIndex().
|
|
11
|
+
* - run.cancel(), run.returnValue.
|
|
12
|
+
*/
|
|
13
|
+
import { Engine, registerWorkflow } from "../index.js";
|
|
14
|
+
import { workflowIdFor } from "./workflow.js";
|
|
15
|
+
let sharedEngine = null;
|
|
16
|
+
let sharedStore = null;
|
|
17
|
+
/** Configure the compat layer with a store/engine (call once at boot). */
|
|
18
|
+
export function initWorkflowApi(store, engine) {
|
|
19
|
+
sharedStore = store;
|
|
20
|
+
sharedEngine = engine ?? new Engine(store);
|
|
21
|
+
}
|
|
22
|
+
export function getEngine() {
|
|
23
|
+
if (!sharedEngine) {
|
|
24
|
+
throw new Error("workflow/api not initialized — call initWorkflowApi(store) at startup");
|
|
25
|
+
}
|
|
26
|
+
return sharedEngine;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register a workflow function under a stable id and start it. Mirrors
|
|
30
|
+
* Vercel's start(workflowFn, args).
|
|
31
|
+
*/
|
|
32
|
+
export async function start(fn, args) {
|
|
33
|
+
const engine = getEngine();
|
|
34
|
+
const id = workflowIdFor(fn);
|
|
35
|
+
registerWorkflow(id, fn);
|
|
36
|
+
const { runId } = await engine.start(id, args);
|
|
37
|
+
return makeRunHandle(runId);
|
|
38
|
+
}
|
|
39
|
+
export function getRun(runId) {
|
|
40
|
+
return makeRunHandle(runId);
|
|
41
|
+
}
|
|
42
|
+
function makeRunHandle(runId) {
|
|
43
|
+
const engine = getEngine();
|
|
44
|
+
const store = sharedStore;
|
|
45
|
+
let cachedStatus = null;
|
|
46
|
+
const statusPromise = (async () => {
|
|
47
|
+
if (cachedStatus)
|
|
48
|
+
return cachedStatus;
|
|
49
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
50
|
+
let delay = 5;
|
|
51
|
+
let seenRow = false;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
const row = await store.getRun(runId);
|
|
54
|
+
if (row) {
|
|
55
|
+
seenRow = true;
|
|
56
|
+
if (row.status === "completed") {
|
|
57
|
+
cachedStatus = "completed";
|
|
58
|
+
return cachedStatus;
|
|
59
|
+
}
|
|
60
|
+
if (row.status === "failed") {
|
|
61
|
+
// cancelled runs are 'failed' with error 'cancelled'
|
|
62
|
+
cachedStatus =
|
|
63
|
+
row.output?.error === "cancelled"
|
|
64
|
+
? "cancelled" : "failed";
|
|
65
|
+
return cachedStatus;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
69
|
+
delay = seenRow ? Math.min(delay * 1.6, 250) : 5;
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`run status timeout: ${runId}`);
|
|
72
|
+
})();
|
|
73
|
+
const handle = {
|
|
74
|
+
runId,
|
|
75
|
+
status: statusPromise,
|
|
76
|
+
returnValue: (async () => (await engine.getRun(runId)).returnValue)(),
|
|
77
|
+
getReadable(opts) {
|
|
78
|
+
const startIndex = opts?.startIndex ?? 0;
|
|
79
|
+
let sent = startIndex;
|
|
80
|
+
let closed = false;
|
|
81
|
+
const collectChunks = async () => {
|
|
82
|
+
// Chunks can live in two places: the latest snapshot memo
|
|
83
|
+
// (compaction folds pre-snapshot chunks) and as raw events after
|
|
84
|
+
// it. Merge, dedupe by key, and order by chunk index.
|
|
85
|
+
const events = await store.getEvents(runId);
|
|
86
|
+
const byKey = new Map();
|
|
87
|
+
for (const e of events) {
|
|
88
|
+
if (e.type === "snapshot") {
|
|
89
|
+
const memo = e.payload.memo ?? {};
|
|
90
|
+
for (const [k, v] of Object.entries(memo)) {
|
|
91
|
+
if (!k.startsWith("chunk:"))
|
|
92
|
+
continue;
|
|
93
|
+
const p = v;
|
|
94
|
+
byKey.set(p.key, p);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (e.type === "chunk") {
|
|
98
|
+
const p = e.payload;
|
|
99
|
+
byKey.set(p.key, p);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...byKey.values()].sort((a, b) => a.index - b.index);
|
|
103
|
+
};
|
|
104
|
+
const stream = new ReadableStream({
|
|
105
|
+
async pull(controller) {
|
|
106
|
+
if (closed) {
|
|
107
|
+
controller.close();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const chunks = await collectChunks();
|
|
111
|
+
const hasDone = chunks.some((c) => c.done);
|
|
112
|
+
// chunks carry a monotonic `index`; entries after startIndex
|
|
113
|
+
for (const c of chunks) {
|
|
114
|
+
const p = c;
|
|
115
|
+
if (p.done) {
|
|
116
|
+
closed = true;
|
|
117
|
+
controller.close();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (p.index < startIndex)
|
|
121
|
+
continue;
|
|
122
|
+
if (p.index >= sent) {
|
|
123
|
+
controller.enqueue(p.value);
|
|
124
|
+
sent = p.index + 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (hasDone) {
|
|
128
|
+
closed = true;
|
|
129
|
+
controller.close();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const row = await store.getRun(runId);
|
|
133
|
+
if (row && row.status !== "running") {
|
|
134
|
+
closed = true;
|
|
135
|
+
controller.close();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
stream.getTailIndex = async () => {
|
|
140
|
+
const chunks = await collectChunks();
|
|
141
|
+
let max = -1;
|
|
142
|
+
for (const c of chunks) {
|
|
143
|
+
if (c.done)
|
|
144
|
+
return max;
|
|
145
|
+
if (typeof c.index === "number" && c.index > max)
|
|
146
|
+
max = c.index;
|
|
147
|
+
}
|
|
148
|
+
return max;
|
|
149
|
+
};
|
|
150
|
+
return stream;
|
|
151
|
+
},
|
|
152
|
+
async cancel() {
|
|
153
|
+
await (await engine.getRun(runId)).cancel();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
return handle;
|
|
157
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable fetch: memoized per step position, like any other side effect.
|
|
3
|
+
* Mirrors Vercel's workflow fetch(): Response-shaped result.
|
|
4
|
+
*/
|
|
5
|
+
import { step } from "../index.js";
|
|
6
|
+
export async function workflowFetch(input, init) {
|
|
7
|
+
return step(async () => {
|
|
8
|
+
const res = await fetch(input, init);
|
|
9
|
+
return {
|
|
10
|
+
__lfResponse: true,
|
|
11
|
+
status: res.status,
|
|
12
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
13
|
+
body: await res.text(),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Workflow compat: getWorkflowMetadata() returns { workflowRunId }.
|
|
3
|
+
*/
|
|
4
|
+
import { current } from "../index.js";
|
|
5
|
+
export function getWorkflowMetadata() {
|
|
6
|
+
if (!current)
|
|
7
|
+
throw new Error("getWorkflowMetadata() outside a workflow");
|
|
8
|
+
return { workflowRunId: current.runId };
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Workflow compat — `workflow/next` surface.
|
|
3
|
+
*
|
|
4
|
+
* Vercel's withWorkflow wraps the Next.js config to enable the directive
|
|
5
|
+
* compiler ("use workflow" / "use step"). lightflow's runtime does not need
|
|
6
|
+
* a bundler transform: directives are inert strings, workflow functions are
|
|
7
|
+
* registered explicitly by start(), and "use step" functions can be wrapped
|
|
8
|
+
* with step() from compat/workflow for durability.
|
|
9
|
+
*
|
|
10
|
+
* This drop-in is an identity wrapper so next.config.ts keeps working
|
|
11
|
+
* unchanged:
|
|
12
|
+
* import { withWorkflow } from "workflow/next";
|
|
13
|
+
* + import { withWorkflow } from "lightflow-engine/compat/next";
|
|
14
|
+
*/
|
|
15
|
+
export function withWorkflow(config) {
|
|
16
|
+
// Directives are inert at runtime; nothing to compile. Accepts and
|
|
17
|
+
// returns the config untouched so existing call sites compose the same.
|
|
18
|
+
return config;
|
|
19
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Workflow compatibility layer — `workflow` module surface.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in for entry-agents-style code: same named exports, same shapes.
|
|
5
|
+
* Directive notes:
|
|
6
|
+
* - "use workflow" / "use step" are inert strings at runtime. This layer
|
|
7
|
+
* makes directly-called "use step" functions durable via the companion
|
|
8
|
+
* webpack loader (workflow/next) or explicit step() wrappers.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { current } from "../index.js";
|
|
12
|
+
export { FatalError, CancelledError, sleep } from "../index.js";
|
|
13
|
+
export { getWorkflowMetadata } from "../compat/metadata.js";
|
|
14
|
+
export { workflowFetch } from "../compat/fetch.js";
|
|
15
|
+
/**
|
|
16
|
+
* Vercel's getWritable returns a web-standard WritableStream whose writes
|
|
17
|
+
* are durable chunk events. Ours maps 1:1: every writer.write(chunk) is a
|
|
18
|
+
* memoized chunk event keyed by call position; writer.close() emits the
|
|
19
|
+
* terminal done marker.
|
|
20
|
+
*/
|
|
21
|
+
export function getWritable() {
|
|
22
|
+
const ctx = current;
|
|
23
|
+
if (!ctx)
|
|
24
|
+
throw new Error("getWritable() outside a workflow");
|
|
25
|
+
const underlying = {
|
|
26
|
+
async write(chunk) {
|
|
27
|
+
// Mirror of engine getWritable().write but through ctx.append.
|
|
28
|
+
// Key includes the durable-timer position so a post-resume write
|
|
29
|
+
// can never collide with (and be skipped against) a pre-timer chunk.
|
|
30
|
+
const key = `w:${ctx.sleepCalls}:${ctx.writes}`;
|
|
31
|
+
const already = ctx.memo.has(`chunk:${key}`);
|
|
32
|
+
ctx.writes += 1;
|
|
33
|
+
if (already)
|
|
34
|
+
return;
|
|
35
|
+
const index = await ctx.store.nextChunkIndex(ctx.runId);
|
|
36
|
+
ctx.seq += 1;
|
|
37
|
+
const payload = { value: chunk, index, key };
|
|
38
|
+
ctx.append({
|
|
39
|
+
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
40
|
+
payload, createdAt: ctx.now(),
|
|
41
|
+
});
|
|
42
|
+
// Record in the live memo so snapshot compaction folds it.
|
|
43
|
+
ctx.memo.set(`chunk:${key}`, { runId: ctx.runId, seq: ctx.seq,
|
|
44
|
+
type: "chunk", payload, createdAt: ctx.now() });
|
|
45
|
+
},
|
|
46
|
+
async close() {
|
|
47
|
+
const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
48
|
+
if (already)
|
|
49
|
+
return;
|
|
50
|
+
ctx.seq += 1;
|
|
51
|
+
ctx.append({
|
|
52
|
+
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
53
|
+
payload: { value: null, done: true, key: "close", index: ctx.writes },
|
|
54
|
+
createdAt: ctx.now(),
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return new WritableStream({
|
|
59
|
+
async write(chunk) { await underlying.write(chunk); },
|
|
60
|
+
async close() { await underlying.close(); },
|
|
61
|
+
async abort() { },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Auto-registration id for a workflow function (stable per code site). */
|
|
65
|
+
export function workflowIdFor(fn) {
|
|
66
|
+
const named = fn.name;
|
|
67
|
+
if (named && named !== "anonymous")
|
|
68
|
+
return named;
|
|
69
|
+
return "wf_" + createHash("sha256")
|
|
70
|
+
.update(String(fn).slice(0, 512)).digest("hex").slice(0, 16);
|
|
71
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export function registerWorkflow(id, fn) {
|
|
|
34
34
|
export function registerStep(id, fn) {
|
|
35
35
|
steps.set(id, fn);
|
|
36
36
|
}
|
|
37
|
-
let current = null;
|
|
37
|
+
export let current = null;
|
|
38
38
|
export function getWorkflowMetadata() {
|
|
39
39
|
if (!current)
|
|
40
40
|
throw new Error("getWorkflowMetadata() outside a workflow");
|
|
@@ -104,7 +104,7 @@ export async function step(fn) {
|
|
|
104
104
|
}
|
|
105
105
|
ctx.seq += 1;
|
|
106
106
|
ctx.freshResults.set(key, value);
|
|
107
|
-
|
|
107
|
+
ctx.append({
|
|
108
108
|
runId: ctx.runId, seq: ctx.seq, type: "step_completed",
|
|
109
109
|
payload: { key, value }, createdAt: ctx.now(),
|
|
110
110
|
});
|
|
@@ -120,6 +120,7 @@ const SNAPSHOT_MIN_EVENTS = 100;
|
|
|
120
120
|
* costing replay time and can be pruned later without breaking resumes.
|
|
121
121
|
*/
|
|
122
122
|
async function maybeSnapshot(ctx) {
|
|
123
|
+
await Promise.all(ctx.inflight);
|
|
123
124
|
ctx.completedNow += 1;
|
|
124
125
|
const done = ctx.completedNow +
|
|
125
126
|
[...ctx.memo.values()].filter((e) => e.type === "step_completed").length;
|
|
@@ -180,13 +181,12 @@ export function getWritable() {
|
|
|
180
181
|
const ctx = current;
|
|
181
182
|
if (!current)
|
|
182
183
|
throw new Error("getWritable() outside a workflow");
|
|
183
|
-
/** Deterministic key: (durable-timer position, writes since it). */
|
|
184
|
-
const keyFor = () => `${ctx.sleepCalls}:${ctx.writes}`;
|
|
185
184
|
return {
|
|
186
185
|
async write(chunk) {
|
|
187
|
-
// Memoized by call position (
|
|
188
|
-
//
|
|
189
|
-
|
|
186
|
+
// Memoized by deterministic call position: (sleep position, writes
|
|
187
|
+
// since it). sleepCalls survives a resume so a post-timer write can
|
|
188
|
+
// never collide with (and be skipped against) a pre-timer chunk.
|
|
189
|
+
const key = `w:${ctx.sleepCalls}:${ctx.writes}`;
|
|
190
190
|
// Use the replay memo — no per-write re-fetch of the event log.
|
|
191
191
|
const already = ctx.memo.has(`chunk:${key}`);
|
|
192
192
|
ctx.writes += 1;
|
|
@@ -194,10 +194,14 @@ export function getWritable() {
|
|
|
194
194
|
return;
|
|
195
195
|
const index = await ctx.store.nextChunkIndex(ctx.runId);
|
|
196
196
|
ctx.seq += 1;
|
|
197
|
+
const payload = { value: chunk, index, key };
|
|
197
198
|
await ctx.store.appendEvent({
|
|
198
199
|
runId: ctx.runId, seq: ctx.seq, type: "chunk",
|
|
199
|
-
payload
|
|
200
|
+
payload, createdAt: ctx.now(),
|
|
200
201
|
});
|
|
202
|
+
// Record in the live memo so snapshot compaction folds it.
|
|
203
|
+
ctx.memo.set(`chunk:${key}`, { runId: ctx.runId, seq: ctx.seq,
|
|
204
|
+
type: "chunk", payload, createdAt: ctx.now() });
|
|
201
205
|
},
|
|
202
206
|
async close() {
|
|
203
207
|
const already = [...ctx.memo.values()].some((e) => e.type === "chunk" && e.payload?.done === true);
|
|
@@ -307,8 +311,13 @@ export class Engine {
|
|
|
307
311
|
// by the winner, and any newer events it would have seen are picked up
|
|
308
312
|
// by the next resume.
|
|
309
313
|
const claimer = this.store;
|
|
310
|
-
|
|
314
|
+
const claim = await claimer.claimRun?.(runId);
|
|
315
|
+
if (claim && !claim.ok)
|
|
316
|
+
return;
|
|
317
|
+
if (claim?.cancelled) {
|
|
318
|
+
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
311
319
|
return;
|
|
320
|
+
}
|
|
312
321
|
const log = await this.store.getEvents(runId);
|
|
313
322
|
// Memo index: one pass over the log, O(1) lookups during replay.
|
|
314
323
|
// A leading 'snapshot' event pre-fills completed step/sleep/chunk state.
|
|
@@ -340,26 +349,36 @@ export class Engine {
|
|
|
340
349
|
runId, seq: log.length ? log.reduce((m, e) => Math.max(m, e.seq), 0) + 1 : 1,
|
|
341
350
|
stepCalls: 0, sleepCalls: 0, hookCalls: 0,
|
|
342
351
|
writes: 0,
|
|
343
|
-
store: this.store, log, memo, completedNow: 0, freshResults: new Map(),
|
|
352
|
+
store: this.store, log, memo, completedNow: 0, freshResults: new Map(),
|
|
353
|
+
inflight: [], cursor: 0, chunks: [], now: () => Date.now(),
|
|
354
|
+
append: (e) => {
|
|
355
|
+
// Pipelined append: capture the promise; callers only await at
|
|
356
|
+
// suspension/end, so several step completions share one flush.
|
|
357
|
+
const p = ctx.store.appendEvent(e).then(() => { ctx.inflight = ctx.inflight.filter(x => x !== p); });
|
|
358
|
+
ctx.inflight.push(p);
|
|
359
|
+
return p;
|
|
360
|
+
},
|
|
344
361
|
};
|
|
345
362
|
const prev = current;
|
|
346
363
|
current = ctx;
|
|
347
364
|
try {
|
|
348
|
-
if (await this.store.isCancelled?.(runId)) {
|
|
349
|
-
await this.store.setStatus(runId, "failed", { error: "cancelled" });
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
365
|
const output = await fn(...args);
|
|
353
|
-
await
|
|
366
|
+
await Promise.all(ctx.inflight);
|
|
367
|
+
await (this.store.finishRun?.(runId, "completed", output)
|
|
368
|
+
?? this.store.setStatus(runId, "completed", output));
|
|
354
369
|
this.local.get(runId)?.resolve(output);
|
|
355
370
|
}
|
|
356
371
|
catch (err) {
|
|
357
|
-
if (err instanceof SuspendSignal)
|
|
372
|
+
if (err instanceof SuspendSignal) {
|
|
373
|
+
await Promise.all(ctx.inflight);
|
|
358
374
|
return; // waiting on a timer
|
|
375
|
+
}
|
|
359
376
|
const fail = async (error) => {
|
|
360
|
-
await this.store.
|
|
377
|
+
await (this.store.finishRun?.(runId, "failed", {
|
|
378
|
+
error: error instanceof Error ? error.message : String(error),
|
|
379
|
+
}) ?? this.store.setStatus(runId, "failed", {
|
|
361
380
|
error: error instanceof Error ? error.message : String(error),
|
|
362
|
-
});
|
|
381
|
+
}));
|
|
363
382
|
this.local.get(runId)?.reject(err instanceof Error ? err : new Error(String(err)));
|
|
364
383
|
};
|
|
365
384
|
if (err instanceof CancelledError)
|
package/dist/src/pg-store.js
CHANGED
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
* columns, so no timezone ambiguity (the bug that cost hours in world-postgres).
|
|
6
6
|
*/
|
|
7
7
|
import pg from "pg";
|
|
8
|
-
export async function createPostgresStore(url) {
|
|
8
|
+
export async function createPostgresStore(url, opts = {}) {
|
|
9
9
|
// Pipelining (pg >= 8.23): batch queries on one connection instead of one
|
|
10
10
|
// round trip per query — 1.5-2.4x on multi-query workloads.
|
|
11
11
|
const pool = new pg.Pool({ connectionString: url, max: 20, pipeline: true });
|
|
12
|
+
if (opts.sessionOptions) {
|
|
13
|
+
const sets = Object.entries(opts.sessionOptions)
|
|
14
|
+
.map(([k, v]) => `SET ${k} = ${v === "off" || v === "on" ? v : `'${v}'`}`)
|
|
15
|
+
.join("; ");
|
|
16
|
+
// Apply per connection as it opens (pg fires 'connect' per client).
|
|
17
|
+
pool.on("connect", (client) => {
|
|
18
|
+
void client.query(sets).catch(() => { });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
12
21
|
await pool.query(`
|
|
13
22
|
CREATE TABLE IF NOT EXISTS lightflow_runs (
|
|
14
23
|
run_id TEXT PRIMARY KEY,
|
|
@@ -208,14 +217,21 @@ export async function createPostgresStore(url) {
|
|
|
208
217
|
async claimDue() { return false; },
|
|
209
218
|
async claimRun(runId, leaseMs = 30_000) {
|
|
210
219
|
const now = Date.now();
|
|
220
|
+
// One round trip: claim the lease AND read the cancelled flag.
|
|
211
221
|
const r = await pool.query(`UPDATE lightflow_runs SET claimed_until=$2, updated_at=$3
|
|
212
222
|
WHERE run_id=$1 AND claimed_until < $3
|
|
213
|
-
RETURNING
|
|
214
|
-
return (r.rowCount ?? 0) > 0;
|
|
223
|
+
RETURNING cancelled`, [runId, now + leaseMs, now]);
|
|
224
|
+
return { ok: (r.rowCount ?? 0) > 0, cancelled: r.rows[0]?.cancelled === true };
|
|
215
225
|
},
|
|
216
226
|
async releaseRun(runId) {
|
|
217
227
|
await pool.query(`UPDATE lightflow_runs SET claimed_until=0 WHERE run_id=$1 AND claimed_until > 0`, [runId]);
|
|
218
228
|
},
|
|
229
|
+
/** Terminal status + lease release in one round trip. */
|
|
230
|
+
async finishRun(runId, status, output) {
|
|
231
|
+
await pool.query(`UPDATE lightflow_runs
|
|
232
|
+
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()]);
|
|
234
|
+
},
|
|
219
235
|
/** LISTEN/NOTIFY wakeup support: re-poll immediately on notify. */
|
|
220
236
|
async notifyWake() {
|
|
221
237
|
await pool.query(`NOTIFY lightflow_wake`);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in verification: the exact Vercel Workflow surface entry-agents uses,
|
|
3
|
+
* exercised against lightflow's compat layer:
|
|
4
|
+
* start(fn, args) / getRun(id) / run.status (Promise) / run.runId /
|
|
5
|
+
* run.returnValue / run.cancel() / run.getReadable({startIndex}) /
|
|
6
|
+
* getTailIndex() / getWritable() as WritableStream / getWorkflowMetadata()
|
|
7
|
+
* -> { workflowRunId } / sleep(Date) / FatalError.
|
|
8
|
+
*/
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import pg from "pg";
|
|
12
|
+
import { Engine, step, sleep, FatalError } from "../src/index.js";
|
|
13
|
+
import { createPostgresStore } from "../src/pg-store.js";
|
|
14
|
+
import { initWorkflowApi, start } from "../src/compat/api.js";
|
|
15
|
+
import { getWritable, getWorkflowMetadata } from "../src/compat/workflow.js";
|
|
16
|
+
const PG_URL = process.env.LIGHTFLOW_PG_URL;
|
|
17
|
+
if (!PG_URL)
|
|
18
|
+
throw new Error("LIGHTFLOW_PG_URL is required");
|
|
19
|
+
test("compat: vercel-style agent chat workflow end-to-end", async () => {
|
|
20
|
+
const store = await createPostgresStore(PG_URL);
|
|
21
|
+
const engine = new Engine(store, { pollMs: 25 });
|
|
22
|
+
initWorkflowApi(store, engine);
|
|
23
|
+
const workerPromise = engine.startWorker();
|
|
24
|
+
setTimeout(() => engine.stopWorker(), 60_000);
|
|
25
|
+
const pool = new pg.Pool({ connectionString: PG_URL });
|
|
26
|
+
// A workflow shaped like entry-agents' runAgentWorkflow:
|
|
27
|
+
// - "use step"-style direct calls via step()
|
|
28
|
+
// - getWritable() as a real WritableStream, written via getWriter()
|
|
29
|
+
// - getWorkflowMetadata() -> { workflowRunId }
|
|
30
|
+
// - sleep(new Date(...)) for durable timers
|
|
31
|
+
// - FatalError for non-retryable failures
|
|
32
|
+
const runAgentWorkflow = async (opts) => {
|
|
33
|
+
"use workflow";
|
|
34
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
35
|
+
const writable = getWritable();
|
|
36
|
+
const writer = writable.getWriter();
|
|
37
|
+
await step(async () => {
|
|
38
|
+
await writer.write(`start:${opts.chatId}`);
|
|
39
|
+
});
|
|
40
|
+
const answer = await step(async () => `reply-to:${opts.messages.join(",")}`);
|
|
41
|
+
// mid-run durable timer like sandbox-lifecycle
|
|
42
|
+
await sleep(new Date(Date.now() + 50));
|
|
43
|
+
await step(async () => { await writer.write(`chunk:${answer}`); });
|
|
44
|
+
await step(async () => { await writer.write("done-marker"); });
|
|
45
|
+
await writer.close();
|
|
46
|
+
if (opts.messages.includes("boom"))
|
|
47
|
+
throw new FatalError("nope");
|
|
48
|
+
return { workflowRunId, answer };
|
|
49
|
+
};
|
|
50
|
+
// Vercel-style start(fn, args) — function, not id
|
|
51
|
+
const run = await start(runAgentWorkflow, [
|
|
52
|
+
{ messages: ["hello", "world"], chatId: "c1" },
|
|
53
|
+
]);
|
|
54
|
+
assert.ok(run.runId.startsWith("lrun_"));
|
|
55
|
+
// status is a Promise, resolvable to a terminal state
|
|
56
|
+
const status = await run.status;
|
|
57
|
+
assert.equal(status, "completed");
|
|
58
|
+
const ret = (await run.returnValue);
|
|
59
|
+
assert.equal(ret.answer, "reply-to:hello,world");
|
|
60
|
+
assert.equal(ret.workflowRunId, run.runId);
|
|
61
|
+
// getReadable() with startIndex + getTailIndex
|
|
62
|
+
const readable = run.getReadable({ startIndex: 0 });
|
|
63
|
+
const tail = await readable.getTailIndex();
|
|
64
|
+
const parts = [];
|
|
65
|
+
const reader = readable.getReader();
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done)
|
|
69
|
+
break;
|
|
70
|
+
parts.push(value);
|
|
71
|
+
}
|
|
72
|
+
assert.deepEqual(parts, ["start:c1", "chunk:reply-to:hello,world", "done-marker"]);
|
|
73
|
+
assert.ok(tail >= 1);
|
|
74
|
+
// resume from startIndex: skipping the first chunk
|
|
75
|
+
const resumed = run.getReadable({ startIndex: 1 });
|
|
76
|
+
const rreader = resumed.getReader();
|
|
77
|
+
const r1 = await rreader.read();
|
|
78
|
+
assert.equal(r1.value, "chunk:reply-to:hello,world");
|
|
79
|
+
// cancel semantics: getRun + status + cancel()
|
|
80
|
+
const dup = await start(runAgentWorkflow, [{ messages: ["x"], chatId: "c2" }]);
|
|
81
|
+
await dup.cancel();
|
|
82
|
+
assert.equal(await dup.status, "cancelled");
|
|
83
|
+
await pool.end();
|
|
84
|
+
engine.stopWorker();
|
|
85
|
+
await workerPromise;
|
|
86
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
import { Engine, step, sleep, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
|
|
3
|
+
import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
|
|
4
|
+
import { getWritable } from "/home/agentuser/lightflow/dist/src/compat/workflow.js";
|
|
5
|
+
import { initWorkflowApi, start } from "/home/agentuser/lightflow/dist/src/compat/api.js";
|
|
6
|
+
import pg from "pg";
|
|
7
|
+
const url = process.env.LIGHTFLOW_PG_URL;
|
|
8
|
+
const store = await createPostgresStore(url);
|
|
9
|
+
const engine = new Engine(store, { pollMs: 25 });
|
|
10
|
+
initWorkflowApi(store, engine);
|
|
11
|
+
const wp = engine.startWorker();
|
|
12
|
+
setTimeout(() => engine.stopWorker(), 30000);
|
|
13
|
+
const wf = async () => {
|
|
14
|
+
const wr = getWritable();
|
|
15
|
+
const writer = wr.getWriter();
|
|
16
|
+
await step(async () => { await writer.write("A"); });
|
|
17
|
+
await sleep(new Date(Date.now() + 50));
|
|
18
|
+
await step(async () => { await writer.write("B"); });
|
|
19
|
+
await step(async () => { await writer.write("C"); });
|
|
20
|
+
await writer.close();
|
|
21
|
+
return "ok";
|
|
22
|
+
};
|
|
23
|
+
const run = await start(wf, []);
|
|
24
|
+
console.log("status:", await run.status);
|
|
25
|
+
const pool = new pg.Pool({ connectionString: url });
|
|
26
|
+
const rows = await pool.query("SELECT seq, payload->>'key' k, payload->>'value' v FROM lightflow_events WHERE type='chunk' ORDER BY seq");
|
|
27
|
+
console.log(rows.rows.map(r=>`${r.k}=${r.v}`).join(" | "));
|
|
28
|
+
await pool.end(); process.exit(0);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
import { Engine, step, sleep, registerWorkflow } from "/home/agentuser/lightflow/dist/src/index.js";
|
|
3
|
+
import { createPostgresStore } from "/home/agentuser/lightflow/dist/src/pg-store.js";
|
|
4
|
+
import { getWritable } from "/home/agentuser/lightflow/dist/src/compat/workflow.js";
|
|
5
|
+
import { initWorkflowApi, start } from "/home/agentuser/lightflow/dist/src/compat/api.js";
|
|
6
|
+
const store = await createPostgresStore(process.env.LIGHTFLOW_PG_URL);
|
|
7
|
+
const engine = new Engine(store, { pollMs: 25 });
|
|
8
|
+
initWorkflowApi(store, engine);
|
|
9
|
+
const wp = engine.startWorker();
|
|
10
|
+
setTimeout(() => engine.stopWorker(), 60000);
|
|
11
|
+
const wf = async () => {
|
|
12
|
+
const writer = getWritable().getWriter();
|
|
13
|
+
for (let i = 0; i < 205; i++) {
|
|
14
|
+
await step(async () => { await writer.write("c" + i); });
|
|
15
|
+
if (i === 100) await sleep(new Date(Date.now() + 40));
|
|
16
|
+
}
|
|
17
|
+
await writer.close();
|
|
18
|
+
return "done";
|
|
19
|
+
};
|
|
20
|
+
const run = await start(wf, []);
|
|
21
|
+
console.log("status:", await run.status);
|
|
22
|
+
const readable = run.getReadable({ startIndex: 0 });
|
|
23
|
+
const reader = readable.getReader();
|
|
24
|
+
const parts = [];
|
|
25
|
+
for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); }
|
|
26
|
+
const missing = []; for (let i=0;i<205;i++) if (!parts.includes("c"+i)) missing.push(i); console.log("chunks:", parts.length, "nmissing:", missing.length, "m0:", missing[0], "mLast:", missing[missing.length-1]);
|
|
27
|
+
console.log("tailIndex:", await readable.getTailIndex());
|
|
28
|
+
// resume from index 200
|
|
29
|
+
const r2 = run.getReadable({ startIndex: 200 });
|
|
30
|
+
const rd2 = r2.getReader();
|
|
31
|
+
const p2 = [];
|
|
32
|
+
for (;;) { const { done, value } = await rd2.read(); if (done) break; p2.push(value); }
|
|
33
|
+
console.log("resumed-from-200:", p2.length, p2[0], p2.at(-1));
|
|
34
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightflow-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A tiny durable workflow engine for Node.js and Postgres
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A tiny durable workflow engine for Node.js and Postgres \u2014 with a Vercel Workflow drop-in compat layer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"types": "dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./dist/index.js",
|
|
11
|
-
"./pg": "./dist/pg-store.js"
|
|
11
|
+
"./pg": "./dist/pg-store.js",
|
|
12
|
+
"./compat/workflow": "./dist/compat/workflow.js",
|
|
13
|
+
"./compat/api": "./dist/compat/api.js",
|
|
14
|
+
"./compat/next": "./dist/compat/next.js"
|
|
12
15
|
},
|
|
13
16
|
"files": [
|
|
14
17
|
"dist",
|
|
15
18
|
"README.md",
|
|
16
|
-
"LICENSE"
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"BENCHMARKS.md"
|
|
17
21
|
],
|
|
18
22
|
"keywords": [
|
|
19
23
|
"workflow",
|