From 9d8a6415381fc67183336325928515a42567e7ec Mon Sep 17 00:00:00 2001 From: coji Date: Sat, 3 Jan 2026 23:49:42 +0900 Subject: [PATCH 01/11] feat(durably-react): add generic type support for useRuns hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add multiple ways to get type-safe run access in useRuns: - `useRuns(options)` - Pass type parameter for dashboards with multiple job types - `useRuns(jobDefinition, options?)` - Pass JobDefinition to infer types and auto-filter by jobName - `useRuns(options?)` - Untyped usage for simple cases New exports: - `TypedRun` type for browser hooks - `TypedClientRun` type for client hooks Examples updated to demonstrate typed dashboard pattern with union types. Closes #14 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .prettierignore | 1 + CHANGELOG.md | 22 +++ .../app/jobs/index.ts | 19 ++- .../app/routes/_index/dashboard.tsx | 20 ++- .../src/components/dashboard.tsx | 17 ++- examples/browser-vite-react/src/jobs/index.ts | 13 +- .../fullstack-react-router/app/jobs/index.ts | 19 ++- .../app/routes/_index/dashboard.tsx | 24 +++- packages/durably-react/docs/llms.md | 126 +++++++++++------- packages/durably-react/src/client.ts | 1 + packages/durably-react/src/client/index.ts | 1 + packages/durably-react/src/client/use-runs.ts | 116 +++++++++++++--- packages/durably-react/src/hooks/use-runs.ts | 122 ++++++++++++++--- packages/durably-react/src/index.ts | 2 +- website/api/durably-react/browser.md | 116 +++++++++++++--- website/api/durably-react/client.md | 122 ++++++++++++----- website/public/llms.txt | 122 +++++++++++------ 17 files changed, 660 insertions(+), 203 deletions(-) diff --git a/.prettierignore b/.prettierignore index 62c691a5..17745fe1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,5 +5,6 @@ node_modules/ __screenshots__/ website/.vitepress/cache/ website/.vitepress/dist/ +website/public/llms.txt *.log CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f67dbd..fc9af2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +#### @coji/durably-react + +- **Generic type support for `useRuns` hook**: Multiple ways to get type-safe run access + - `useRuns(options)` - Pass type parameter for dashboards with multiple job types + - `useRuns(jobDefinition, options?)` - Pass JobDefinition to infer types and auto-filter by jobName + - `useRuns(options?)` - Untyped usage for simple cases + - New `TypedRun` type for browser hooks + - New `TypedClientRun` type for client hooks + ```tsx + // Dashboard with multiple job types + type DashboardRun = TypedRun | TypedRun + const { runs } = useRuns({ pageSize: 10 }) + + // Single job with auto-filter + const { runs } = useRuns(myJob) + // runs[0].output is typed as the job's output type + ``` + ## [0.6.1] - 2026-01-03 ### Fixed diff --git a/examples/browser-react-router-spa/app/jobs/index.ts b/examples/browser-react-router-spa/app/jobs/index.ts index e87adee6..5539b5e8 100644 --- a/examples/browser-react-router-spa/app/jobs/index.ts +++ b/examples/browser-react-router-spa/app/jobs/index.ts @@ -5,6 +5,19 @@ * When adding a new job, import and add it here. */ -export { dataSyncJob } from './data-sync' -export { importCsvJob, type ImportCsvOutput } from './import-csv' -export { processImageJob } from './process-image' +import type { JobInput, JobOutput } from '@coji/durably' +import { dataSyncJob } from './data-sync' +import { importCsvJob } from './import-csv' +import { processImageJob } from './process-image' + +export { dataSyncJob, importCsvJob, processImageJob } + +/** Type for ImportCsvOutput (for backward compatibility) */ +export type ImportCsvOutput = JobOutput + +/** Input/Output types for all jobs - used for typed useRuns dashboard */ +export type DataSyncInput = JobInput +export type DataSyncOutput = JobOutput +export type ImportCsvInput = JobInput +export type ProcessImageInput = JobInput +export type ProcessImageOutput = JobOutput diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx index f7571c1f..fc9363cf 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -3,16 +3,32 @@ * * Displays run history with real-time updates and pagination. * Uses browser-only mode hooks for direct durably access. + * + * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ import type { Run } from '@coji/durably' -import { useDurably, useRuns } from '@coji/durably-react' +import { type TypedRun, useDurably, useRuns } from '@coji/durably-react' import { useState } from 'react' +import type { + DataSyncInput, + DataSyncOutput, + ImportCsvInput, + ImportCsvOutput, + ProcessImageInput, + ProcessImageOutput, +} from '~/jobs' + +/** Union type for all job runs in this dashboard */ +type DashboardRun = + | TypedRun + | TypedRun + | TypedRun export function Dashboard() { const { durably } = useDurably() const { runs, page, hasMore, isLoading, refresh, nextPage, prevPage } = - useRuns({ + useRuns({ pageSize: 6, }) diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx index f7571c1f..3c1922d7 100644 --- a/examples/browser-vite-react/src/components/dashboard.tsx +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -3,16 +3,29 @@ * * Displays run history with real-time updates and pagination. * Uses browser-only mode hooks for direct durably access. + * + * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ import type { Run } from '@coji/durably' -import { useDurably, useRuns } from '@coji/durably-react' +import { type TypedRun, useDurably, useRuns } from '@coji/durably-react' import { useState } from 'react' +import type { + DataSyncInput, + DataSyncOutput, + ProcessImageInput, + ProcessImageOutput, +} from '../jobs' + +/** Union type for all job runs in this dashboard */ +type DashboardRun = + | TypedRun + | TypedRun export function Dashboard() { const { durably } = useDurably() const { runs, page, hasMore, isLoading, refresh, nextPage, prevPage } = - useRuns({ + useRuns({ pageSize: 6, }) diff --git a/examples/browser-vite-react/src/jobs/index.ts b/examples/browser-vite-react/src/jobs/index.ts index 404d49a4..84be9514 100644 --- a/examples/browser-vite-react/src/jobs/index.ts +++ b/examples/browser-vite-react/src/jobs/index.ts @@ -5,5 +5,14 @@ * When adding a new job, import and add it here. */ -export { dataSyncJob } from './data-sync' -export { processImageJob } from './process-image' +import type { JobInput, JobOutput } from '@coji/durably' +import { dataSyncJob } from './data-sync' +import { processImageJob } from './process-image' + +export { dataSyncJob, processImageJob } + +/** Input/Output types for all jobs - used for typed useRuns dashboard */ +export type DataSyncInput = JobInput +export type DataSyncOutput = JobOutput +export type ProcessImageInput = JobInput +export type ProcessImageOutput = JobOutput diff --git a/examples/fullstack-react-router/app/jobs/index.ts b/examples/fullstack-react-router/app/jobs/index.ts index e87adee6..5539b5e8 100644 --- a/examples/fullstack-react-router/app/jobs/index.ts +++ b/examples/fullstack-react-router/app/jobs/index.ts @@ -5,6 +5,19 @@ * When adding a new job, import and add it here. */ -export { dataSyncJob } from './data-sync' -export { importCsvJob, type ImportCsvOutput } from './import-csv' -export { processImageJob } from './process-image' +import type { JobInput, JobOutput } from '@coji/durably' +import { dataSyncJob } from './data-sync' +import { importCsvJob } from './import-csv' +import { processImageJob } from './process-image' + +export { dataSyncJob, importCsvJob, processImageJob } + +/** Type for ImportCsvOutput (for backward compatibility) */ +export type ImportCsvOutput = JobOutput + +/** Input/Output types for all jobs - used for typed useRuns dashboard */ +export type DataSyncInput = JobInput +export type DataSyncOutput = JobOutput +export type ImportCsvInput = JobInput +export type ProcessImageInput = JobInput +export type ProcessImageOutput = JobOutput diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index a9cd8211..1f1eab30 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -3,15 +3,35 @@ * * Displays run history with real-time updates via SSE and pagination. * First page auto-subscribes to SSE for instant updates. + * + * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ import type { RunRecord, StepRecord } from '@coji/durably-react/client' -import { useRunActions, useRuns } from '@coji/durably-react/client' +import { + type TypedClientRun, + useRunActions, + useRuns, +} from '@coji/durably-react/client' import { useState } from 'react' +import type { + DataSyncInput, + DataSyncOutput, + ImportCsvInput, + ImportCsvOutput, + ProcessImageInput, + ProcessImageOutput, +} from '~/jobs' + +/** Union type for all job runs in this dashboard */ +type DashboardRun = + | TypedClientRun + | TypedClientRun + | TypedClientRun export function Dashboard() { const { runs, isLoading, error, page, hasMore, nextPage, prevPage, refresh } = - useRuns({ + useRuns({ api: '/api/durably', pageSize: 6, }) diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index f2ceffc6..c5b1317a 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -261,28 +261,42 @@ function LogViewer({ runId }: { runId: string | null }) { ### useRuns -List runs with filtering and real-time updates: +List runs with filtering, pagination, and real-time updates: ```tsx -import { useRuns } from '@coji/durably-react' +import { useRuns, TypedRun } from '@coji/durably-react' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedRun<{ file: string }, { count: number }> +type SyncRun = TypedRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun function Dashboard() { - const { runs, isLoading, refresh } = useRuns({ - jobName: 'my-job', // Optional: filter by job - status: 'running', // Optional: filter by status - limit: 10, // Optional: maximum runs - }) + const { runs } = useRuns({ pageSize: 10 }) + // runs are typed as DashboardRun[] + // Use run.jobName to narrow the type +} - return ( -
- - {runs.map((run) => ( -
- {run.jobName}: {run.status} -
- ))} -
- ) +// Option 2: JobDefinition (single job, auto-filters by jobName) +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(myJob, { status: 'completed', pageSize: 10 }) + // runs[0].output is typed as { result: number } | null +} + +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ jobName: 'my-job', pageSize: 10 }) + // runs[0].output is unknown } ``` @@ -421,40 +435,42 @@ function Component({ runId }: { runId: string }) { List runs with pagination and real-time updates: ```tsx -import { useRuns } from '@coji/durably-react/client' +import { useRuns, TypedClientRun } from '@coji/durably-react/client' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedClientRun<{ file: string }, { count: number }> +type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun function Dashboard() { - const { - runs, - page, - hasMore, - isLoading, - nextPage, - prevPage, - goToPage, - refresh, - } = useRuns({ + const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + // runs are typed as DashboardRun[] +} + +// Option 2: JobDefinition (single job, auto-filters by jobName) +const syncDataJob = defineJob({ + name: 'sync-data', + input: z.object({ userId: z.string() }), + output: z.object({ count: z.number() }), + run: async (step, payload) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(syncDataJob, { api: '/api/durably', - jobName: 'sync-data', // Optional: filter by job - status: 'running', // Optional: filter by status - pageSize: 20, // Optional: items per page + status: 'completed', + pageSize: 20, }) + // runs[0].output is typed as { count: number } | null +} - return ( -
- {runs.map((run) => ( -
- {run.jobName}: {run.status} -
- ))} - - -
- ) +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) + // runs[0].output is unknown } ``` @@ -547,6 +563,24 @@ interface LogEntry { data: unknown timestamp: string } + +// Browser hooks: TypedRun with generic input/output +type TypedRun< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> = Omit & { + payload: TInput + output: TOutput | null +} + +// Client hooks: TypedClientRun with generic input/output +type TypedClientRun< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> = Omit & { + input: TInput + output: TOutput | null +} ``` ## Common Patterns diff --git a/packages/durably-react/src/client.ts b/packages/durably-react/src/client.ts index 01fbe68c..e581e4ef 100644 --- a/packages/durably-react/src/client.ts +++ b/packages/durably-react/src/client.ts @@ -31,6 +31,7 @@ export type { export { useRuns } from './client/use-runs' export type { ClientRun, + TypedClientRun, UseRunsClientOptions, UseRunsClientResult, } from './client/use-runs' diff --git a/packages/durably-react/src/client/index.ts b/packages/durably-react/src/client/index.ts index ae88c2e6..b0b119af 100644 --- a/packages/durably-react/src/client/index.ts +++ b/packages/durably-react/src/client/index.ts @@ -31,6 +31,7 @@ export type { export { useRuns } from './use-runs' export type { ClientRun, + TypedClientRun, UseRunsClientOptions, UseRunsClientResult, } from './use-runs' diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index 2ade34bb..6660b26a 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -1,3 +1,4 @@ +import type { JobDefinition } from '@coji/durably' import { useCallback, useEffect, useRef, useState } from 'react' import type { Progress, RunStatus } from '../types' @@ -19,6 +20,19 @@ export interface ClientRun { completedAt: string | null } +/** + * A typed version of ClientRun with generic input/output types. + */ +export type TypedClientRun< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> = Omit & { + input: TInput + output: TOutput | null +} + /** * SSE notification event from /runs/subscribe */ @@ -79,11 +93,14 @@ export interface UseRunsClientOptions { pageSize?: number } -export interface UseRunsClientResult { +export interface UseRunsClientResult< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> { /** * List of runs for the current page */ - runs: ClientRun[] + runs: TypedClientRun[] /** * Current page (0-indexed) */ @@ -123,32 +140,87 @@ export interface UseRunsClientResult { * First page (page 0) automatically subscribes to SSE for real-time updates. * Other pages are static and require manual refresh. * - * @example + * @example With generic type parameter (dashboard with multiple job types) + * ```tsx + * type DashboardRun = TypedClientRun | TypedClientRun + * + * function Dashboard() { + * const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + * // runs are typed as DashboardRun[] + * } + * ``` + * + * @example With JobDefinition (single job, auto-filters by jobName) * ```tsx + * const myJob = defineJob({ name: 'my-job', ... }) + * * function RunHistory() { - * const { runs, page, hasMore, nextPage, prevPage, refresh } = useRuns({ - * api: '/api/durably', - * jobName: 'import-csv', - * pageSize: 10, - * }) + * const { runs } = useRuns(myJob, { api: '/api/durably' }) + * // runs[0].output is typed! + * return
{runs[0]?.output?.someField}
+ * } + * ``` * - * return ( - *
- * {runs.map(run => ( - *
{run.jobName}: {run.status}
- * ))} - * - * - * - *
- * ) + * @example With options only (untyped) + * ```tsx + * function RunHistory() { + * const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + * // runs[0].output is unknown * } * ``` */ -export function useRuns(options: UseRunsClientOptions): UseRunsClientResult { - const { api, jobName, status, pageSize = 10 } = options +// Overload 1: With generic type parameter +export function useRuns< + TRun extends TypedClientRun< + Record, + Record | undefined + >, +>( + options: UseRunsClientOptions, +): UseRunsClientResult< + TRun extends TypedClientRun ? I : Record, + TRun extends TypedClientRun ? O : Record +> + +// Overload 2: With JobDefinition for type inference (auto-filters by jobName) +export function useRuns< + TName extends string, + TInput extends Record, + TOutput extends Record | undefined, +>( + jobDefinition: JobDefinition, + options: Omit, +): UseRunsClientResult + +// Overload 3: Without type parameter (untyped, backward compatible) +export function useRuns(options: UseRunsClientOptions): UseRunsClientResult + +// Implementation +export function useRuns< + TName extends string, + TInput extends Record, + TOutput extends Record | undefined, +>( + jobDefinitionOrOptions: + | JobDefinition + | UseRunsClientOptions, + optionsArg?: Omit, +): UseRunsClientResult { + // Determine if first argument is a JobDefinition + const isJobDefinition = + 'name' in jobDefinitionOrOptions && 'run' in jobDefinitionOrOptions + + const jobName = isJobDefinition + ? jobDefinitionOrOptions.name + : (jobDefinitionOrOptions as UseRunsClientOptions).jobName + + const options = isJobDefinition + ? (optionsArg as Omit) + : (jobDefinitionOrOptions as UseRunsClientOptions) + + const { api, status, pageSize = 10 } = options - const [runs, setRuns] = useState([]) + const [runs, setRuns] = useState[]>([]) const [page, setPage] = useState(0) const [hasMore, setHasMore] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -175,7 +247,7 @@ export function useRuns(options: UseRunsClientOptions): UseRunsClientResult { throw new Error(`Failed to fetch runs: ${response.statusText}`) } - const data = (await response.json()) as ClientRun[] + const data = (await response.json()) as TypedClientRun[] if (isMountedRef.current) { setHasMore(data.length > pageSize) diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 1cd2bc94..2de380cc 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -1,7 +1,20 @@ -import type { Run } from '@coji/durably' +import type { JobDefinition, Run } from '@coji/durably' import { useCallback, useEffect, useState } from 'react' import { useDurably } from '../context' +/** + * A typed version of Run with generic input/output types. + */ +export type TypedRun< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> = Omit & { + payload: TInput + output: TOutput | null +} + export interface UseRunsOptions { /** * Filter by job name @@ -23,11 +36,14 @@ export interface UseRunsOptions { realtime?: boolean } -export interface UseRunsResult { +export interface UseRunsResult< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> { /** * List of runs for the current page */ - runs: Run[] + runs: TypedRun[] /** * Current page (0-indexed) */ @@ -61,31 +77,93 @@ export interface UseRunsResult { /** * Hook for listing runs with pagination and real-time updates. * - * @example + * @example With generic type parameter (dashboard with multiple job types) * ```tsx + * type DashboardRun = TypedRun | TypedRun + * + * function Dashboard() { + * const { runs } = useRuns({ pageSize: 10 }) + * // runs are typed as DashboardRun[] + * } + * ``` + * + * @example With JobDefinition (single job, auto-filters by jobName) + * ```tsx + * const myJob = defineJob({ name: 'my-job', ... }) + * * function Dashboard() { - * const { runs, page, hasMore, nextPage, prevPage, isLoading } = useRuns({ - * pageSize: 20, - * }) + * const { runs } = useRuns(myJob) + * // runs[0].output is typed! + * return
{runs[0]?.output?.someField}
+ * } + * ``` * - * return ( - *
- * {runs.map(run => ( - *
{run.jobName}: {run.status}
- * ))} - * - * - *
- * ) + * @example With options only (untyped) + * ```tsx + * function Dashboard() { + * const { runs } = useRuns({ pageSize: 20 }) + * // runs[0].output is unknown * } * ``` */ -export function useRuns(options?: UseRunsOptions): UseRunsResult { +// Overload 1: With generic type parameter +export function useRuns< + TRun extends TypedRun< + Record, + Record | undefined + >, +>( + options?: UseRunsOptions, +): UseRunsResult< + TRun extends TypedRun ? I : Record, + TRun extends TypedRun ? O : Record +> + +// Overload 2: With JobDefinition for type inference (auto-filters by jobName) +export function useRuns< + TName extends string, + TInput extends Record, + TOutput extends Record | undefined, +>( + jobDefinition: JobDefinition, + options?: Omit, +): UseRunsResult + +// Overload 3: Without type parameter (untyped, backward compatible) +export function useRuns(options?: UseRunsOptions): UseRunsResult + +// Implementation +export function useRuns< + TName extends string, + TInput extends Record, + TOutput extends Record | undefined, +>( + jobDefinitionOrOptions?: + | JobDefinition + | UseRunsOptions, + optionsArg?: Omit, +): UseRunsResult { const { durably } = useDurably() + + // Determine if first argument is a JobDefinition + const isJobDefinition = + jobDefinitionOrOptions && + 'name' in jobDefinitionOrOptions && + 'run' in jobDefinitionOrOptions + + const jobName = isJobDefinition + ? jobDefinitionOrOptions.name + : (jobDefinitionOrOptions as UseRunsOptions | undefined)?.jobName + + const options = isJobDefinition + ? optionsArg + : (jobDefinitionOrOptions as UseRunsOptions | undefined) + const pageSize = options?.pageSize ?? 10 const realtime = options?.realtime ?? true + const status = options?.status - const [runs, setRuns] = useState([]) + const [runs, setRuns] = useState[]>([]) const [page, setPage] = useState(0) const [hasMore, setHasMore] = useState(false) const [isLoading, setIsLoading] = useState(false) @@ -96,17 +174,17 @@ export function useRuns(options?: UseRunsOptions): UseRunsResult { setIsLoading(true) try { const data = await durably.getRuns({ - jobName: options?.jobName, - status: options?.status, + jobName, + status, limit: pageSize + 1, offset: page * pageSize, }) setHasMore(data.length > pageSize) - setRuns(data.slice(0, pageSize)) + setRuns(data.slice(0, pageSize) as TypedRun[]) } finally { setIsLoading(false) } - }, [durably, options?.jobName, options?.status, pageSize, page]) + }, [durably, jobName, status, pageSize, page]) // Initial fetch and subscribe to events useEffect(() => { diff --git a/packages/durably-react/src/index.ts b/packages/durably-react/src/index.ts index 3e75bb57..0f4738c5 100644 --- a/packages/durably-react/src/index.ts +++ b/packages/durably-react/src/index.ts @@ -10,5 +10,5 @@ export type { UseJobLogsOptions, UseJobLogsResult } from './hooks/use-job-logs' export { useJobRun } from './hooks/use-job-run' export type { UseJobRunOptions, UseJobRunResult } from './hooks/use-job-run' export { useRuns } from './hooks/use-runs' -export type { UseRunsOptions, UseRunsResult } from './hooks/use-runs' +export type { TypedRun, UseRunsOptions, UseRunsResult } from './hooks/use-runs' export type { DurablyEvent, LogEntry, Progress, RunStatus } from './types' diff --git a/website/api/durably-react/browser.md b/website/api/durably-react/browser.md index e3ae3a26..8f1bf2e0 100644 --- a/website/api/durably-react/browser.md +++ b/website/api/durably-react/browser.md @@ -239,51 +239,125 @@ function LogViewer({ runId }: { runId: string | null }) { ## useRuns -List runs with optional filtering and real-time updates. +List runs with optional filtering, pagination, and real-time updates. The hook automatically subscribes to Durably events and refreshes the list when runs change. It listens to: - `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:retry` - refresh list - `run:progress` - update progress in place - `step:start`, `step:complete` - refresh for step count updates +### Generic type parameter (dashboard with multiple job types) + +Use a type parameter to specify the run type for dashboards with multiple job types: + +```tsx +import { useRuns, TypedRun } from '@coji/durably-react' + +// Define your run types +type ImportRun = TypedRun<{ file: string }, { count: number }> +type SyncRun = TypedRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun + +function Dashboard() { + const { runs } = useRuns({ pageSize: 10 }) + + return ( +
    + {runs.map(run => ( +
  • + {run.jobName}: {run.status} + {/* Use jobName to narrow the type */} + {run.jobName === 'import-csv' && run.output?.count} +
  • + ))} +
+ ) +} +``` + +### With JobDefinition (single job, auto-filters by jobName) + +Pass a `JobDefinition` to get typed runs and auto-filter by job name: + ```tsx +import { defineJob } from '@coji/durably' import { useRuns } from '@coji/durably-react' +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { /* ... */ }, +}) + function RunList() { - const { runs, isLoading, refresh } = useRuns({ - jobName: 'my-job', - status: 'completed', - limit: 10, - }) + const { runs } = useRuns(myJob, { status: 'completed', pageSize: 10 }) return ( -
- -
    - {runs.map(run => ( -
  • - {run.jobName}: {run.status} - {run.progress && ` (${run.progress.current}/${run.progress.total})`} -
  • - ))} -
-
+
    + {runs.map(run => ( +
  • + {/* run.output is typed as { result: number } | null */} + Result: {run.output?.result} +
  • + ))} +
+ ) +} +``` + +### Without type parameter (untyped) + +```tsx +import { useRuns } from '@coji/durably-react' + +function RunList() { + const { runs } = useRuns({ jobName: 'my-job', pageSize: 10 }) + + return ( +
    + {runs.map(run => ( +
  • + {/* run.output is unknown */} + {run.jobName}: {run.status} +
  • + ))} +
) } ``` +### Signatures + +```ts +// With type parameter (dashboard) +useRuns(options?) + +// With JobDefinition (single job, auto-filters) +useRuns(jobDefinition, options?) + +// Without type parameter (untyped) +useRuns(options?) +``` + ### Options | Option | Type | Description | |--------|------|-------------| -| `jobName` | `string` | Filter by job name | +| `jobName` | `string` | Filter by job name (only for untyped usage) | | `status` | `RunStatus` | Filter by status | -| `limit` | `number` | Maximum number of runs to return | +| `pageSize` | `number` | Number of runs per page (default: 10) | +| `realtime` | `boolean` | Subscribe to real-time updates (default: true) | ### Return Type | Property | Type | Description | |----------|------|-------------| -| `runs` | `Run[]` | List of runs | +| `runs` | `TypedRun[]` | List of runs (typed when using JobDefinition) | +| `page` | `number` | Current page (0-indexed) | +| `hasMore` | `boolean` | Whether more pages exist | | `isLoading` | `boolean` | Loading state | -| `refresh` | `() => void` | Manually refresh the list | +| `nextPage` | `() => void` | Go to next page | +| `prevPage` | `() => void` | Go to previous page | +| `goToPage` | `(page: number) => void` | Go to specific page | +| `refresh` | `() => Promise` | Manually refresh the list | diff --git a/website/api/durably-react/client.md b/website/api/durably-react/client.md index 9223246a..e6fe1460 100644 --- a/website/api/durably-react/client.md +++ b/website/api/durably-react/client.md @@ -214,54 +214,106 @@ The first page (page 0) automatically subscribes to SSE for real-time updates. I Other pages are static and require manual refresh. +### Generic type parameter (dashboard with multiple job types) + +Use a type parameter to specify the run type for dashboards with multiple job types: + ```tsx -import { useRuns } from '@coji/durably-react/client' +import { useRuns, TypedClientRun } from '@coji/durably-react/client' + +// Define your run types +type ImportRun = TypedClientRun<{ file: string }, { count: number }> +type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun function Dashboard() { - const { - runs, - isLoading, - error, - page, - hasMore, - nextPage, - prevPage, - goToPage, - refresh, - } = useRuns({ - api: '/api/durably', - jobName: 'sync-data', // Optional filter - status: 'completed', // Optional filter - pageSize: 10, - }) + const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) return ( -
-
    - {runs.map((run) => ( -
  • - {run.jobName}: {run.status} - {run.progress && ` (${run.progress.current}/${run.progress.total})`} -
  • - ))} -
-
- - Page {page + 1} - - -
-
+
    + {runs.map(run => ( +
  • + {run.jobName}: {run.status} + {/* Use jobName to narrow the type */} + {run.jobName === 'import-csv' && run.output?.count} +
  • + ))} +
) } ``` +### With JobDefinition (single job, auto-filters by jobName) + +Pass a `JobDefinition` to get typed runs and auto-filter by job name: + +```tsx +import { defineJob } from '@coji/durably' +import { useRuns } from '@coji/durably-react/client' + +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { /* ... */ }, +}) + +function RunList() { + const { runs } = useRuns(myJob, { api: '/api/durably', status: 'completed' }) + + return ( +
    + {runs.map(run => ( +
  • + {/* run.output is typed as { result: number } | null */} + Result: {run.output?.result} +
  • + ))} +
+ ) +} +``` + +### Without type parameter (untyped) + +```tsx +import { useRuns } from '@coji/durably-react/client' + +function RunList() { + const { runs } = useRuns({ api: '/api/durably', jobName: 'my-job', pageSize: 10 }) + + return ( +
    + {runs.map(run => ( +
  • + {/* run.output is unknown */} + {run.jobName}: {run.status} +
  • + ))} +
+ ) +} +``` + +### Signatures + +```ts +// With type parameter (dashboard) +useRuns(options) + +// With JobDefinition (single job, auto-filters) +useRuns(jobDefinition, options) + +// Without type parameter (untyped) +useRuns(options) +``` + ### Options | Option | Type | Description | |--------|------|-------------| | `api` | `string` | API base path | -| `jobName` | `string` | Filter by job name | +| `jobName` | `string` | Filter by job name (only for untyped usage) | | `status` | `RunStatus` | Filter by status | | `pageSize` | `number` | Number of runs per page | @@ -269,7 +321,7 @@ function Dashboard() { | Property | Type | Description | |----------|------|-------------| -| `runs` | `RunRecord[]` | List of runs | +| `runs` | `TypedClientRun[]` | List of runs (typed when using JobDefinition) | | `isLoading` | `boolean` | Loading state | | `error` | `string \| null` | Error message | | `page` | `number` | Current page (0-indexed) | diff --git a/website/public/llms.txt b/website/public/llms.txt index c6afd344..784751b9 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -696,28 +696,42 @@ function LogViewer({ runId }: { runId: string | null }) { ### useRuns -List runs with filtering and real-time updates: +List runs with filtering, pagination, and real-time updates: ```tsx -import { useRuns } from '@coji/durably-react' +import { useRuns, TypedRun } from '@coji/durably-react' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedRun<{ file: string }, { count: number }> +type SyncRun = TypedRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun function Dashboard() { - const { runs, isLoading, refresh } = useRuns({ - jobName: 'my-job', // Optional: filter by job - status: 'running', // Optional: filter by status - limit: 10, // Optional: maximum runs - }) + const { runs } = useRuns({ pageSize: 10 }) + // runs are typed as DashboardRun[] + // Use run.jobName to narrow the type +} - return ( -
- - {runs.map((run) => ( -
- {run.jobName}: {run.status} -
- ))} -
- ) +// Option 2: JobDefinition (single job, auto-filters by jobName) +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(myJob, { status: 'completed', pageSize: 10 }) + // runs[0].output is typed as { result: number } | null +} + +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ jobName: 'my-job', pageSize: 10 }) + // runs[0].output is unknown } ``` @@ -856,36 +870,42 @@ function Component({ runId }: { runId: string }) { List runs with pagination and real-time updates: ```tsx -import { useRuns } from '@coji/durably-react/client' +import { useRuns, TypedClientRun } from '@coji/durably-react/client' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedClientRun<{ file: string }, { count: number }> +type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun function Dashboard() { - const { - runs, - page, - hasMore, - isLoading, - nextPage, - prevPage, - goToPage, - refresh, - } = useRuns({ + const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + // runs are typed as DashboardRun[] +} + +// Option 2: JobDefinition (single job, auto-filters by jobName) +const syncDataJob = defineJob({ + name: 'sync-data', + input: z.object({ userId: z.string() }), + output: z.object({ count: z.number() }), + run: async (step, payload) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(syncDataJob, { api: '/api/durably', - jobName: 'sync-data', // Optional: filter by job - status: 'running', // Optional: filter by status - pageSize: 20, // Optional: items per page + status: 'completed', + pageSize: 20, }) + // runs[0].output is typed as { count: number } | null +} - return ( -
- {runs.map((run) => ( -
- {run.jobName}: {run.status} -
- ))} - - -
- ) +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) + // runs[0].output is unknown } ``` @@ -978,6 +998,24 @@ interface LogEntry { data: unknown timestamp: string } + +// Browser hooks: TypedRun with generic input/output +type TypedRun< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> = Omit & { + payload: TInput + output: TOutput | null +} + +// Client hooks: TypedClientRun with generic input/output +type TypedClientRun< + TInput extends Record = Record, + TOutput extends Record | undefined = Record, +> = Omit & { + input: TInput + output: TOutput | null +} ``` ## Common Patterns From 965ff7322913a8852cee682cf7c2e3b084779c59 Mon Sep 17 00:00:00 2001 From: coji Date: Sat, 3 Jan 2026 23:52:39 +0900 Subject: [PATCH 02/11] chore: bump @coji/durably-react to 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update version from 0.6.1 to 0.7.0 for new feature release - Move [Unreleased] changelog entry to [0.7.0] with today's date 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 2 ++ packages/durably-react/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9af2a2..9952b0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.7.0] - 2026-01-03 + ### Added #### @coji/durably-react diff --git a/packages/durably-react/package.json b/packages/durably-react/package.json index 570dc3b1..f1fb0447 100644 --- a/packages/durably-react/package.json +++ b/packages/durably-react/package.json @@ -1,6 +1,6 @@ { "name": "@coji/durably-react", - "version": "0.6.1", + "version": "0.7.0", "description": "React bindings for Durably - step-oriented resumable batch execution", "type": "module", "main": "./dist/index.js", From 33e595b1cbbe82b0cbaa1ab6e2c803fc8c72ea6a Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:02:54 +0900 Subject: [PATCH 03/11] refactor(durably-react): centralize typed run types and improve JobDefinition detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move TypedRun, TypedClientRun, and ClientRun to types.ts for single source of truth - Add isJobDefinition type guard function for robust JobDefinition detection - Re-export types from hooks for backward compatibility - Replace inline 'name' in obj && 'run' in obj checks with type guard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/durably-react/src/client/use-runs.ts | 48 ++++--------- packages/durably-react/src/hooks/use-runs.ts | 28 +++----- packages/durably-react/src/types.ts | 71 ++++++++++++++++++- 3 files changed, 90 insertions(+), 57 deletions(-) diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index 6660b26a..f7ab703a 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -1,37 +1,14 @@ import type { JobDefinition } from '@coji/durably' import { useCallback, useEffect, useRef, useState } from 'react' -import type { Progress, RunStatus } from '../types' +import { + type Progress, + type RunStatus, + type TypedClientRun, + isJobDefinition, +} from '../types' -/** - * Run type for client mode (matches server response) - */ -export interface ClientRun { - id: string - jobName: string - status: RunStatus - input: unknown - output: unknown | null - error: string | null - currentStepIndex: number - stepCount: number - progress: Progress | null - createdAt: string - startedAt: string | null - completedAt: string | null -} - -/** - * A typed version of ClientRun with generic input/output types. - */ -export type TypedClientRun< - TInput extends Record = Record, - TOutput extends Record | undefined = - | Record - | undefined, -> = Omit & { - input: TInput - output: TOutput | null -} +// Re-export types for convenience +export type { ClientRun, TypedClientRun } from '../types' /** * SSE notification event from /runs/subscribe @@ -206,15 +183,14 @@ export function useRuns< | UseRunsClientOptions, optionsArg?: Omit, ): UseRunsClientResult { - // Determine if first argument is a JobDefinition - const isJobDefinition = - 'name' in jobDefinitionOrOptions && 'run' in jobDefinitionOrOptions + // Determine if first argument is a JobDefinition using type guard + const isJob = isJobDefinition(jobDefinitionOrOptions) - const jobName = isJobDefinition + const jobName = isJob ? jobDefinitionOrOptions.name : (jobDefinitionOrOptions as UseRunsClientOptions).jobName - const options = isJobDefinition + const options = isJob ? (optionsArg as Omit) : (jobDefinitionOrOptions as UseRunsClientOptions) diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 2de380cc..790a23d8 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -1,19 +1,10 @@ -import type { JobDefinition, Run } from '@coji/durably' +import type { JobDefinition } from '@coji/durably' import { useCallback, useEffect, useState } from 'react' import { useDurably } from '../context' +import { type TypedRun, isJobDefinition } from '../types' -/** - * A typed version of Run with generic input/output types. - */ -export type TypedRun< - TInput extends Record = Record, - TOutput extends Record | undefined = - | Record - | undefined, -> = Omit & { - payload: TInput - output: TOutput | null -} +// Re-export TypedRun for convenience +export type { TypedRun } from '../types' export interface UseRunsOptions { /** @@ -145,17 +136,14 @@ export function useRuns< ): UseRunsResult { const { durably } = useDurably() - // Determine if first argument is a JobDefinition - const isJobDefinition = - jobDefinitionOrOptions && - 'name' in jobDefinitionOrOptions && - 'run' in jobDefinitionOrOptions + // Determine if first argument is a JobDefinition using type guard + const isJob = isJobDefinition(jobDefinitionOrOptions) - const jobName = isJobDefinition + const jobName = isJob ? jobDefinitionOrOptions.name : (jobDefinitionOrOptions as UseRunsOptions | undefined)?.jobName - const options = isJobDefinition + const options = isJob ? optionsArg : (jobDefinitionOrOptions as UseRunsOptions | undefined) diff --git a/packages/durably-react/src/types.ts b/packages/durably-react/src/types.ts index 5a396c4e..6a47367f 100644 --- a/packages/durably-react/src/types.ts +++ b/packages/durably-react/src/types.ts @@ -1,6 +1,6 @@ // Shared type definitions for @coji/durably-react -import type { JobDefinition } from '@coji/durably' +import type { JobDefinition, Run } from '@coji/durably' // Type inference utilities for extracting Input/Output types from JobDefinition export type InferInput = @@ -101,3 +101,72 @@ export type DurablyEvent = message: string data: unknown } + +// ============================================================================= +// Typed Run types for useRuns hooks +// ============================================================================= + +/** + * A typed version of Run with generic payload/output types. + * Used by browser hooks (direct durably access). + */ +export type TypedRun< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> = Omit & { + payload: TInput + output: TOutput | null +} + +/** + * Run type for client mode (matches server response). + * Used by client hooks (HTTP/SSE connection). + */ +export interface ClientRun { + id: string + jobName: string + status: RunStatus + input: unknown + output: unknown | null + error: string | null + currentStepIndex: number + stepCount: number + progress: Progress | null + createdAt: string + startedAt: string | null + completedAt: string | null +} + +/** + * A typed version of ClientRun with generic input/output types. + * Used by client hooks (HTTP/SSE connection). + */ +export type TypedClientRun< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> = Omit & { + input: TInput + output: TOutput | null +} + +/** + * Type guard to check if an object is a JobDefinition. + * Used to distinguish between JobDefinition and options objects in overloaded functions. + */ +export function isJobDefinition< + TName extends string = string, + TInput extends Record = Record, + TOutput extends Record | undefined = undefined, +>(obj: unknown): obj is JobDefinition { + return ( + typeof obj === 'object' && + obj !== null && + 'name' in obj && + 'run' in obj && + typeof (obj as { run: unknown }).run === 'function' + ) +} From 58397be00340346743b7759de9630dee45090a64 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:06:25 +0900 Subject: [PATCH 04/11] test(durably-react): add type-level tests for useRuns typed generics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests for TypedRun and TypedClientRun type inference - Add tests for UseRunsResult and UseRunsClientResult generic types - Add tests for union types in multi-job dashboard scenarios - Verify pagination controls and error handling types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/durably-react/tests/types.test.ts | 120 +++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/packages/durably-react/tests/types.test.ts b/packages/durably-react/tests/types.test.ts index bb429e71..aa75df4f 100644 --- a/packages/durably-react/tests/types.test.ts +++ b/packages/durably-react/tests/types.test.ts @@ -13,6 +13,11 @@ import { z } from 'zod' 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 { + TypedClientRun, + UseRunsClientResult, +} from '../src/client/use-runs' // Test job definitions const typedJob = defineJob({ @@ -133,4 +138,119 @@ describe('Type inference', () => { expectTypeOf(voidOutputJob.name).toEqualTypeOf<'void-output-job'>() }) }) + + describe('useRuns (browser)', () => { + it('TypedRun has correct payload and output types', () => { + type TestRun = TypedRun<{ taskId: string }, { success: boolean }> + + expectTypeOf().toEqualTypeOf<{ taskId: string }>() + expectTypeOf().toEqualTypeOf<{ + success: boolean + } | null>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + >() + }) + + it('UseRunsResult with generic type has typed runs', () => { + type Result = UseRunsResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toBeArray() + expectTypeOf().toEqualTypeOf<{ + taskId: string + }>() + expectTypeOf().toEqualTypeOf<{ + success: boolean + } | null>() + }) + + it('UseRunsResult has pagination controls', () => { + type Result = UseRunsResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + }) + + it('union types work for multi-job dashboards', () => { + type ImportRun = TypedRun<{ file: string }, { count: number }> + type SyncRun = TypedRun<{ userId: string }, { synced: boolean }> + type DashboardRun = ImportRun | SyncRun + + type Result = UseRunsResult< + DashboardRun extends TypedRun ? I : never, + DashboardRun extends TypedRun ? O : never + > + + // runs array should accept either type + expectTypeOf().toEqualTypeOf< + { file: string } | { userId: string } + >() + }) + }) + + describe('useRuns (client)', () => { + it('TypedClientRun has correct input and output types', () => { + type TestRun = TypedClientRun<{ taskId: string }, { success: boolean }> + + expectTypeOf().toEqualTypeOf<{ taskId: string }>() + expectTypeOf().toEqualTypeOf<{ + success: boolean + } | null>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('UseRunsClientResult with generic type has typed runs', () => { + type Result = UseRunsClientResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toBeArray() + expectTypeOf().toEqualTypeOf<{ + taskId: string + }>() + expectTypeOf().toEqualTypeOf<{ + success: boolean + } | null>() + }) + + it('UseRunsClientResult has pagination and error', () => { + type Result = UseRunsClientResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + expectTypeOf().toBeFunction() + }) + + it('union types work for multi-job dashboards', () => { + type ImportRun = TypedClientRun<{ file: string }, { count: number }> + type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> + type DashboardRun = ImportRun | SyncRun + + type Result = UseRunsClientResult< + DashboardRun extends TypedClientRun ? I : never, + DashboardRun extends TypedClientRun ? O : never + > + + // runs array should accept either type + expectTypeOf().toEqualTypeOf< + { file: string } | { userId: string } + >() + }) + }) }) From 3359791a7cb4bac2931cf95150c84b6ce23001a3 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:08:05 +0900 Subject: [PATCH 05/11] refactor(tests): reorganize type imports and improve readability in useRuns tests --- packages/durably-react/tests/types.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/durably-react/tests/types.test.ts b/packages/durably-react/tests/types.test.ts index aa75df4f..d643da72 100644 --- a/packages/durably-react/tests/types.test.ts +++ b/packages/durably-react/tests/types.test.ts @@ -10,14 +10,14 @@ import { defineJob } from '@coji/durably' import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' -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 { TypedClientRun, 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' // Test job definitions const typedJob = defineJob({ @@ -213,7 +213,10 @@ describe('Type inference', () => { }) it('UseRunsClientResult with generic type has typed runs', () => { - type Result = UseRunsClientResult<{ taskId: string }, { success: boolean }> + type Result = UseRunsClientResult< + { taskId: string }, + { success: boolean } + > expectTypeOf().toBeArray() expectTypeOf().toEqualTypeOf<{ @@ -225,7 +228,10 @@ describe('Type inference', () => { }) it('UseRunsClientResult has pagination and error', () => { - type Result = UseRunsClientResult<{ taskId: string }, { success: boolean }> + type Result = UseRunsClientResult< + { taskId: string }, + { success: boolean } + > expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() From 791e4089b3d45f37c34028e6a2c92af3caada797 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:12:32 +0900 Subject: [PATCH 06/11] fix: align TOutput default type and selectedRun type in examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TOutput default type in UseRunsResult and UseRunsClientResult to match TypedRun/TypedClientRun (Record | undefined) - Update example dashboards to use DashboardRun type for selectedRun instead of Run for better type consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../browser-react-router-spa/app/routes/_index/dashboard.tsx | 5 ++--- examples/browser-vite-react/src/components/dashboard.tsx | 5 ++--- packages/durably-react/src/client/use-runs.ts | 4 +++- packages/durably-react/src/hooks/use-runs.ts | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx index fc9363cf..56ba5b9c 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -7,7 +7,6 @@ * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ -import type { Run } from '@coji/durably' import { type TypedRun, useDurably, useRuns } from '@coji/durably-react' import { useState } from 'react' import type { @@ -32,7 +31,7 @@ export function Dashboard() { pageSize: 6, }) - const [selectedRun, setSelectedRun] = useState(null) + const [selectedRun, setSelectedRun] = useState(null) const [steps, setSteps] = useState< { index: number; name: string; status: string }[] >([]) @@ -41,7 +40,7 @@ export function Dashboard() { if (!durably) return const run = await durably.getRun(runId) if (run) { - setSelectedRun(run) + setSelectedRun(run as DashboardRun) const stepsData = await durably.storage.getSteps(runId) setSteps( stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx index 3c1922d7..fcd91e8e 100644 --- a/examples/browser-vite-react/src/components/dashboard.tsx +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -7,7 +7,6 @@ * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ -import type { Run } from '@coji/durably' import { type TypedRun, useDurably, useRuns } from '@coji/durably-react' import { useState } from 'react' import type { @@ -29,7 +28,7 @@ export function Dashboard() { pageSize: 6, }) - const [selectedRun, setSelectedRun] = useState(null) + const [selectedRun, setSelectedRun] = useState(null) const [steps, setSteps] = useState< { index: number; name: string; status: string }[] >([]) @@ -38,7 +37,7 @@ export function Dashboard() { if (!durably) return const run = await durably.getRun(runId) if (run) { - setSelectedRun(run) + setSelectedRun(run as DashboardRun) const stepsData = await durably.storage.getSteps(runId) setSteps( stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts index f7ab703a..9afd619c 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -72,7 +72,9 @@ export interface UseRunsClientOptions { export interface UseRunsClientResult< TInput extends Record = Record, - TOutput extends Record | undefined = Record, + TOutput extends Record | undefined = + | Record + | undefined, > { /** * List of runs for the current page diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts index 790a23d8..343798f7 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -29,7 +29,9 @@ export interface UseRunsOptions { export interface UseRunsResult< TInput extends Record = Record, - TOutput extends Record | undefined = Record, + TOutput extends Record | undefined = + | Record + | undefined, > { /** * List of runs for the current page From 6611020cfacb2b5d81073c5e41e45410281dd6af Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:20:20 +0900 Subject: [PATCH 07/11] feat(durably): add generic type parameter to getRun/getRuns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add generic type parameter to getRun() and getRuns() for type-safe run retrieval - Update examples to use typed getRun() instead of type cast - Update CHANGELOG with new feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 12 ++++++++++ .../app/routes/_index/dashboard.tsx | 4 ++-- .../src/components/dashboard.tsx | 4 ++-- packages/durably/src/durably.ts | 24 ++++++++++++++++--- packages/durably/src/storage.ts | 12 +++++----- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9952b0fe..b6d757d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +#### @coji/durably + +- **Generic type parameter for `getRun()` and `getRuns()`**: Type-safe run retrieval + ```ts + // Untyped (returns Run) + const run = await durably.getRun(runId) + + // Typed (returns custom type) + type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } + const typedRun = await durably.getRun(runId) + ``` + #### @coji/durably-react - **Generic type support for `useRuns` hook**: Multiple ways to get type-safe run access diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx index 56ba5b9c..5cc4eec5 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -38,9 +38,9 @@ export function Dashboard() { const showDetails = async (runId: string) => { if (!durably) return - const run = await durably.getRun(runId) + const run = await durably.getRun(runId) if (run) { - setSelectedRun(run as DashboardRun) + setSelectedRun(run) const stepsData = await durably.storage.getSteps(runId) setSteps( stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx index fcd91e8e..ec0d473d 100644 --- a/examples/browser-vite-react/src/components/dashboard.tsx +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -35,9 +35,9 @@ export function Dashboard() { const showDetails = async (runId: string) => { if (!durably) return - const run = await durably.getRun(runId) + const run = await durably.getRun(runId) if (run) { - setSelectedRun(run as DashboardRun) + setSelectedRun(run) const stepsData = await durably.storage.getSteps(runId) setSteps( stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index 1c341ede..8aab0db1 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -176,14 +176,32 @@ export interface Durably< deleteRun(runId: string): Promise /** - * Get a run by ID (returns unknown output type) + * Get a run by ID + * @example + * ```ts + * // Untyped (returns Run) + * const run = await durably.getRun(runId) + * + * // Typed (returns custom type) + * type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } + * const typedRun = await durably.getRun(runId) + * ``` */ - getRun(runId: string): Promise + getRun(runId: string): Promise /** * Get runs with optional filtering + * @example + * ```ts + * // Untyped (returns Run[]) + * const runs = await durably.getRuns({ status: 'completed' }) + * + * // Typed (returns custom type[]) + * type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } + * const typedRuns = await durably.getRuns({ jobName: 'my-job' }) + * ``` */ - getRuns(filter?: RunFilter): Promise + getRuns(filter?: RunFilter): Promise /** * Register a plugin diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 6056ee46..5f98e4d5 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -117,8 +117,8 @@ export interface Storage { batchCreateRuns(inputs: CreateRunInput[]): Promise updateRun(runId: string, data: UpdateRunInput): Promise deleteRun(runId: string): Promise - getRun(runId: string): Promise - getRuns(filter?: RunFilter): Promise + getRun(runId: string): Promise + getRuns(filter?: RunFilter): Promise getNextPendingRun(excludeConcurrencyKeys: string[]): Promise // Step operations @@ -317,7 +317,7 @@ export function createKyselyStorage(db: Kysely): Storage { await db.deleteFrom('durably_runs').where('id', '=', runId).execute() }, - async getRun(runId: string): Promise { + async getRun(runId: string): Promise { const row = await db .selectFrom('durably_runs') .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') @@ -329,10 +329,10 @@ export function createKyselyStorage(db: Kysely): Storage { .groupBy('durably_runs.id') .executeTakeFirst() - return row ? rowToRun(row) : null + return row ? (rowToRun(row) as T) : null }, - async getRuns(filter?: RunFilter): Promise { + async getRuns(filter?: RunFilter): Promise { let query = db .selectFrom('durably_runs') .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') @@ -363,7 +363,7 @@ export function createKyselyStorage(db: Kysely): Storage { } const rows = await query.execute() - return rows.map(rowToRun) + return rows.map(rowToRun) as T[] }, async getNextPendingRun( From 63a921241cb6b9a4f277ae82976f955ae084c6ac Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:22:36 +0900 Subject: [PATCH 08/11] docs: update llms.md and website for generic getRun/getRuns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add typed getRun/getRuns examples to llms.md - Update website API docs with generic type parameter documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/durably/docs/llms.md | 10 +++++++++- website/api/create-durably.md | 23 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 5d5404a4..8c8f9708 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -145,8 +145,12 @@ if (run?.status === 'completed') { console.log(run.output.syncedCount) } -// Via durably instance (cross-job) +// Via durably instance (untyped) const run = await durably.getRun(runId) + +// Via durably instance (typed with generic parameter) +type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +const typedRun = await durably.getRun(runId) ``` ### Query Runs @@ -162,6 +166,10 @@ const runs = await durably.getRuns({ limit: 10, offset: 0, }) + +// Typed getRuns with generic parameter +type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +const typedRuns = await durably.getRuns({ jobName: 'my-job' }) ``` ### Retry Failed Runs diff --git a/website/api/create-durably.md b/website/api/create-durably.md index 306b2729..14c3aae8 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -122,15 +122,24 @@ Deletes a run and its associated steps and logs. ### `getRun()` ```ts -await durably.getRun(runId: string): Promise +await durably.getRun(runId: string): Promise ``` -Gets a single run by ID. +Gets a single run by ID. Supports generic type parameter for type-safe access. + +```ts +// Untyped (returns Run) +const run = await durably.getRun(runId) + +// Typed (returns custom type) +type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +const typedRun = await durably.getRun(runId) +``` ### `getRuns()` ```ts -await durably.getRuns(filter?: RunFilter): Promise +await durably.getRuns(filter?: RunFilter): Promise interface RunFilter { jobName?: string @@ -140,7 +149,13 @@ interface RunFilter { } ``` -Gets runs with optional filtering and pagination. +Gets runs with optional filtering and pagination. Supports generic type parameter. + +```ts +// Typed getRuns +type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +const runs = await durably.getRuns({ jobName: 'my-job' }) +``` ### `getJob()` From 78c282f46e1deb31a9712ccea1394ce496c2707b Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 00:53:05 +0900 Subject: [PATCH 09/11] chore: bump @coji/durably to 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/durably/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/durably/package.json b/packages/durably/package.json index cb196526..0ca9768d 100644 --- a/packages/durably/package.json +++ b/packages/durably/package.json @@ -1,6 +1,6 @@ { "name": "@coji/durably", - "version": "0.6.1", + "version": "0.7.0", "description": "Step-oriented resumable batch execution for Node.js and browsers using SQLite", "type": "module", "main": "./dist/index.js", From cce96e3d1526355440a13e274f9b55074f9f919b Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 01:03:28 +0900 Subject: [PATCH 10/11] style: fix formatting in llms.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/durably/docs/llms.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 8c8f9708..1db1e25a 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -149,7 +149,10 @@ if (run?.status === 'completed') { const run = await durably.getRun(runId) // Via durably instance (typed with generic parameter) -type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} const typedRun = await durably.getRun(runId) ``` @@ -168,7 +171,10 @@ const runs = await durably.getRuns({ }) // Typed getRuns with generic parameter -type MyRun = Run & { payload: { userId: string }; output: { count: number } | null } +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} const typedRuns = await durably.getRuns({ jobName: 'my-job' }) ``` From 3c96ca61b519b75b482ec9d8cb4adc28bc80e8b0 Mon Sep 17 00:00:00 2001 From: coji Date: Sun, 4 Jan 2026 01:03:45 +0900 Subject: [PATCH 11/11] feat: add typed support for getRun and getRuns with generic parameters --- website/public/llms.txt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/website/public/llms.txt b/website/public/llms.txt index 784751b9..861c9ad8 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -145,8 +145,15 @@ if (run?.status === 'completed') { console.log(run.output.syncedCount) } -// Via durably instance (cross-job) +// Via durably instance (untyped) const run = await durably.getRun(runId) + +// Via durably instance (typed with generic parameter) +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} +const typedRun = await durably.getRun(runId) ``` ### Query Runs @@ -162,6 +169,13 @@ const runs = await durably.getRuns({ limit: 10, offset: 0, }) + +// Typed getRuns with generic parameter +type MyRun = Run & { + payload: { userId: string } + output: { count: number } | null +} +const typedRuns = await durably.getRuns({ jobName: 'my-job' }) ``` ### Retry Failed Runs