Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/durably-react/docs/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ List runs with pagination and real-time updates.
interface UseRunsClientOptions {
api: string // API endpoint URL (e.g., '/api/durably')
jobName?: string | string[] // Filter by job name(s)
status?: RunStatus // Filter by status
status?: RunStatus | RunStatus[] // Filter by status(es)
labels?: Record<string, string> // Filter by labels (all must match)
pageSize?: number // Runs per page (default: 10)
realtime?: boolean // Subscribe to real-time updates via SSE (default: true)
Expand Down
50 changes: 23 additions & 27 deletions packages/durably-react/src/client/use-runs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { JobDefinition } from '@coji/durably'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useStableValue } from '../shared/use-stable-value'
import {
type Progress,
type RunStatus,
Expand Down Expand Up @@ -70,9 +71,9 @@ export interface UseRunsClientOptions {
*/
jobName?: string | string[]
/**
* Filter by status
* Filter by status(es). Pass one status, or an array for multiple (OR).
*/
status?: RunStatus
status?: RunStatus | RunStatus[]
/**
* Filter by labels (all specified labels must match)
*/
Expand Down Expand Up @@ -215,21 +216,15 @@ export function useRuns<

const { api, status, labels, pageSize = 10, realtime = true } = options

// Stabilize labels reference to prevent infinite re-renders
const labelsKey = labels ? JSON.stringify(labels) : undefined
const stableLabels = useMemo(
() =>
labelsKey ? (JSON.parse(labelsKey) as Record<string, string>) : undefined,
[labelsKey],
)

// Stabilize jobName reference to prevent infinite re-renders with array literals
const jobNameKey = jobName ? JSON.stringify(jobName) : undefined
const stableJobName = useMemo(
() =>
jobNameKey ? (JSON.parse(jobNameKey) as string | string[]) : undefined,
[jobNameKey],
)
const stableLabels = useStableValue(labels)
const stableJobName = useStableValue(jobName)
const stableStatus = useStableValue(status)

// Normalize empty status array to undefined (no filter)
const normalizedStatus =
Array.isArray(stableStatus) && stableStatus.length === 0
? undefined
: stableStatus

const [runs, setRuns] = useState<TypedClientRun<TInput, TOutput>[]>([])
const [page, setPage] = useState(0)
Expand All @@ -246,8 +241,8 @@ export function useRuns<

try {
const params = new URLSearchParams()
appendJobNameToParams(params, stableJobName)
if (status) params.set('status', status)
appendArrayParam(params, 'jobName', stableJobName)
appendArrayParam(params, 'status', normalizedStatus)
appendLabelsToParams(params, stableLabels)
params.set('limit', String(pageSize + 1))
params.set('offset', String(page * pageSize))
Expand All @@ -274,7 +269,7 @@ export function useRuns<
setIsLoading(false)
}
}
}, [api, stableJobName, status, stableLabels, pageSize, page])
}, [api, stableJobName, normalizedStatus, stableLabels, pageSize, page])

// Initial fetch
useEffect(() => {
Expand All @@ -300,7 +295,7 @@ export function useRuns<

// Build SSE URL
const params = new URLSearchParams()
appendJobNameToParams(params, stableJobName)
appendArrayParam(params, 'jobName', stableJobName)
appendLabelsToParams(params, stableLabels)
const sseUrl = `${api}/runs/subscribe${params.toString() ? `?${params.toString()}` : ''}`

Expand Down Expand Up @@ -391,13 +386,14 @@ export function useRuns<
}
}

function appendJobNameToParams(
function appendArrayParam(
params: URLSearchParams,
jobName: string | string[] | undefined,
key: string,
value: string | string[] | undefined,
) {
if (!jobName) return
for (const name of Array.isArray(jobName) ? jobName : [jobName]) {
params.append('jobName', name)
if (value === undefined) return
for (const v of Array.isArray(value) ? value : [value]) {
params.append(key, v)
}
}

Expand Down
40 changes: 17 additions & 23 deletions packages/durably-react/src/hooks/use-runs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { JobDefinition } from '@coji/durably'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { JobDefinition, RunStatus } from '@coji/durably'
import { useCallback, useEffect, useState } from 'react'
import { useDurably } from '../context'
import { useStableValue } from '../shared/use-stable-value'
import { type TypedRun, isJobDefinition } from '../types'

// Re-export TypedRun for convenience
Expand All @@ -12,9 +13,9 @@ export interface UseRunsOptions {
*/
jobName?: string | string[]
/**
* Filter by status
* Filter by status(es). Pass one status, or an array for multiple (OR).
*/
status?: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
status?: RunStatus | RunStatus[]
/**
* Filter by labels (all specified labels must match)
*/
Expand Down Expand Up @@ -156,23 +157,16 @@ export function useRuns<

const pageSize = options?.pageSize ?? 10
const realtime = options?.realtime ?? true
const status = options?.status

// Stabilize jobName reference to prevent re-fetch loops with array literals
const jobNameKey = jobName ? JSON.stringify(jobName) : undefined
const stableJobName = useMemo(
() =>
jobNameKey ? (JSON.parse(jobNameKey) as string | string[]) : undefined,
[jobNameKey],
)

// Stabilize labels reference to prevent infinite re-renders
const labelsKey = options?.labels ? JSON.stringify(options.labels) : undefined
const labels = useMemo(
() =>
labelsKey ? (JSON.parse(labelsKey) as Record<string, string>) : undefined,
[labelsKey],
)

const stableJobName = useStableValue(jobName)
const stableStatus = useStableValue(options?.status)
const labels = useStableValue(options?.labels)

// Normalize empty status array to undefined (no filter)
const normalizedStatus =
Array.isArray(stableStatus) && stableStatus.length === 0
? undefined
: stableStatus

const [runs, setRuns] = useState<TypedRun<TInput, TOutput>[]>([])
const [page, setPage] = useState(0)
Expand All @@ -186,7 +180,7 @@ export function useRuns<
try {
const data = await durably.getRuns({
jobName: stableJobName,
status,
status: normalizedStatus,
labels,
limit: pageSize + 1,
offset: page * pageSize,
Expand All @@ -196,7 +190,7 @@ export function useRuns<
} finally {
setIsLoading(false)
}
}, [durably, stableJobName, status, labels, pageSize, page])
}, [durably, stableJobName, normalizedStatus, labels, pageSize, page])

// Initial fetch and subscribe to events
useEffect(() => {
Expand Down
13 changes: 13 additions & 0 deletions packages/durably-react/src/shared/use-stable-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useMemo } from 'react'

/**
* Stabilize a value reference using JSON serialization.
* Prevents re-render loops when callers pass inline arrays/objects.
*/
export function useStableValue<T>(value: T | undefined): T | undefined {
const key = value !== undefined ? JSON.stringify(value) : undefined
return useMemo(
() => (key !== undefined ? (JSON.parse(key) as T) : undefined),
[key],
)
}
25 changes: 25 additions & 0 deletions packages/durably-react/tests/browser/use-runs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,31 @@ describe('useRuns', () => {
})
})

it('filters by multiple statuses', async () => {
const durably = await createTestDurably({ pollingIntervalMs: 50 })
instances.push(durably)

const { result } = renderHook(
() => useRuns({ status: ['pending', 'leased'] }),
{
wrapper: createWrapper(durably),
},
)

const d = durably.register({ testJobHandle: testJob })

await d.jobs.testJobHandle.trigger({ value: 1 })
await d.jobs.testJobHandle.trigger({ value: 2 })

await waitFor(() => {
expect(result.current.runs.length).toBe(2)
})

for (const run of result.current.runs) {
expect(['pending', 'leased']).toContain(run.status)
}
})

it('supports pagination', async () => {
const durably = await createTestDurably({ pollingIntervalMs: 50 })
instances.push(durably)
Expand Down
40 changes: 40 additions & 0 deletions packages/durably-react/tests/client/use-runs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,46 @@ describe('useRuns (client)', () => {
expect(url).toContain('status=completed')
})

it('appends multiple status params to the request URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
})
globalThis.fetch = fetchMock

renderHook(() =>
useRuns({ api: '/api/durably', status: ['pending', 'leased'] }),
)

await waitFor(() => {
expect(fetchMock).toHaveBeenCalled()
})

const url = fetchMock.mock.calls[0][0] as string
expect(url).toContain('status=pending')
expect(url).toContain('status=leased')
})

it('does not refetch when the same status array values are passed on re-render', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
})
globalThis.fetch = fetchMock

const { rerender } = renderHook(() =>
useRuns({ api: '/api/durably', status: ['pending', 'leased'] }),
)

await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})

rerender()

expect(fetchMock.mock.calls.length).toBe(1)
})

it('handles pagination', async () => {
const page1Runs = [
createMockRun({ id: 'run-1' }),
Expand Down
21 changes: 19 additions & 2 deletions packages/durably-react/tests/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@
* rather than runtime behavior.
*/

import { defineJob } from '@coji/durably'
import { defineJob, type RunStatus } from '@coji/durably'
import { describe, expectTypeOf, it } from 'vitest'
import { z } from 'zod'
import type {
TypedClientRun,
UseRunsClientOptions,
UseRunsClientResult,
} from '../src/client/use-runs'
import type { UseJobResult } from '../src/hooks/use-job'
import type { UseJobLogsResult } from '../src/hooks/use-job-logs'
import type { UseJobRunResult } from '../src/hooks/use-job-run'
import type { TypedRun, UseRunsResult } from '../src/hooks/use-runs'
import type {
TypedRun,
UseRunsOptions,
UseRunsResult,
} from '../src/hooks/use-runs'

// Test job definitions
const typedJob = defineJob({
Expand Down Expand Up @@ -202,6 +207,12 @@ describe('Type inference', () => {
{ file: string } | { userId: string }
>()
})

it('UseRunsOptions.status accepts a single status or an array', () => {
expectTypeOf<UseRunsOptions['status']>().toEqualTypeOf<
RunStatus | RunStatus[] | undefined
>()
})
})

describe('useRuns (client)', () => {
Expand Down Expand Up @@ -276,5 +287,11 @@ describe('Type inference', () => {
{ file: string } | { userId: string }
>()
})

it('UseRunsClientOptions.status accepts a single status or an array', () => {
expectTypeOf<UseRunsClientOptions['status']>().toEqualTypeOf<
RunStatus | RunStatus[] | undefined
>()
})
})
})
5 changes: 4 additions & 1 deletion packages/durably/docs/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ const typedRun = await durably.getRun<MyRun>(runId)
// Get failed runs
const failedRuns = await durably.getRuns({ status: 'failed' })

// Get active runs (multiple statuses)
const activeRuns = await durably.getRuns({ status: ['pending', 'leased'] })

// Filter by job name with pagination
const runs = await durably.getRuns({
jobName: 'sync-users', // also accepts string[] for multiple jobs
Expand Down Expand Up @@ -704,7 +707,7 @@ interface LogData {
interface RunFilter<
TLabels extends Record<string, string> = Record<string, string>,
> {
status?: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
status?: RunStatus | RunStatus[]
jobName?: string | string[]
labels?: Partial<TLabels>
limit?: number
Expand Down
Loading