Skip to content

Commit f856031

Browse files
authored
World-postgres: allow steps.get in world-postgres to handle undefined runId (#135)
1 parent 278c3ee commit f856031

File tree

3 files changed

+393
-12
lines changed

3 files changed

+393
-12
lines changed

.changeset/sweet-cougars-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/world-postgres": patch
3+
---
4+
5+
Handle undefined runId in world-postgres steps.get()
Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
import { execSync } from 'node:child_process';
2+
import postgres from 'postgres';
3+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
4+
import { createClient } from './drizzle/index.js';
5+
import { createRunsStorage, createStepsStorage } from './storage.js';
6+
7+
describe('Storage (Postgres integration)', () => {
8+
const connectionString =
9+
process.env.WORKFLOW_POSTGRES_URL ||
10+
'postgres://world:world@localhost:5432/world';
11+
12+
const sql = postgres(connectionString, { max: 1 });
13+
const drizzle = createClient(sql);
14+
const runs = createRunsStorage(drizzle);
15+
const steps = createStepsStorage(drizzle);
16+
17+
async function truncateTables() {
18+
await sql`TRUNCATE TABLE workflow_events, workflow_steps, workflow_hooks, workflow_runs RESTART IDENTITY CASCADE`;
19+
}
20+
21+
beforeAll(async () => {
22+
// Ensure schema is applied
23+
process.env.DATABASE_URL = connectionString;
24+
process.env.WORKFLOW_POSTGRES_URL = connectionString;
25+
execSync('pnpm db:push', {
26+
stdio: 'inherit',
27+
cwd: process.cwd(),
28+
env: process.env,
29+
});
30+
}, 120_000);
31+
32+
beforeEach(async () => {
33+
await truncateTables();
34+
});
35+
36+
afterAll(async () => {
37+
await sql.end();
38+
});
39+
40+
describe('runs', () => {
41+
describe('create', () => {
42+
it('should create a new workflow run', async () => {
43+
const runData = {
44+
deploymentId: 'deployment-123',
45+
workflowName: 'test-workflow',
46+
executionContext: { userId: 'user-1' },
47+
input: ['arg1', 'arg2'],
48+
};
49+
50+
const run = await runs.create(runData);
51+
52+
expect(run.runId).toMatch(/^wrun_/);
53+
expect(run.deploymentId).toBe('deployment-123');
54+
expect(run.status).toBe('pending');
55+
expect(run.workflowName).toBe('test-workflow');
56+
expect(run.executionContext).toEqual({ userId: 'user-1' });
57+
expect(run.input).toEqual(['arg1', 'arg2']);
58+
expect(run.output).toBeUndefined();
59+
expect(run.error).toBeUndefined();
60+
expect(run.errorCode).toBeUndefined();
61+
expect(run.startedAt).toBeUndefined();
62+
expect(run.completedAt).toBeUndefined();
63+
expect(run.createdAt).toBeInstanceOf(Date);
64+
expect(run.updatedAt).toBeInstanceOf(Date);
65+
});
66+
67+
it('should handle minimal run data', async () => {
68+
const runData = {
69+
deploymentId: 'deployment-123',
70+
workflowName: 'minimal-workflow',
71+
input: [],
72+
};
73+
74+
const run = await runs.create(runData);
75+
76+
expect(run.executionContext).toBeUndefined();
77+
expect(run.input).toEqual([]);
78+
});
79+
});
80+
81+
describe('get', () => {
82+
it('should retrieve an existing run', async () => {
83+
const created = await runs.create({
84+
deploymentId: 'deployment-123',
85+
workflowName: 'test-workflow',
86+
input: ['arg'],
87+
});
88+
89+
const retrieved = await runs.get(created.runId);
90+
expect(retrieved.runId).toBe(created.runId);
91+
expect(retrieved.workflowName).toBe('test-workflow');
92+
expect(retrieved.input).toEqual(['arg']);
93+
});
94+
95+
it('should throw error for non-existent run', async () => {
96+
await expect(runs.get('missing')).rejects.toMatchObject({
97+
status: 404,
98+
});
99+
});
100+
});
101+
102+
describe('update', () => {
103+
it('should update run status to running', async () => {
104+
const created = await runs.create({
105+
deploymentId: 'deployment-123',
106+
workflowName: 'test-workflow',
107+
input: [],
108+
});
109+
110+
const updated = await runs.update(created.runId, { status: 'running' });
111+
expect(updated.status).toBe('running');
112+
expect(updated.startedAt).toBeInstanceOf(Date);
113+
});
114+
115+
it('should update run status to completed', async () => {
116+
const created = await runs.create({
117+
deploymentId: 'deployment-123',
118+
workflowName: 'test-workflow',
119+
input: [],
120+
});
121+
122+
const updated = await runs.update(created.runId, {
123+
status: 'completed',
124+
output: [{ result: 42 }],
125+
});
126+
expect(updated.status).toBe('completed');
127+
expect(updated.completedAt).toBeInstanceOf(Date);
128+
expect(updated.output).toEqual([{ result: 42 }]);
129+
});
130+
131+
it('should update run status to failed', async () => {
132+
const created = await runs.create({
133+
deploymentId: 'deployment-123',
134+
workflowName: 'test-workflow',
135+
input: [],
136+
});
137+
138+
const updated = await runs.update(created.runId, {
139+
status: 'failed',
140+
error: 'boom',
141+
errorCode: 'E_FAIL',
142+
});
143+
expect(updated.status).toBe('failed');
144+
expect(updated.completedAt).toBeInstanceOf(Date);
145+
expect(updated.error).toBe('boom');
146+
expect(updated.errorCode).toBe('E_FAIL');
147+
});
148+
149+
it('should throw error for non-existent run', async () => {
150+
await expect(
151+
runs.update('missing', { status: 'running' })
152+
).rejects.toMatchObject({
153+
status: 404,
154+
});
155+
});
156+
});
157+
158+
describe('list', () => {
159+
it('should list all runs', async () => {
160+
const run1 = await runs.create({
161+
deploymentId: 'deployment-1',
162+
workflowName: 'workflow-1',
163+
input: [],
164+
});
165+
166+
const run2 = await runs.create({
167+
deploymentId: 'deployment-2',
168+
workflowName: 'workflow-2',
169+
input: [],
170+
});
171+
172+
const result = await runs.list();
173+
expect(result.data.map((r) => r.runId)).toEqual(
174+
[run1.runId, run2.runId].sort().reverse()
175+
);
176+
});
177+
178+
it('should filter runs by workflowName', async () => {
179+
await runs.create({
180+
deploymentId: 'deployment-1',
181+
workflowName: 'workflow-1',
182+
input: [],
183+
});
184+
const run2 = await runs.create({
185+
deploymentId: 'deployment-2',
186+
workflowName: 'workflow-2',
187+
input: [],
188+
});
189+
190+
const result = await runs.list({ workflowName: 'workflow-2' });
191+
192+
expect(result.data).toHaveLength(1);
193+
expect(result.data[0].runId).toBe(run2.runId);
194+
});
195+
196+
it('should support pagination', async () => {
197+
// Create multiple runs
198+
for (let i = 0; i < 5; i++) {
199+
await runs.create({
200+
deploymentId: `deployment-${i}`,
201+
workflowName: `workflow-${i}`,
202+
input: [],
203+
});
204+
}
205+
206+
const page1 = await runs.list({
207+
pagination: { limit: 2 },
208+
});
209+
210+
expect(page1.data).toHaveLength(2);
211+
expect(page1.cursor).not.toBeNull();
212+
213+
const page2 = await runs.list({
214+
pagination: { limit: 2, cursor: page1.cursor || undefined },
215+
});
216+
217+
expect(page2.data).toHaveLength(2);
218+
expect(page2.data[0].runId).not.toBe(page1.data[0].runId);
219+
});
220+
});
221+
222+
describe('cancel', () => {
223+
it('should cancel a run', async () => {
224+
const created = await runs.create({
225+
deploymentId: 'deployment-123',
226+
workflowName: 'test-workflow',
227+
input: [],
228+
});
229+
const cancelled = await runs.cancel(created.runId);
230+
expect(cancelled.status).toBe('cancelled');
231+
expect(cancelled.completedAt).toBeInstanceOf(Date);
232+
});
233+
});
234+
235+
describe('pause', () => {
236+
it('should pause a run', async () => {
237+
const created = await runs.create({
238+
deploymentId: 'deployment-123',
239+
workflowName: 'test-workflow',
240+
input: [],
241+
});
242+
const paused = await runs.pause(created.runId);
243+
expect(paused.status).toBe('paused');
244+
});
245+
});
246+
247+
describe('resume', () => {
248+
it('should resume a paused run', async () => {
249+
const created = await runs.create({
250+
deploymentId: 'deployment-123',
251+
workflowName: 'test-workflow',
252+
input: [],
253+
});
254+
await runs.pause(created.runId);
255+
const resumed = await runs.resume(created.runId);
256+
expect(resumed.status).toBe('running');
257+
});
258+
});
259+
});
260+
261+
describe('steps', () => {
262+
let testRunId: string;
263+
264+
beforeEach(async () => {
265+
const run = await runs.create({
266+
deploymentId: 'deployment-123',
267+
workflowName: 'test-workflow',
268+
input: [],
269+
});
270+
testRunId = run.runId;
271+
});
272+
273+
describe('create', () => {
274+
it('should create a new step', async () => {
275+
const stepData = { stepId: 'step-123', stepName: 'first', input: [] };
276+
const step = await steps.create(testRunId, stepData);
277+
278+
expect(step.runId).toBe(testRunId);
279+
expect(step.stepId).toBe('step-123');
280+
expect(step.status).toBe('pending');
281+
expect(step.attempt).toBe(1);
282+
expect(step.output).toBeUndefined();
283+
});
284+
});
285+
286+
describe('get', () => {
287+
it('should retrieve a step with runId and stepId', async () => {
288+
const created = await steps.create(testRunId, {
289+
stepId: 'step-123',
290+
stepName: 'test-step',
291+
input: [],
292+
});
293+
294+
const retrieved = await steps.get(testRunId, created.stepId);
295+
296+
expect(retrieved.stepId).toBe(created.stepId);
297+
});
298+
299+
it('should retrieve a step with only stepId', async () => {
300+
const created = await steps.create(testRunId, {
301+
stepId: 'step-123',
302+
stepName: 'test-step',
303+
input: [],
304+
});
305+
306+
const retrieved = await steps.get(undefined, created.stepId);
307+
308+
expect(retrieved.stepId).toBe(created.stepId);
309+
expect(retrieved.runId).toBe(testRunId);
310+
});
311+
312+
it('should throw error for non-existent step', async () => {
313+
await expect(
314+
steps.get(testRunId, 'missing-step')
315+
).rejects.toMatchObject({ status: 404 });
316+
});
317+
});
318+
319+
describe('update', () => {
320+
it('should update step status to running', async () => {
321+
await steps.create(testRunId, {
322+
stepId: 'step-123',
323+
stepName: 'test-step',
324+
input: ['input1'],
325+
});
326+
const updated = await steps.update(testRunId, 'step-123', {
327+
status: 'running',
328+
});
329+
expect(updated.status).toBe('running');
330+
expect(updated.startedAt).toBeInstanceOf(Date);
331+
});
332+
333+
it('should update step status to completed', async () => {
334+
await steps.create(testRunId, {
335+
stepId: 'step-123',
336+
stepName: 'test-step',
337+
input: ['input1'],
338+
});
339+
const updated = await steps.update(testRunId, 'step-123', {
340+
status: 'completed',
341+
output: ['ok'],
342+
});
343+
expect(updated.status).toBe('completed');
344+
expect(updated.completedAt).toBeInstanceOf(Date);
345+
expect(updated.output).toEqual(['ok']);
346+
});
347+
348+
it('should update step status to failed', async () => {
349+
await steps.create(testRunId, {
350+
stepId: 'step-123',
351+
stepName: 'test-step',
352+
input: [],
353+
});
354+
const updated = await steps.update(testRunId, 'step-123', {
355+
status: 'failed',
356+
error: 'bad',
357+
errorCode: 'X',
358+
});
359+
expect(updated.status).toBe('failed');
360+
expect(updated.completedAt).toBeInstanceOf(Date);
361+
expect(updated.error).toBe('bad');
362+
expect(updated.errorCode).toBe('X');
363+
});
364+
365+
it('should update attempt count', async () => {
366+
await steps.create(testRunId, {
367+
stepId: 'step-123',
368+
stepName: 'test-step',
369+
input: [],
370+
});
371+
const updated = await steps.update(testRunId, 'step-123', {
372+
attempt: 2,
373+
});
374+
expect(updated.attempt).toBe(2);
375+
});
376+
});
377+
});
378+
});

0 commit comments

Comments
 (0)