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..b6d757d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ 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] + +## [0.7.0] - 2026-01-03 + +### 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 + - `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..5cc4eec5 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -3,27 +3,42 @@ * * 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, }) - const [selectedRun, setSelectedRun] = useState(null) + const [selectedRun, setSelectedRun] = useState(null) const [steps, setSteps] = useState< { index: number; name: string; status: string }[] >([]) const showDetails = async (runId: string) => { if (!durably) return - const run = await durably.getRun(runId) + const run = await durably.getRun(runId) if (run) { setSelectedRun(run) const stepsData = await durably.storage.getSteps(runId) diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx index f7571c1f..ec0d473d 100644 --- a/examples/browser-vite-react/src/components/dashboard.tsx +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -3,27 +3,39 @@ * * 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, }) - const [selectedRun, setSelectedRun] = useState(null) + const [selectedRun, setSelectedRun] = useState(null) const [steps, setSteps] = useState< { index: number; name: string; status: string }[] >([]) const showDetails = async (runId: string) => { if (!durably) return - const run = await durably.getRun(runId) + const run = await durably.getRun(runId) if (run) { setSelectedRun(run) const stepsData = await durably.storage.getSteps(runId) 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/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", 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..9afd619c 100644 --- a/packages/durably-react/src/client/use-runs.ts +++ b/packages/durably-react/src/client/use-runs.ts @@ -1,23 +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 -} +// Re-export types for convenience +export type { ClientRun, TypedClientRun } from '../types' /** * SSE notification event from /runs/subscribe @@ -79,11 +70,16 @@ export interface UseRunsClientOptions { pageSize?: number } -export interface UseRunsClientResult { +export interface UseRunsClientResult< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> { /** * List of runs for the current page */ - runs: ClientRun[] + runs: TypedClientRun[] /** * Current page (0-indexed) */ @@ -123,32 +119,86 @@ 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 using type guard + const isJob = isJobDefinition(jobDefinitionOrOptions) + + const jobName = isJob + ? jobDefinitionOrOptions.name + : (jobDefinitionOrOptions as UseRunsClientOptions).jobName + + const options = isJob + ? (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 +225,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..343798f7 100644 --- a/packages/durably-react/src/hooks/use-runs.ts +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -1,6 +1,10 @@ -import type { 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' + +// Re-export TypedRun for convenience +export type { TypedRun } from '../types' export interface UseRunsOptions { /** @@ -23,11 +27,16 @@ export interface UseRunsOptions { realtime?: boolean } -export interface UseRunsResult { +export interface UseRunsResult< + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, +> { /** * List of runs for the current page */ - runs: Run[] + runs: TypedRun[] /** * Current page (0-indexed) */ @@ -61,31 +70,90 @@ 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, page, hasMore, nextPage, prevPage, isLoading } = useRuns({ - * pageSize: 20, - * }) + * 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', ... }) * - * return ( - *
- * {runs.map(run => ( - *
{run.jobName}: {run.status}
- * ))} - * - * - *
- * ) + * function Dashboard() { + * const { runs } = useRuns(myJob) + * // runs[0].output is typed! + * return
{runs[0]?.output?.someField}
+ * } + * ``` + * + * @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 using type guard + const isJob = isJobDefinition(jobDefinitionOrOptions) + + const jobName = isJob + ? jobDefinitionOrOptions.name + : (jobDefinitionOrOptions as UseRunsOptions | undefined)?.jobName + + const options = isJob + ? 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 +164,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/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' + ) +} diff --git a/packages/durably-react/tests/types.test.ts b/packages/durably-react/tests/types.test.ts index bb429e71..d643da72 100644 --- a/packages/durably-react/tests/types.test.ts +++ b/packages/durably-react/tests/types.test.ts @@ -10,9 +10,14 @@ import { defineJob } from '@coji/durably' import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' +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({ @@ -133,4 +138,125 @@ 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 } + >() + }) + }) }) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 5d5404a4..1db1e25a 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -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 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", 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( 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()` 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..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 @@ -696,28 +710,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 +884,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 +1012,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