diff --git a/.gitignore b/.gitignore index 8c4a42e8..69f5fbd5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,11 @@ dist/ __screenshots__/ local.db .serena/ +.turbo/ # VitePress website/.vitepress/cache/ website/.vitepress/dist/ + +# test coverage +coverage \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index bd5535a6..62c691a5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,9 @@ pnpm-lock.yaml +dist/ +node_modules/ +.turbo/ +__screenshots__/ +website/.vitepress/cache/ +website/.vitepress/dist/ +*.log +CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 43271f0f..698beb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,107 @@ 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/). +## [0.6.0] - 2026-01-02 + +### Breaking Changes + +#### @coji/durably + +- **`register()` API simplified**: `registerAll()` renamed to `register()`, old single-job signature removed + ```diff + - const job = durably.register(jobDef) + + const { job } = durably.register({ job: jobDef }) + ``` + +#### @coji/durably-react + +- **`DurablyProvider` simplified**: Removed `autoStart`, `onReady` props and `isReady` state + - Provider now only provides context; durably instance must be initialized before passing + - All hooks no longer return `isReady` - always ready when durably is available + - Migration: Call `await durably.init()` before passing to `DurablyProvider` + ```diff + - console.log('ready')}> + + + ``` +- **React 19 required**: `peerDependencies` now requires React 19+ (uses `React.use()` hook) + +### Added + +#### @coji/durably + +- **`init()` method**: Combines `migrate()` and `start()` for simpler initialization + ```ts + const durably = createDurably({ dialect }) + await durably.init() // migrate + start in one call + ``` +- **Type-safe `durably.jobs` property**: Access registered jobs with full type inference + ```ts + const durably = createDurably({ dialect }).register({ processImage, syncUsers }) + await durably.jobs.processImage.trigger({ imageId: '123' }) // Type-safe + ``` +- **Retry from cancelled state**: `retry()` now works on both `failed` and `cancelled` runs +- **New events**: `run:trigger`, `run:cancel`, `run:retry` for complete run lifecycle tracking +- **`stepCount` on `Run` type**: Number of completed steps, available in `getRun()`, `getRuns()` + +#### @coji/durably/server + +- **New endpoints**: `GET /steps?runId=xxx`, `DELETE /run?runId=xxx` +- **SSE enhancements**: `/runs/subscribe` now streams all lifecycle events + - Run events: `run:trigger`, `run:start`, `run:complete`, `run:fail`, `run:cancel`, `run:retry`, `run:progress` + - Step events: `step:start`, `step:complete`, `step:fail` + - Log events: `log:write` + +#### @coji/durably-react + +- **`useRuns` hook**: List and paginate runs with filtering (`jobName`, `status`) and real-time updates + - Subscribes to step and progress events for live dashboard updates +- **`useJob` options**: `autoResume` (track pending/running jobs on mount), `followLatest` (switch to latest run) +- **`createDurablyClient`**: Type-safe client factory for server-connected mode +- **`createJobHooks`**: Per-job hook factory for server-connected mode + +#### @coji/durably-react/client + +- **`useRunActions` enhancements**: `deleteRun()`, `getRun()`, `getSteps()` +- **Step progress**: `stepCount` and `currentStepIndex` on `ClientRun` and `RunRecord` types +- **New type exports**: `RunRecord`, `StepRecord` + +### Changed + +- Simplified README files - detailed documentation moved to website +- Updated all examples to use new `register()` API pattern +- Added Turbo for monorepo task orchestration +- Unified dashboard UI across all examples + +### Fixed + +- Type inference for `register()` return value now works correctly +- SSE stream lifecycle properly cleaned up on client disconnect + ## [0.5.0] - 2025-12-24 ### Added +#### @coji/durably + - `run:progress` event: Now emitted when `step.progress()` is called - Enables real-time progress tracking via event subscription - Event payload: `{ runId, jobName, progress: { current, total?, message? } }` +- `getJob(name)`: Retrieve a registered job by name +- `subscribe(runId)`: Subscribe to run events as a ReadableStream +- `createDurablyHandler(durably)`: Create HTTP handlers for client/server architecture + +#### @coji/durably-react (New Package) + +- Initial release of React bindings for Durably +- **Browser-complete mode**: Run Durably entirely in the browser with SQLite WASM + - `DurablyProvider`: React context provider for Durably instance + - `useDurably`: Access the Durably instance directly + - `useJob`: Trigger and monitor a job with real-time updates + - `useJobRun`: Subscribe to an existing run by ID + - `useJobLogs`: Subscribe to logs from a run +- **Server-connected mode**: Connect to a remote Durably server via SSE + - `useJob`, `useJobRun`, `useJobLogs` from `@coji/durably-react/client` + - Works with `createDurablyHandler` on the server ## [0.4.0] - 2025-12-23 @@ -19,8 +113,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **New API pattern**: `defineJob()` + `durably.register()` replaces `durably.defineJob()` - `defineJob()` is now a standalone function that creates a `JobDefinition` - - `durably.register(jobDef)` registers the definition and returns a `JobHandle` + - `durably.register({ name: jobDef })` registers jobs and returns an object of `JobHandle`s - This enables idempotent registration (safe for React StrictMode) + - Supports registering multiple jobs in a single call ### Migration @@ -34,15 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - }, async (step, payload) => { - // ... - }) -+ const myJob = durably.register( -+ defineJob({ -+ name: 'my-job', -+ input: z.object({ id: z.string() }), -+ run: async (step, payload) => { -+ // ... -+ }, -+ }) -+ ) ++ const myJobDef = defineJob({ ++ name: 'my-job', ++ input: z.object({ id: z.string() }), ++ run: async (step, payload) => { ++ // ... ++ }, ++ }) ++ const { myJob } = durably.register({ myJob: myJobDef }) ``` ### Removed diff --git a/CLAUDE.md b/CLAUDE.md index 97ede353..41ba138a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,13 +20,14 @@ When API changes are made, update `packages/durably/docs/llms.md` to keep it in ## Core Concepts -- **Job**: Defined via `durably.defineJob()`, receives a step context and payload +- **Job**: Defined via `defineJob()` and registered with `durably.register()`, receives a step context and payload - **Step**: Created via `step.run()`, each step's success state and return value is persisted - **Run**: A job execution instance, created via `trigger()`, always persisted as `pending` before execution - **Worker**: Polls for pending runs and executes them sequentially ## Key Design Decisions +- **ESM-only**: This library is ESM-only. CommonJS is not supported. Always use top-level `await` for async initialization (e.g., `await durably.migrate()`). Do not wrap in async IIFE or Promise chains. - Single-threaded execution, no parallel run processing in minimal config - No automatic retry - failures are immediate and explicit (`retry()` API for manual retry) - Dialect injection pattern - Kysely dialect passed to `createDurably()` to abstract SQLite implementations diff --git a/README.md b/README.md index c8e581e6..55e4c901 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ Step-oriented resumable batch execution for Node.js and browsers using SQLite. **[Documentation](https://coji.github.io/durably/)** | **[Live Demo](https://durably-demo.vercel.app)** +## Packages + +| Package | Description | +|---------|-------------| +| [@coji/durably](./packages/durably) | Core library - job definitions, steps, and persistence | +| [@coji/durably-react](./packages/durably-react) | React bindings - hooks for triggering and monitoring jobs | + ## Features - Resumable batch processing with step-level persistence @@ -13,62 +20,9 @@ Step-oriented resumable batch execution for Node.js and browsers using SQLite. - Event system for monitoring and extensibility - Type-safe input/output with Zod schemas -## Installation - -```bash -# Node.js with better-sqlite3 -npm install @coji/durably kysely zod better-sqlite3 - -# Node.js with libsql -npm install @coji/durably kysely zod @libsql/client @libsql/kysely-libsql - -# Browser with SQLocal -npm install @coji/durably kysely zod sqlocal -``` - -## Usage - -```ts -import { createDurably } from '@coji/durably' -import SQLite from 'better-sqlite3' -import { SqliteDialect } from 'kysely' -import { z } from 'zod' - -const dialect = new SqliteDialect({ - database: new SQLite('local.db'), -}) - -const durably = createDurably({ dialect }) - -const syncUsers = durably.defineJob( - { - name: 'sync-users', - input: z.object({ orgId: z.string() }), - output: z.object({ syncedCount: z.number() }), - }, - async (step, payload) => { - const users = await step.run('fetch-users', async () => { - return api.fetchUsers(payload.orgId) - }) - - await step.run('save-to-db', async () => { - await db.upsertUsers(users) - }) - - return { syncedCount: users.length } - }, -) - -await durably.migrate() -durably.start() - -await syncUsers.trigger({ orgId: 'org_123' }) -``` - -## Documentation +## Quick Start -- [Specification](docs/spec.md) - Core API and concepts -- [Streaming Extension](docs/spec-streaming.md) - AI Agent workflow support (conceptual, not yet implemented) +See the [Getting Started Guide](https://coji.github.io/durably/guide/getting-started) for installation and usage instructions. ## License diff --git a/biome.json b/biome.json index 0761b8ba..0095410c 100644 --- a/biome.json +++ b/biome.json @@ -1,30 +1,31 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", - "files": { - "includes": ["**"] - }, - "assist": { "actions": { "source": { "organizeImports": "off" } } }, - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - } - }, - "overrides": [ - { - "includes": ["**/tests/**"], - "linter": { - "rules": { - "style": { - "noNonNullAssertion": "off" - } - } - } - } - ] + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "root": true, + "files": { + "includes": ["**"] + }, + "assist": { "actions": { "source": { "organizeImports": "off" } } }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "overrides": [ + { + "includes": ["**/tests/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] } diff --git a/docs/spec-react.md b/docs/spec-react.md index cb450bc5..65c682fa 100644 --- a/docs/spec-react.md +++ b/docs/spec-react.md @@ -1,42 +1,114 @@ # @coji/durably-react 仕様書 -## Why: なぜ React 統合が必要か +## 概要 -Durably をブラウザの React アプリケーションで使用する場合、以下の課題が発生する。 +`@coji/durably-react` は、Durably を React アプリケーションで使うためのバインディングである。 -1. **ライフサイクル管理の複雑さ**: Durably インスタンスの初期化・終了を React のライフサイクルに合わせる必要がある -2. **イベントリスナーの蓄積**: コンポーネントのマウント/アンマウントでリスナーが適切にクリーンアップされない -3. **状態管理のボイラープレート**: Run のステータス、進捗、ログを React の状態として管理するコードが冗長 -4. **型安全性の欠如**: イベントハンドラやジョブ出力の型が失われやすい +以下の2つの動作モードをサポートする: -React 統合パッケージはこれらを解決し、宣言的で型安全な API を提供する。 +| モード | 説明 | サーバー | クライアント | +|--------|------|----------|--------------| +| **ブラウザ完結** | ブラウザ内で Durably を実行 | 不要 | `@coji/durably-react` + `@coji/durably` | +| **サーバー連携** | サーバーで Durably を実行、クライアントで購読 | `@coji/durably` | `@coji/durably-react/client`(軽量) | --- -## What: これは何か +## パッケージ構成 -`@coji/durably-react` は、Durably を React アプリケーションで使うためのバインディングである。 +```text +@coji/durably-react +├── index.ts # ブラウザ完結モード用(DurablyProvider + hooks) +└── client/index.ts # サーバー連携モード用(軽量、@coji/durably 不要) + +@coji/durably +└── server.ts # サーバー側ヘルパー(Web 標準 API) +``` -### 提供するもの +--- -| Export | 説明 | -| ------------------ | -------------------------------------------- | -| `DurablyProvider` | Durably インスタンスのライフサイクル管理 | -| `useDurably()` | Durably インスタンスと初期化状態へのアクセス | -| `useJob(job)` | ジョブの実行とステータス管理 | -| `useJobRun(runId)` | 特定の Run のステータス購読 | -| `useJobLogs()` | リアルタイムログ購読 | +## パターン A: ブラウザ完結モード -※ `defineJob` は `@coji/durably` から直接 import する。 +ブラウザ内で SQLite(OPFS)を使い、Durably を完全にクライアントサイドで実行する。 ---- +### セットアップ + +Durably インスタンスは Promise として export し、DurablyProvider に直接渡す: + +```ts +// lib/durably.ts +import { createDurably, type Durably } from '@coji/durably' +import { SQLocalKysely } from 'sqlocal/kysely' +import { processImageJob } from './jobs' -## 基本的な使い方 +export { processImageJob } + +export const sqlocal = new SQLocalKysely('app.sqlite3') + +async function initDurably(): Promise { + const instance = createDurably({ + dialect: sqlocal.dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, + }) + await instance.migrate() + instance.register({ processImage: processImageJob }) + return instance +} + +/** Shared Durably instance promise */ +export const durably = initDurably() +``` ```tsx -// ======================================== -// 1. ジョブ定義(React の外、静的) -// ======================================== +// App.tsx +import { DurablyProvider } from '@coji/durably-react' +import { durably } from './lib/durably' + +function Loading() { + return
Loading...
+} + +export function App() { + return ( + }> + + + ) +} +``` + +`DurablyProvider` は `Durably` または `Promise` を受け付ける。Promise の場合は内部で React 19 の `use()` を使って解決する。`fallback` を指定すると自動的に Suspense でラップされる。 + +React Router 7 の場合は `clientLoader` を使用することもできる: + +```tsx +// root.tsx +import { DurablyProvider } from '@coji/durably-react' +import { Outlet } from 'react-router' +import { getDurably } from './lib/durably' + +export async function clientLoader() { + const durably = await getDurably() + return { durably } +} + +export function HydrateFallback() { + return
Loading...
+} + +export default function App({ loaderData }) { + return ( + + + + ) +} +``` + +### ジョブ定義 + +```ts // jobs.ts import { defineJob } from '@coji/durably' import { z } from 'zod' @@ -47,37 +119,23 @@ export const processTask = defineJob({ output: z.object({ success: z.boolean() }), run: async (step, payload) => { await step.run('validate', () => validate(payload.taskId)) + step.progress(1, 2, 'Validating...') await step.run('process', () => process(payload.taskId)) + step.progress(2, 2, 'Processing...') return { success: true } }, }) +``` -// ======================================== -// 2. Provider(root) -// ======================================== -// root.tsx -import { DurablyProvider } from '@coji/durably-react' -import { SQLocalKysely } from 'sqlocal/kysely' - -export default function App() { - return ( - new SQLocalKysely('app.sqlite3').dialect} - > - - - ) -} +### 使用 -// ======================================== -// 3. 使用(シンプル) -// ======================================== +```tsx // component.tsx import { useJob } from '@coji/durably-react' import { processTask } from './jobs' function TaskRunner() { - const { trigger, status, output, isRunning } = useJob(processTask) + const { trigger, status, output, progress, isRunning } = useJob(processTask) return (
@@ -88,7 +146,11 @@ function TaskRunner() { {isRunning ? 'Processing...' : 'Process Task'} - {status === 'completed' &&
Done: {output?.success ? '✓' : '✗'}
} + {progress && ( + + )} + + {status === 'completed' &&
Done: {output?.success ? 'Yes' : 'No'}
}
) } @@ -96,302 +158,488 @@ function TaskRunner() { --- -## API 仕様 +## パターン B: サーバー連携モード -### DurablyProvider +サーバーで Durably を実行し、クライアントは HTTP/SSE で接続する。 -Durably インスタンスを作成し、子コンポーネントに提供する。 +### サーバー側(Web 標準 API) -```tsx -import { DurablyProvider } from '@coji/durably-react' -import { SQLocalKysely } from 'sqlocal/kysely' - -function App() { - return ( - new SQLocalKysely('app.sqlite3').dialect} - options={{ - pollingInterval: 1000, - heartbeatInterval: 5000, - staleThreshold: 30000, - }} - > - - - ) -} -``` - -#### Props - -| Prop | 型 | 必須 | 説明 | -| ---------------- | ------------------ | ---- | -------------------------------------------------------------- | -| `dialectFactory` | `() => Dialect` | ✓ | Dialect を生成するファクトリ関数(Provider 内部で一度だけ実行)| -| `options` | `DurablyOptions` | - | Durably 設定オプション | -| `autoStart` | `boolean` | - | マウント時に自動で `start()` を呼ぶ(デフォルト: true) | -| `autoMigrate` | `boolean` | - | マウント時に自動で `migrate()` を呼ぶ(デフォルト: true) | -| `children` | `ReactNode` | ✓ | 子コンポーネント | - -#### なぜ `dialectFactory` なのか - -コアライブラリの `createDurably({ dialect })` は dialect インスタンスを直接受け取る。これはアプリケーション起動時に一度だけ呼ばれるためである。 +```ts +// app/routes/api.durably.ts (React Router / Remix example) +import { createDurablyHandler } from '@coji/durably/server' +import { durably } from '~/lib/durably.server' -一方、React コンポーネントは再レンダリングのたびに関数が実行される。`dialect` を直接渡すと毎回新しいインスタンスが生成されてしまう。`dialectFactory` はファクトリ関数を受け取り、Provider 内部で一度だけ実行することでこの問題を回避する。 +const handler = createDurablyHandler(durably) -#### ライフサイクル +// 全ルートを自動処理(推奨) +export async function loader({ request }: LoaderFunctionArgs) { + return handler.handle(request, '/api/durably') +} -1. **マウント時**: `dialectFactory()` → `createDurably()` → `migrate()` → `start()` の順で初期化 -2. **アンマウント時**: `stop()` を呼び、イベントリスナーをすべて解除 -3. **Strict Mode**: 二重マウントでも正しく動作(ref で初期化済みフラグを管理) +export async function action({ request }: ActionFunctionArgs) { + return handler.handle(request, '/api/durably') +} +``` -### useDurably +または手動で実装: -Durably インスタンスと初期化状態を取得する。通常は `useJob` を使うため、直接使用することは少ない。 +```ts +// POST /api/durably/trigger +export async function action({ request }: ActionFunctionArgs) { + const { jobName, input } = await request.json() + const job = durably.getJob(jobName) + const run = await job.trigger(input) + return Response.json({ runId: run.id }) +} -```tsx -import { useDurably } from '@coji/durably-react' +// GET /api/durably/subscribe?runId=xxx +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') -function MyComponent() { - const { durably, isReady, error } = useDurably() + if (!runId) { + return new Response('Missing runId', { status: 400 }) + } - if (error) return
Error: {error.message}
- if (!isReady) return
Loading...
+ const stream = durably.subscribe(runId) - // durably インスタンスを直接使用 + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }) } ``` -#### 戻り値 - -| プロパティ | 型 | 説明 | -| ---------- | ---------------- | --------------------------------------- | -| `durably` | `Durably \| null`| Durably インスタンス(未初期化時は null)| -| `isReady` | `boolean` | 初期化完了フラグ | -| `error` | `Error \| null` | 初期化エラー | - -### useJob - -ジョブの実行とステータス管理を行う。`JobDefinition` を受け取り、自動で durably に登録する。 +### クライアント側(軽量) ```tsx -import { useJob } from '@coji/durably-react' -import { processTask } from './jobs' +// component.tsx +import { useJob } from '@coji/durably-react/client' function TaskRunner() { - const { - isReady, - trigger, - status, - output, - error, - logs, - progress, - isRunning, - isPending, - isCompleted, - isFailed, - currentRunId, - reset, - } = useJob(processTask) + const { trigger, status, output, progress, isRunning } = useJob({ + baseUrl: '/api/durably', + jobName: 'process-task', + }) return (
{progress && ( )} - {isCompleted &&
Result: {JSON.stringify(output)}
} - {isFailed &&
Error: {error}
} - -
    - {logs.map((log) => ( -
  • {log.message}
  • - ))} -
+ {status === 'completed' &&
Done!
}
) } ``` -#### 引数 +--- -| 引数 | 型 | 説明 | -| --------- | --------------------------------------- | ------------------------------------------------ | -| `job` | `JobDefinition` | ジョブ定義 | -| `options` | `UseJobOptions` | オプション設定(省略可) | +## API 仕様 -#### UseJobOptions +### ブラウザ完結モード (`@coji/durably-react`) -| プロパティ | 型 | 説明 | -| -------------- | -------- | -------------------------------------------------------- | -| `initialRunId` | `string` | 初期状態で購読する Run ID(ページリロード時の復元に使用)| +#### DurablyProvider -#### 戻り値 +```tsx +// Promise を渡す場合(推奨) +}> + {children} + + +// 解決済みインスタンスを渡す場合 + console.log('Ready!')} +> + {children} + +``` -| プロパティ | 型 | 説明 | -| ---------------- | --------------------------------------------------------------- | ------------------------------------ | -| `isReady` | `boolean` | Durably 初期化完了フラグ | -| `trigger` | `(input: TInput, options?: TriggerOptions) => Promise` | ジョブを実行 | -| `triggerAndWait` | `(input: TInput, options?: TriggerOptions) => Promise<{...}>` | 実行して完了を待つ | -| `status` | `RunStatus \| null` | 現在の Run のステータス | -| `output` | `TOutput \| null` | 完了時の出力(型安全) | -| `error` | `string \| null` | 失敗時のエラーメッセージ | -| `logs` | `LogEntry[]` | リアルタイムログ | -| `progress` | `Progress \| null` | 進捗情報 | -| `isRunning` | `boolean` | 実行中かどうか | -| `isPending` | `boolean` | 待機中かどうか | -| `isCompleted` | `boolean` | 完了したかどうか | -| `isFailed` | `boolean` | 失敗したかどうか | -| `currentRunId` | `string \| null` | 現在の Run ID | -| `reset` | `() => void` | 状態をリセット | +| Prop | 型 | 必須 | 説明 | +|------|-----|------|------| +| `durably` | `Durably \| Promise` | Yes | Durably インスタンスまたは Promise | +| `autoStart` | `boolean` | - | 自動 start()(デフォルト: true) | +| `onReady` | `(durably: Durably) => void` | - | 準備完了コールバック | +| `fallback` | `ReactNode` | - | Promise 解決中のフォールバック UI | -※ `isReady` が `false` の間は `trigger()` を呼ばないこと。呼んだ場合は例外がスローされる。 +#### useDurably -#### 動作 +```tsx +const { durably, isReady, error } = useDurably() +``` -- `useJob` 呼び出し時に、内部で `durably.register(job)` を実行 -- `trigger()` 呼び出し時にイベントリスナーを登録 -- Run の完了/失敗時に自動でリスナーを解除 -- コンポーネントのアンマウント時にもリスナーを解除 +| 戻り値 | 型 | 説明 | +|--------|-----|------| +| `durably` | `Durably \| null` | インスタンス | +| `isReady` | `boolean` | 初期化完了 | +| `error` | `Error \| null` | 初期化エラー | -### useJobRun +#### useJob -特定の Run ID のステータスを購読する。ページリロード後に既存の Run を再購読する場合に使用。 +```tsx +const { + isReady, + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + currentRunId, + reset, +} = useJob(jobDefinition, options?) +``` -> **Note**: ページリロード時の Run 復元には `useJob` の `initialRunId` オプションを使うことを推奨。 -> `useJobRun` は、ジョブ定義なしで Run ID のみで購読したい場合に使用する。 +| 引数 | 型 | 説明 | +|------|-----|------| +| `jobDefinition` | `JobDefinition` | ジョブ定義 | +| `options.initialRunId` | `string` | 初期購読 Run ID | +| `options.autoResume` | `boolean` | pending/running の Run を自動再開(デフォルト: true) | +| `options.followLatest` | `boolean` | 新しい Run 開始時に自動切替(デフォルト: true) | + +**戻り値の詳細**: + +| プロパティ | 型 | 説明 | +|-----------|-----|------| +| `isReady` | `boolean` | 準備完了 | +| `trigger(input)` | `Promise<{ runId: string }>` | ジョブを実行、Run ID を返す | +| `triggerAndWait(input)` | `Promise<{ runId: string; output: TOutput }>` | 実行して完了を待つ | +| `status` | `RunStatus \| null` | 現在のステータス | +| `output` | `TOutput \| null` | 完了時の出力 | +| `error` | `string \| null` | 失敗時のエラー | +| `logs` | `LogEntry[]` | ログ一覧 | +| `progress` | `Progress \| null` | 進捗情報 | +| `isRunning` | `boolean` | 実行中 | +| `isPending` | `boolean` | 待機中 | +| `isCompleted` | `boolean` | 完了 | +| `isFailed` | `boolean` | 失敗 | +| `currentRunId` | `string \| null` | 現在の Run ID | +| `reset` | `() => void` | 状態リセット | + +#### useJobRun ```tsx -// 推奨: useJob + initialRunId(trigger も使える) -import { useJob } from '@coji/durably-react' -import { useSearchParams } from 'react-router' -import { processTask } from './jobs' +const { status, output, error, logs, progress } = useJobRun({ runId }) +``` -function TaskRunner() { - const [searchParams, setSearchParams] = useSearchParams() - const runId = searchParams.get('runId') +Run ID のみで購読(trigger なし)。`runId` が `null` の場合は購読せず待機する。 - const { isReady, trigger, status, output } = useJob(processTask, { - initialRunId: runId ?? undefined, - }) +| 引数 | 型 | 説明 | +|------|-----|------| +| `runId` | `string \| null` | 購読する Run ID | - const handleRun = async () => { - const run = await trigger({ taskId: '123' }) - setSearchParams({ runId: run.id }) - } +#### useJobLogs - return ( -
- - {status === 'completed' &&
Result: {JSON.stringify(output)}
} -
- ) -} +```tsx +const { logs, clear } = useJobLogs({ runId, maxLogs? }) ``` +| 引数 | 型 | 説明 | +|------|-----|------| +| `runId` | `string \| null` | Run ID | +| `maxLogs` | `number` | 最大ログ数(デフォルト: 100) | + +#### useRuns + ```tsx -// useJobRun: Run ID のみで購読(trigger なし) -import { useJobRun } from '@coji/durably-react' -import { useSearchParams } from 'react-router' +const { + isReady, + runs, + isLoading, + page, + hasMore, + nextPage, + prevPage, + goToPage, + refresh, +} = useRuns(options?) +``` -function RunStatus() { - const [searchParams] = useSearchParams() - const runId = searchParams.get('runId') +| オプション | 型 | 説明 | +|------------|------|------| +| `jobName` | `string` | ジョブ名でフィルタ | +| `status` | `RunStatus` | ステータスでフィルタ | +| `pageSize` | `number` | 1ページの件数(デフォルト: 10) | +| `realtime` | `boolean` | リアルタイム更新(デフォルト: true) | + +| 戻り値 | 型 | 説明 | +|--------|-----|------| +| `isReady` | `boolean` | 準備完了 | +| `runs` | `Run[]` | Run 一覧 | +| `isLoading` | `boolean` | 読み込み中 | +| `page` | `number` | 現在ページ | +| `hasMore` | `boolean` | 次ページあり | +| `nextPage` | `() => void` | 次ページへ | +| `prevPage` | `() => void` | 前ページへ | +| `goToPage` | `(page: number) => void` | 指定ページへ | +| `refresh` | `() => Promise` | 再読み込み | + +> **Note**: Run アクション(retry, cancel, delete)は `useDurably` から取得した Durably インスタンスを使用するか、サーバー連携モードでは `useRunActions` を使用する。 - const { status, output, error, logs, progress } = useJobRun(runId) +--- - if (!runId) return
No run ID
+### サーバー連携モード - return ( -
-

Run: {runId}

-

Status: {status}

+#### サーバー側 (`@coji/durably/server`) - {progress && ( - - )} +```ts +import { createDurablyHandler } from '@coji/durably/server' - {status === 'completed' && ( -
{JSON.stringify(output, null, 2)}
- )} +const handler = createDurablyHandler(durably, { + onRequest: async () => { + await durably.migrate() + durably.start() + } +}) - {status === 'failed' && ( -
{error}
- )} -
- ) -} +// 自動ルーティング(推奨) +handler.handle(request: Request, basePath: string): Promise + +// 個別ハンドラー +handler.trigger(request: Request): Promise // POST /trigger +handler.subscribe(request: Request): Response // GET /subscribe?runId=xxx +handler.runs(request: Request): Promise // GET /runs +handler.run(request: Request): Promise // GET /run?runId=xxx +handler.steps(request: Request): Promise // GET /steps?runId=xxx +handler.retry(request: Request): Promise // POST /retry?runId=xxx +handler.cancel(request: Request): Promise // POST /cancel?runId=xxx +handler.delete(request: Request): Promise // DELETE /run?runId=xxx +handler.runsSubscribe(request: Request): Response // GET /runs/subscribe ``` -#### 引数 +**API 規約**: -| 引数 | 型 | 説明 | -| ------- | ---------------- | --------------- | -| `runId` | `string \| null` | 購読する Run ID | +| エンドポイント | メソッド | リクエスト | レスポンス | +|---------------|---------|-----------|-----------| +| `{basePath}/trigger` | POST | `{ jobName, input, idempotencyKey?, concurrencyKey? }` | `{ runId }` | +| `{basePath}/subscribe?runId=xxx` | GET | - | SSE stream (single run) | +| `{basePath}/runs` | GET | `?jobName=&status=&limit=&offset=` | `Run[]` | +| `{basePath}/run?runId=xxx` | GET | - | `Run` or 404 | +| `{basePath}/steps?runId=xxx` | GET | - | `Step[]` | +| `{basePath}/retry?runId=xxx` | POST | - | `{ success: true }` | +| `{basePath}/cancel?runId=xxx` | POST | - | `{ success: true }` | +| `{basePath}/run?runId=xxx` | DELETE | - | `{ success: true }` | +| `{basePath}/runs/subscribe` | GET | `?jobName=` | SSE stream (run updates) | + +> **Note**: 認証・認可、CORS、CSRF の扱いは本仕様のスコープ外。アプリケーション側で適切に実装すること。 + +**SSE イベント形式**: + +Single run subscription (`/subscribe?runId=xxx`): +```text +data: {"type":"run:start","runId":"xxx","jobName":"process-task","payload":{...}} + +data: {"type":"run:progress","runId":"xxx","jobName":"process-task","progress":{"current":1,"total":2}} + +data: {"type":"run:complete","runId":"xxx","jobName":"process-task","output":{"success":true},"duration":1234} + +data: {"type":"run:fail","runId":"xxx","jobName":"process-task","error":"Something went wrong"} + +data: {"type":"run:cancel","runId":"xxx","jobName":"process-task"} + +data: {"type":"run:retry","runId":"xxx","jobName":"process-task"} + +``` + +Runs subscription (`/runs/subscribe`): +```text +data: {"type":"run:trigger","runId":"xxx","jobName":"process-task"} + +data: {"type":"run:start","runId":"xxx","jobName":"process-task"} + +data: {"type":"run:complete","runId":"xxx","jobName":"process-task"} + +data: {"type":"run:fail","runId":"xxx","jobName":"process-task"} + +data: {"type":"run:cancel","runId":"xxx","jobName":"process-task"} -#### 戻り値 +data: {"type":"run:retry","runId":"xxx","jobName":"process-task"} -`useJob` の戻り値から `trigger` 系を除いたもの。 +data: {"type":"run:progress","runId":"xxx","jobName":"process-task","progress":{"current":1,"total":2}} -### useJobLogs +``` -ログをリアルタイムで購読する。 +#### クライアント側 (`@coji/durably-react/client`) ```tsx -import { useJobLogs } from '@coji/durably-react' +import { useJob, useJobRun, useJobLogs, useRuns, useRunActions } from '@coji/durably-react/client' + +// ジョブ実行 + 購読 +const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isReady, + isRunning, + isPending, + isCompleted, + isFailed, + currentRunId, + reset, +} = useJob({ + api: '/api/durably', + jobName: 'process-task', +}) -function LogViewer({ runId }: { runId?: string }) { - const { logs, clear } = useJobLogs({ runId, maxLogs: 100 }) +// 既存 Run の購読のみ +const { status, output, error, logs, progress } = useJobRun({ + api: '/api/durably', + runId: 'xxx', +}) - return ( -
- -
    - {logs.map((log) => ( -
  • - [{log.timestamp}] {log.message} -
  • - ))} -
-
- ) +// ログ購読 +const { logs, clear } = useJobLogs({ + api: '/api/durably', + runId: 'xxx', +}) + +// Run 一覧 +const { runs, isLoading, hasMore, nextPage, prevPage, refresh } = useRuns({ + api: '/api/durably', + jobName: 'process-task', +}) + +// Run アクション +const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = useRunActions({ + api: '/api/durably', +}) +``` + +**useJob オプション**: + +| オプション | 型 | 必須 | 説明 | +|------------|------|------|------| +| `api` | `string` | Yes | API エンドポイント | +| `jobName` | `string` | Yes | ジョブ名 | +| `initialRunId` | `string` | - | 初期購読 Run ID(再接続用) | + +**useJobRun オプション**: + +| オプション | 型 | 必須 | 説明 | +|------------|------|------|------| +| `api` | `string` | Yes | API エンドポイント | +| `runId` | `string \| null` | Yes | Run ID(`null` の場合は購読しない) | + +**useJobLogs オプション**: + +| オプション | 型 | 必須 | 説明 | +|------------|------|------|------| +| `api` | `string` | Yes | API エンドポイント | +| `runId` | `string \| null` | Yes | Run ID(`null` の場合は購読しない) | +| `maxLogs` | `number` | - | 保持する最大ログ数(デフォルト: 100) | + +**useRuns オプション**: + +| オプション | 型 | 必須 | 説明 | +|------------|------|------|------| +| `api` | `string` | Yes | API エンドポイント | +| `jobName` | `string` | - | ジョブ名でフィルタ | +| `status` | `RunStatus` | - | ステータスでフィルタ | +| `pageSize` | `number` | - | 1ページの件数(デフォルト: 10) | + +**useRunActions オプション**: + +| オプション | 型 | 必須 | 説明 | +|------------|----------|------|--------------------| +| `api` | `string` | Yes | API エンドポイント | + +| 戻り値 | 型 | 説明 | +|-------------|---------------------------------------------------|-----------------| +| `retry` | `(runId: string) => Promise` | Run を再実行 | +| `cancel` | `(runId: string) => Promise` | Run をキャンセル | +| `deleteRun` | `(runId: string) => Promise` | Run を削除 | +| `getRun` | `(runId: string) => Promise` | Run を取得 | +| `getSteps` | `(runId: string) => Promise` | Steps を取得 | +| `isLoading` | `boolean` | アクション実行中 | +| `error` | `string \| null` | エラーメッセージ | + +**RunRecord 型**: + +```ts +interface RunRecord { + id: string + jobName: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + payload: unknown + output: unknown | null + error: string | null + progress: { current: number; total?: number; message?: string } | null + createdAt: string + startedAt: string | null + completedAt: string | null } ``` -#### オプション +**StepRecord 型**: -| オプション | 型 | 説明 | -| ---------- | -------- | ----------------------------------------- | -| `runId` | `string` | 特定の Run のログのみ購読(省略時は全ログ)| -| `maxLogs` | `number` | 保持する最大ログ数(デフォルト: 100) | +```ts +interface StepRecord { + name: string + status: 'completed' | 'failed' + output: unknown +} +``` + +--- -#### 戻り値 +### 型安全クライアントファクトリ -| プロパティ | 型 | 説明 | -| ---------- | ------------ | ------------------ | -| `logs` | `LogEntry[]` | ログエントリの配列 | -| `clear` | `() => void` | ログをクリア | +```tsx +import { createDurablyClient, createJobHooks } from '@coji/durably-react/client' +import type { jobs } from './durably.server' // サーバー側の jobs をインポート + +// 方法1: createDurablyClient(推奨) +// サーバー側で register() した jobs の型を使用 +const durably = createDurablyClient({ + api: '/api/durably', +}) + +// 型安全なアクセス +const { trigger, status } = durably.processTask.useJob() +await trigger({ taskId: '123' }) // 型安全 + +const { status, output } = durably.processTask.useRun(runId) +const { logs, clearLogs } = durably.processTask.useLogs(runId) + +// 方法2: createJobHooks(単一ジョブ用) +import type { processTaskJob } from './jobs' + +const processTaskHooks = createJobHooks({ + api: '/api/durably', + jobName: 'process-task', +}) + +const { trigger, status } = processTaskHooks.useJob() +``` --- ## 型定義 ```ts -interface DurablyOptions { - pollingInterval?: number - heartbeatInterval?: number - staleThreshold?: number -} - +// 共通 type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' interface Progress { @@ -410,63 +658,84 @@ interface LogEntry { timestamp: string } -interface Run { - id: string - jobName: string - status: RunStatus - output: TOutput | null - error: string | null - progress: Progress | null - createdAt: string - updatedAt: string -} +// イベント(SSE で送信される) +type DurablyEvent = + | { type: 'run:trigger'; runId: string; jobName: string; payload: unknown } + | { type: 'run:start'; runId: string; jobName: string; payload: unknown } + | { type: 'run:complete'; runId: string; jobName: string; output: unknown; duration: number } + | { type: 'run:fail'; runId: string; jobName: string; error: string; failedStepName: string } + | { type: 'run:cancel'; runId: string; jobName: string } + | { type: 'run:retry'; runId: string; jobName: string } + | { type: 'run:progress'; runId: string; jobName: string; progress: Progress } + | { type: 'step:start'; runId: string; jobName: string; stepName: string; stepIndex: number } + | { type: 'step:complete'; runId: string; jobName: string; stepName: string; stepIndex: number; output: unknown; duration: number } + | { type: 'step:fail'; runId: string; jobName: string; stepName: string; stepIndex: number; error: string } + | { type: 'log:write'; runId: string; stepName: string | null; level: 'info' | 'warn' | 'error'; message: string; data: unknown } + | { type: 'worker:error'; error: string; context: string; runId?: string } +``` + +--- + +## 依存関係 + +### ブラウザ完結モード + +```text +@coji/durably-react +├── @coji/durably (peer dependency) +├── react (peer dependency, >= 18.0.0) +└── react-dom (peer dependency, >= 18.0.0) +``` + +```bash +npm install @coji/durably-react @coji/durably kysely zod sqlocal react react-dom +``` + +### サーバー連携モード + +**サーバー**: +```bash +npm install @coji/durably kysely zod better-sqlite3 +``` + +**クライアント**(軽量、`@coji/durably` 不要): +```bash +npm install @coji/durably-react react react-dom ``` --- ## 使用例 -### 進捗表示付きバッチ処理 +### 進捗表示付きバッチ処理(ブラウザ完結) ```tsx // jobs.ts -import { defineJob } from '@coji/durably' -import { z } from 'zod' - export const batchProcess = defineJob({ name: 'batch-process', input: z.object({ items: z.array(z.string()) }), output: z.object({ processed: z.number() }), run: async (step, payload) => { const { items } = payload - let processed = 0 - for (let i = 0; i < items.length; i++) { - await step.run(`process-${items[i]}`, async () => { - await processItem(items[i]) - }) - processed++ - step.progress(processed, items.length, `Processing ${items[i]}`) + await step.run(`process-${i}`, () => processItem(items[i])) + step.progress(i + 1, items.length, `Processing ${items[i]}`) } - - return { processed } + return { processed: items.length } }, }) // component.tsx -import { useJob } from '@coji/durably-react' -import { batchProcess } from './jobs' - function BatchProcessor() { const { trigger, progress, isRunning, output } = useJob(batchProcess) return (
{progress && ( @@ -476,7 +745,60 @@ function BatchProcessor() {
)} - {output &&
Processed: {output.processed} items
} + {output &&
Processed: {output.processed}
} + + ) +} +``` + +### AI エージェント(サーバー連携) + +```tsx +// サーバー: jobs.server.ts +export const aiAgent = defineJob({ + name: 'ai-agent', + input: z.object({ prompt: z.string() }), + output: z.object({ response: z.string() }), + run: async (step, { prompt }) => { + step.log.info('Processing prompt', { prompt }) + + const plan = await step.run('plan', () => generatePlan(prompt)) + step.progress(1, 3, 'Planning...') + + const research = await step.run('research', () => doResearch(plan)) + step.progress(2, 3, 'Researching...') + + const response = await step.run('generate', () => generate(research)) + step.progress(3, 3, 'Generating...') + + return { response } + }, +}) + +// クライアント: component.tsx +import { useJob } from '@coji/durably-react/client' + +function AIChat() { + const { trigger, status, progress, output, logs } = useJob({ + api: '/api/durably', + jobName: 'ai-agent', + }) + + return ( +
+ + + {progress &&
{progress.message}
} + +
+ {logs.map((log, i) => ( +
[{log.level}] {log.message}
+ ))} +
+ + {output &&
{output.response}
}
) } @@ -485,290 +807,318 @@ function BatchProcessor() { ### ページリロード後の再接続 ```tsx -import { useJob, useJobRun } from '@coji/durably-react' -import { useSearchParams, useNavigate } from 'react-router' -import { processTask } from './jobs' +import { useJob } from '@coji/durably-react/client' +import { useSearchParams } from 'react-router' function TaskPage() { - const [searchParams] = useSearchParams() - const navigate = useNavigate() - const existingRunId = searchParams.get('runId') - - // 新規実行用 - const { trigger, currentRunId } = useJob(processTask) + const [searchParams, setSearchParams] = useSearchParams() + const runId = searchParams.get('runId') - // 既存 Run の購読用 - const { status, progress, output } = useJobRun(existingRunId ?? currentRunId) + const { trigger, status, output } = useJob({ + api: '/api/durably', + jobName: 'process-task', + initialRunId: runId ?? undefined, // 既存 Run を再購読 + }) const handleStart = async () => { - const run = await trigger({ taskId: 'task-1' }) - navigate(`?runId=${run.id}`) // URL に runId を保存 + const { runId } = await trigger({ taskId: '123' }) + setSearchParams({ runId }) // URL に保存 } return (
- - {status &&

Status: {status}

} - {progress && } - {output &&
{JSON.stringify(output, null, 2)}
} + {status === 'completed' &&
{JSON.stringify(output)}
}
) } ``` ---- +### Run 一覧ダッシュボード(ブラウザ完結モード) -## 内部実装指針 +```tsx +import { useRuns, useDurably } from '@coji/durably-react' -### useJob の実装 +function Dashboard() { + const { durably } = useDurably() + const { + runs, + isLoading, + page, + hasMore, + nextPage, + prevPage, + refresh, + } = useRuns({ pageSize: 10 }) + + const handleRetry = async (runId: string) => { + await durably?.retry(runId) + refresh() + } -```tsx -function useJob( - jobDef: JobDefinition -) { - const { durably, isReady } = useDurably() - const [state, setState] = useState(initialState) - const listenersRef = useRef void>>([]) - const jobHandleRef = useRef | null>(null) - - // ジョブを登録 - useEffect(() => { - if (!durably || !isReady) return - jobHandleRef.current = durably.register(jobDef) - }, [durably, isReady, jobDef.name]) - - const trigger = useCallback(async (input: TInput, options?: TriggerOptions) => { - const jobHandle = jobHandleRef.current - if (!durably || !jobHandle) { - throw new Error('Durably not initialized') - } + const handleCancel = async (runId: string) => { + await durably?.cancel(runId) + refresh() + } - const run = await jobHandle.trigger(input, options) - setState(s => ({ ...s, currentRunId: run.id, status: 'pending' })) - - // リスナー登録 - const unsubs = [ - durably.on('run:start', (e) => { - if (e.runId === run.id) { - setState(s => ({ ...s, status: 'running' })) - } - }), - durably.on('run:complete', (e) => { - if (e.runId === run.id) { - setState(s => ({ ...s, status: 'completed', output: e.output })) - cleanup() - } - }), - durably.on('run:fail', (e) => { - if (e.runId === run.id) { - setState(s => ({ ...s, status: 'failed', error: e.error })) - cleanup() - } - }), - durably.on('log:write', (e) => { - if (e.runId === run.id) { - setState(s => ({ ...s, logs: [...s.logs, e] })) - } - }), - ] - - listenersRef.current = unsubs - return run - }, [durably]) - - const cleanup = useCallback(() => { - listenersRef.current.forEach(unsub => unsub()) - listenersRef.current = [] - }, []) - - useEffect(() => cleanup, [cleanup]) - - return { trigger, ...state } + return ( +
+ + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + ))} + +
IDJobStatusActions
{run.id}{run.jobName}{run.status} + {(run.status === 'failed' || run.status === 'cancelled') && ( + + )} + {(run.status === 'pending' || run.status === 'running') && ( + + )} +
+ +
+ + Page {page + 1} + +
+
+ ) } ``` ---- +### Run 一覧ダッシュボード(サーバー連携モード) -## Durably コア側の要件 +```tsx +import { useRuns, useRunActions } from '@coji/durably-react/client' -React 統合を実現するために、コアライブラリに以下が必要。 +function Dashboard() { + const { runs, isLoading, page, hasMore, nextPage, prevPage, refresh } = useRuns({ + api: '/api/durably', + pageSize: 10, + }) + const { retry, cancel, isLoading: isActioning } = useRunActions({ + api: '/api/durably', + }) -### 1. イベントリスナーの解除機能 + const handleRetry = async (runId: string) => { + await retry(runId) + refresh() + } -`on()` が unsubscribe 関数を返す必要がある。 + const handleCancel = async (runId: string) => { + await cancel(runId) + refresh() + } -```ts -const unsubscribe = durably.on('run:complete', handler) -unsubscribe() // リスナーを解除 + return ( +
+ + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + ))} + +
IDJobStatusActions
{run.id}{run.jobName}{run.status} + {(run.status === 'failed' || run.status === 'cancelled') && ( + + )} + {(run.status === 'pending' || run.status === 'running') && ( + + )} +
+ +
+ + Page {page + 1} + +
+
+ ) +} ``` -### 2. register メソッド +--- -`JobDefinition` を受け取り、`JobHandle` を返す。 +## 内部実装指針 -```ts -const jobHandle = durably.register(jobDef) -``` +### ブラウザ完結モード ---- +- `DurablyProvider` で渡された `durably` インスタンスを Context に保持 +- `autoStart=true` の場合、マウント時に `durably.start()` を呼び出し +- `useJob` は `durably.on()` でイベント購読 +- アンマウント時にリスナー解除 -## 依存関係 +### サーバー連携モード -``` -@coji/durably-react -├── @coji/durably (peer dependency) -├── react (peer dependency, >= 18.0.0) -└── react-dom (peer dependency, >= 18.0.0) +- `useJob` は `fetch()` で trigger、`EventSource` で購読 +- SSE の再接続は自動(EventSource の標準動作) +- `@coji/durably` に依存しない -@coji/durably -├── kysely (peer dependency) -└── zod (peer dependency) -``` +### Strict Mode 対応 -インストール(ブラウザ環境): - -```bash -npm install @coji/durably-react @coji/durably kysely zod sqlocal react react-dom -``` +- ref で初期化済みフラグを管理 +- 二重マウントでも正しく動作 --- -## 検討事項 +## 将来拡張 -### SSR 対応 +### Streaming 対応 -- `DurablyProvider` はクライアントサイドのみで動作 -- SSR 時は `isReady: false` を返し、ハイドレーション後に初期化 +`step.stream()` でトークン単位のストリーミングを追加予定。 -### React 19 の Strict Mode - -- 開発モードでの二重マウントに対応 -- ref を使った初期化済みフラグで重複初期化を防止 +```tsx +// 将来 +const { trigger, chunks, fullText, isStreaming } = useJobStream({ + baseUrl: '/api/durably', + jobName: 'ai-chat', +}) +``` -### エラーバウンダリ +### カスタム API アダプター -- Provider 初期化エラーは `error` として公開 -- 子コンポーネントでエラーバウンダリを使用することを推奨 +```tsx +// 将来: カスタム API 実装 +const { trigger, status } = useJob({ + trigger: async (input) => { + const res = await fetch('/custom/trigger', { ... }) + return res.json() + }, + subscribe: (runId) => new EventSource(`/custom/subscribe/${runId}`), +}) +``` --- -## 将来拡張への準備 +## v1 からの変更点 -### Streaming 対応 (spec-streaming.md 参照) +### @coji/durably コアパッケージ -v2 で `durably.subscribe()` が実装された際、以下の拡張を予定している。現在の設計はこれらを妨げないよう考慮されている。 +#### 型安全な `durably.jobs` API -#### 1. useJob の events 追加 +`register()` がオブジェクト形式を受け取り、型安全な `jobs` プロパティを返すようになった: -```tsx -// v1(現在) -const { trigger, status, output, logs, progress } = useJob(job) - -// v2(将来)- events を追加 -const { trigger, status, output, logs, progress, events } = useJob(job) +```ts +// 旧: 個別に register +const processImageHandle = durably.register(processImageJob) +const syncUsersHandle = durably.register(syncUsersJob) + +// 新: オブジェクト形式で一括登録、型安全な jobs プロパティ +const durably = createDurably({ dialect }) + .register({ + processImage: processImageJob, + syncUsers: syncUsersJob, + }) -// events は AsyncIterable -for await (const event of events) { - if (event.type === 'stream') { - // トークン単位のストリーミングデータ - console.log(event.data) - } -} +// 型安全なアクセス +await durably.jobs.processImage.trigger({ imageId: '123' }) +await durably.jobs.syncUsers.trigger({ source: 'api' }) ``` -`events` は v1 では `null` を返す。v2 で追加しても破壊的変更にならない。 +#### 新しいイベント -#### 2. 内部実装の切り替え +以下のイベントが追加された: -| バージョン | イベント購読方式 | -| ---------- | ----------------------------------------------------------- | -| v1 | `durably.on()` ベース(同期的、プロセス内のみ) | -| v2 | `durably.subscribe()` ベース(ReadableStream、再接続対応) | +| イベント | 説明 | +|-----------------|-------------------------------------| +| `run:trigger` | ジョブがトリガーされた時(Worker 実行前) | +| `run:cancel` | Run がキャンセルされた時 | +| `run:retry` | Run がリトライされた時 | -外部 API は変わらない。内部で使用するイベントソースを切り替える。 +#### `subscribe()` メソッド -#### 3. useJobStream フック(新規、v2) +`durably.subscribe(runId)` で特定 Run のイベントを `ReadableStream` で購読可能: -streaming 専用のフック。`step.stream()` の emit をリアルタイムで消費する。 - -```tsx -import { useJobStream } from '@coji/durably-react' +```ts +const stream = durably.subscribe(runId) +const reader = stream.getReader() -function AIChat() { - const { trigger, isStreaming, chunks, fullText } = useJobStream(chatJob) +while (true) { + const { done, value } = await reader.read() + if (done) break + console.log(value) // DurablyEvent +} +``` - return ( -
- +#### `getJob()` メソッド - {isStreaming && ( -
- {chunks.map((chunk, i) => ( - {chunk.text} - ))} -
- )} +名前で登録済みジョブを取得: - {!isStreaming && fullText && ( -
{fullText}
- )} -
- ) +```ts +const job = durably.getJob('process-image') +if (job) { + await job.trigger({ imageId: '123' }) } ``` -#### 4. サーバー実行 + クライアント購読 +### @coji/durably/server -サーバーサイドで Durably を実行し、クライアントで購読するパターン。常駐サーバー(非 Serverless)を前提とする。 +#### 新しいエンドポイント -```tsx -// サーバー側: Resource Route で SSE エンドポイント -// app/routes/api.runs.$runId.stream.ts -export async function loader({ params }: LoaderFunctionArgs) { - const stream = await durably.subscribe(params.runId) - - const sseStream = stream.pipeThrough(new TransformStream({ - transform(event, controller) { - controller.enqueue(`data: ${JSON.stringify(event)}\n\n`) - } - })) +| エンドポイント | メソッド | 説明 | +|--------------------------------|----------|--------------------------| +| `{basePath}/steps?runId=xxx` | GET | Run のステップ一覧を取得 | +| `{basePath}/run?runId=xxx` | DELETE | Run を削除 | - return new Response(sseStream, { - headers: { 'Content-Type': 'text-event-stream' }, - }) -} -``` +#### SSE イベント拡張 -```tsx -// クライアント側: SSE を消費するフック -import { useEventSource } from '@coji/durably-react/client' +`/runs/subscribe` エンドポイントで以下の新しいイベントを配信: -function TaskStatus({ runId }: { runId: string }) { - const { status, progress, output } = useEventSource( - `/api/runs/${runId}/stream` - ) +- `run:trigger` - ジョブトリガー時 +- `run:cancel` - キャンセル時 +- `run:retry` - リトライ時 - return
Status: {status}
-} -``` +### @coji/durably-react/client -`@coji/durably-react/client` は Durably 本体に依存しない軽量なフック集として提供予定。 +#### `useRunActions` の拡張 -### 設計上の考慮事項 +新しいメソッドが追加された: -1. **useJob の戻り値は拡張可能** - - 新しいプロパティを追加しても既存コードは壊れない - - `events` など将来のプロパティは `null` または `undefined` を返す +| メソッド | 説明 | +|---------------|-------------------| +| `deleteRun()` | Run を削除 | +| `getRun()` | Run を取得 | +| `getSteps()` | Steps を取得 | -2. **Provider の props は安定** - - `dialect` と `options` の構造は変わらない - - 新しいオプションは追加されるが、既存は維持 +新しい型がエクスポートされた: -3. **内部でのイベントソース抽象化** - - `on()` から `subscribe()` への移行を内部で吸収 - - フック利用者は実装の詳細を意識しない +- `RunRecord` - Run のレコード型 +- `StepRecord` - Step のレコード型 diff --git a/docs/spec-streaming.md b/docs/spec-streaming.md index d8a4e21f..68756185 100644 --- a/docs/spec-streaming.md +++ b/docs/spec-streaming.md @@ -1,8 +1,8 @@ -# 将来仕様検討: AI Agent ワークフロー対応 +# 将来仕様: AI Agent ワークフロー拡張 (v2) -> **⚠️ 注意: これは構想段階のドキュメントです。まだ実装されていません。** +> **⚠️ 注意: これは将来拡張の構想ドキュメントです。** > -> v1 の基本機能が安定した後、Phase A から段階的に検討・実装予定です。 +> v1 で `subscribe()` は実装済み。本ドキュメントでは v2 以降の拡張機能を検討します。 ## 背景 @@ -16,22 +16,45 @@ LLM を使った AI Agent のワークフローを durably で実装したい。 --- -## 課題 +## v1 で実装済みの機能 -### 現仕様の制約 +### `subscribe()` - リアルタイム購読 -1. **ストリーミング非対応**: 現在の設計はリクエスト/レスポンス型。ステップ完了時にのみ状態が永続化される。 +v1 で実装済み。Run の実行中に発火されるイベントを `ReadableStream` として取得できる。 -2. **リアルタイム通知の欠如**: イベントシステムは同一プロセス内のみ。ブラウザタブ間やサーバー→クライアントへのプッシュ機構がない。 +```ts +const stream = durably.subscribe(runId) + +const reader = stream.getReader() +while (true) { + const { done, value } = await reader.read() + if (done) break + + switch (value.type) { + case 'run:start': + console.log('Run started') + break + case 'step:complete': + console.log(`Step ${value.stepName} completed`) + break + case 'run:complete': + console.log('Run completed:', value.output) + break + case 'run:fail': + console.error('Run failed:', value.error) + break + } +} +``` -3. **LLM 特有の要件**: - - トークン単位のストリーミング出力 - - 中間結果の表示(思考過程、ツール呼び出し) - - 長時間実行中の heartbeat +**現在の制約**: +- イベントはメモリ上のみ(永続化されない) +- 再接続時に過去のイベントは取得できない +- `step.stream()` によるトークン単位のストリーミングは未対応 --- -## 拡張案 +## v2 拡張案 ### 1. ストリーミングステップ (`step.stream()`) @@ -82,103 +105,9 @@ export const aiAgent = defineJob({ - ステップ完了時に戻り値が永続化される - 再実行時は完了済みステップをスキップ(通常と同じ) -### 2. リアルタイム購読 (`subscribe()`) - -実行中の Run にリアルタイムで接続する API。Web Streams API の `ReadableStream` を使用する。 - -```ts -// subscribe() は ReadableStream を返す -const stream = await durably.subscribe(runId) - -// ReadableStream として消費 -for await (const event of stream) { - switch (event.type) { - case 'stream': - appendToUI(event.data.text) - break - case 'step:complete': - updateProgress(event.stepName) - break - case 'run:complete': - showResult(event.output) - break - } -} -``` - -**メリット**: -- ブラウザネイティブ API(追加ライブラリ不要) -- バックプレッシャー対応(消費側が遅いと生成を待つ) -- `for await...of` で直感的に消費 -- `pipeThrough()` でトランスフォーム可能 -- Node.js でも同じ API(`ReadableStream` は標準化済み) - -**実装イメージ**: - -```ts -// subscribe の実装 -async function subscribe(runId: string, options?: SubscribeOptions): Promise> { - // 初期イベントを先に取得(再接続時は resumeFrom 以降) - const initialEvents = await getEvents(runId, options?.resumeFrom) - - return new ReadableStream({ - start(controller) { - // 取得済みの初期イベントをプッシュ - for (const event of initialEvents) { - controller.enqueue(event) - } - }, - - async pull(controller) { - // 新しいイベントをポーリングまたはリッスン - const event = await waitForNextEvent(runId) - if (event.type === 'run:complete' || event.type === 'run:fail') { - controller.enqueue(event) - controller.close() - } else { - controller.enqueue(event) - } - }, - - cancel() { - // クリーンアップ - } - }) -} -``` - -**再接続の実装**: - -```ts -// クライアント側で最後のイベントを記録 -let lastSequence = 0 - -async function consumeWithReconnect(runId: string) { - while (true) { - try { - const stream = await durably.subscribe(runId, { - resumeFrom: lastSequence - }) - - for await (const event of stream) { - lastSequence = event.sequence - handleEvent(event) - - if (event.type === 'run:complete' || event.type === 'run:fail') { - return // 正常終了 - } - } - } catch (error) { - // 接続エラー時はリトライ - await sleep(1000) - } - } -} -``` - -### 3. イベントログの永続化 +### 2. イベントログの永続化 -ストリーミングイベントを再接続時に再生するため、イベントログを永続化。 +粗いイベント(step:*, run:*)を永続化し、再接続時に再生可能にする。 ```sql -- events テーブル(新規追加) @@ -186,10 +115,10 @@ CREATE TABLE events ( id TEXT PRIMARY KEY, -- ULID run_id TEXT NOT NULL, step_name TEXT, - type TEXT NOT NULL, -- 'stream', 'step:start', 'step:complete', etc. + type TEXT NOT NULL, -- 'step:start', 'step:complete', 'run:complete', etc. data TEXT, -- JSON sequence INTEGER NOT NULL, -- 順序保証用 - created_at TEXT NOT NULL, -- イベント型では timestamp として公開 + created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ); @@ -197,11 +126,29 @@ CREATE TABLE events ( CREATE INDEX idx_events_run_sequence ON events(run_id, sequence); ``` -**注**: DBカラム名 `created_at` は、イベント型では `timestamp` フィールドとして公開される(v1 と同じパターン)。 +**永続化するイベント**: +- `run:start`, `run:complete`, `run:fail` +- `step:start`, `step:complete`, `step:fail` +- `run:progress` +- `log:write` + +**永続化しないイベント**: +- `stream`(トークン単位の emit)- メモリのみで直接配信 + +### 3. 再接続サポート (`resumeFrom`) + +```ts +interface SubscribeOptions { + resumeFrom?: number // 最後に受信した sequence +} + +// 再接続時に使用 +const stream = durably.subscribe(runId, { resumeFrom: lastSequence }) +``` **再接続フロー**: 1. クライアントが `resumeFrom: lastSequence` で接続 -2. サーバーは `sequence > lastSequence` のイベントを送信 +2. サーバーは `sequence > lastSequence` のイベントを DB から取得して送信 3. 以降はリアルタイムでイベントを配信 ### 4. チェックポイント(長時間実行対応) @@ -230,179 +177,124 @@ const response = await step.stream('generate-response', async (emit, checkpoint) - チェックポイントがあれば、そこから再開 - LLM に「続きを生成」のプロンプトを送る(アプリケーション側の責務) -### 5. ブラウザ環境での実装 - -ブラウザでは Web Worker + BroadcastChannel でタブ間通信。 - -```ts -// メインタブ(実行側) -const durably = createDurably({ dialect }) -durably.on('stream', (event) => { - // BroadcastChannel で他のタブに通知 - channel.postMessage(event) -}) - -// 別タブ(表示側) -const channel = new BroadcastChannel('durably-events') -channel.onmessage = (event) => { - updateUI(event.data) -} -``` - -**制約**: -- 同一オリジン内のみ -- タブが全て閉じると通知も停止 -- 永続化されたイベントログから復元は可能 - --- ## API 設計案 -### JobHandle の拡張 +### StepContext の拡張 ```ts -interface JobHandle { - // 型情報(v1と同じ) - readonly name: TName - readonly $types: { - input: TInput - output: TOutput +interface StepContext { + // v1 (実装済み) + readonly runId: string + run(name: string, fn: () => Promise): Promise + progress(current: number, total?: number, message?: string): void + log: { + info(message: string, data?: unknown): void + warn(message: string, data?: unknown): void + error(message: string, data?: unknown): void } - // 既存 - trigger(input: TInput, options?: TriggerOptions): Promise> - getRun(id: string): Promise | null> - getRuns(filter?: RunFilter): Promise[]> - - // 新規: ReadableStream を返す(初期イベント取得のため非同期) - subscribe(runId: string, options?: SubscribeOptions): Promise> + // v2 (新規) + stream( + name: string, + fn: (emit: EmitFn, checkpoint?: CheckpointFn) => Promise + ): Promise } -interface SubscribeOptions { - resumeFrom?: number // 最後に受信した sequence -} +type EmitFn = (data: unknown) => void +type CheckpointFn = (state: unknown) => Promise +``` -// subscribe() は ReadableStream を返す -type DurablyEventStream = ReadableStream +### DurablyEvent の拡張 +```ts +// v1 イベント (実装済み) type DurablyEvent = - // v1 イベント | RunStartEvent | RunCompleteEvent | RunFailEvent + | RunProgressEvent | StepStartEvent | StepCompleteEvent | StepFailEvent | LogWriteEvent - // v2 追加イベント - | StreamEvent + | WorkerErrorEvent -interface StreamEvent { +// v2 追加イベント +interface StreamEvent extends BaseEvent { type: 'stream' runId: string stepName: string - sequence: number data: unknown // emit() に渡されたデータ - timestamp: string } ``` -### StepContext の拡張 +### subscribe() の拡張 ```ts -interface StepContext { - // 既存 - run(name: string, fn: () => Promise): Promise - log: Logger - progress(current: number, total: number, message?: string): void +// v1 (実装済み) +subscribe(runId: string): ReadableStream - // 新規 - stream( - name: string, - fn: (emit: EmitFn, checkpoint: CheckpointFn) => Promise - ): Promise -} +// v2 (拡張) +subscribe(runId: string, options?: SubscribeOptions): ReadableStream -type EmitFn = (data: unknown) => void -type CheckpointFn = (state: unknown) => Promise +interface SubscribeOptions { + resumeFrom?: number // 最後に受信した sequence +} ``` --- ## 実装フェーズ案 -### Phase A: イベントログ基盤(v0.2) +### Phase A: step.stream() 基本実装 -- `events` テーブルの追加 -- `step.stream()` の基本実装(emit のみ、checkpoint なし) -- `subscribe()` の実装(ReadableStream を返す、ポーリングベース) +- `step.stream()` の実装(emit のみ、checkpoint なし) +- `StreamEvent` の追加 +- `subscribe()` で `stream` イベントを配信 -### Phase B: 再接続とタブ間通知(v0.3) +### Phase B: イベント永続化と再接続 -- `resumeFrom` による再接続(イベントログから再生) -- BroadcastChannel によるタブ間通知(ブラウザ、ポーリング不要に) -- ヘルパー関数 `subscribeWithReconnect()` の提供 +- `events` テーブルの追加 +- 粗いイベント(step:*, run:*)の永続化 +- `resumeFrom` による再接続サポート +- Storage インターフェースの拡張: + ```ts + createEvent(event: DurablyEvent): Promise + getEvents(runId: string, afterSequence?: number): Promise + ``` -### Phase C: チェックポイント(v0.4) +### Phase C: チェックポイント - `checkpoint()` の実装 -- 部分的な再開のサポート +- チェックポイントからの再開サポート - TTL によるイベントログのクリーンアップ --- -## 検討事項 +## 設計上の考慮事項 ### DB負荷とストリーミング戦略 LLM のトークン単位ストリーミングは 1秒に数十〜数百回の emit が発生しうる。 毎回 DB に書き込むのは現実的ではない。 -#### 採用する方針: イベントログは粗いイベントのみ永続化 +**採用方針**: イベントログは粗いイベントのみ永続化 -```ts -// DB に永続化するイベント(再接続時に再生可能) -step:start -step:complete -step:fail -run:complete -run:fail -progress 更新(setProgress) - -// DB に書かないイベント(メモリのみ、直接配信) -stream(トークン単位の emit) -``` +| イベント | 永続化 | 備考 | +|----------------|--------|----------------------| +| `run:*` | ✅ | 再接続時に再生 | +| `step:*` | ✅ | 再接続時に再生 | +| `run:progress` | ✅ | 進捗状態の復元 | +| `log:write` | ✅ | ログの永続化 | +| `stream` | ❌ | メモリのみ、直接配信 | **トレードオフ**: - 再接続時にトークン単位の再生は不可 - 進行中ステップがあれば、そのステップは最初からやり直し - ステップを細かく分ければ損失は最小限 -**実装イメージ**: - -```ts -step.stream('generate-response', async (emit) => { - for await (const chunk of llmStream) { - // emit はメモリのみ → 接続中のクライアントに即座に配信 - emit({ type: 'token', text: chunk.text }) - } - // ステップ完了時に最終結果が DB に保存される - return fullResponse -}) -``` - -**再接続時の挙動**: - -1. クライアントが再接続 -2. DB から `step:complete` までのイベントを再生 -3. 進行中のステップがあれば、ステップ完了を待つ -4. 完了していないステップの途中経過(トークン)は失われる - -これは許容できる制約。理由: -- LLM の応答は再生成可能(決定論的ではないが、意味的には同等) -- ステップを細かく分ければ損失は小さい -- DB負荷を劇的に削減できる - ### ストレージ容量 永続化するイベントは粗いものに限定されるため、容量問題は軽減される。 @@ -494,37 +386,33 @@ export const codingAssistant = defineJob({ }, }) -// main.ts -import { createDurably } from '@coji/durably' -import { codingAssistant } from './jobs' - -const durably = createDurably({ dialect }) -const codingAssistantJob = durably.register(codingAssistant) +// client.ts - subscribe() で購読 +const stream = durably.subscribe(run.id) -const run = await codingAssistantJob.trigger({ - task: 'Add user authentication', - codebase: '/path/to/repo', -}) - -const stream = await codingAssistantJob.subscribe(run.id) +const reader = stream.getReader() +while (true) { + const { done, value } = await reader.read() + if (done) break -for await (const event of stream) { - switch (event.type) { + switch (value.type) { case 'stream': - switch (event.data.type) { + switch (value.data.type) { case 'thinking': - appendToThinkingPanel(event.data.text) + appendToThinkingPanel(value.data.text) break case 'diff-chunk': - appendToDiffViewer(event.data.file, event.data.text) + appendToDiffViewer(value.data.file, value.data.text) break case 'status': - updateStatus(event.data.message) + updateStatus(value.data.message) break } break + case 'run:progress': + updateProgressBar(value.progress) + break case 'run:complete': - showFinalResult(event.output) + showFinalResult(value.output) break } } @@ -534,13 +422,12 @@ for await (const event of stream) { ## まとめ -| 機能 | 優先度 | 複雑度 | 備考 | -|------|--------|--------|------| -| `step.stream()` | 高 | 中 | AI Agent の基本要件 | -| `subscribe()` (ReadableStream) | 高 | 低 | Web Streams API、追加依存なし | -| 粗いイベントのみ永続化 | 高 | 低 | step:*, run:* のみ DB 保存。トークン単位はメモリのみ | -| `resumeFrom` 再接続 | 高 | 低 | 完了済みステップから再生 | -| `checkpoint()` | 中 | 高 | 長時間実行に必要 | -| BroadcastChannel | 低 | 低 | ブラウザ専用の最適化 | +| 機能 | 状態 | 複雑度 | 備考 | +|----------------------|----------------|--------|------------------------------| +| `subscribe()` | ✅ v1 実装済み | - | ReadableStream を返す | +| `step.stream()` | 🔜 v2 Phase A | 中 | AI Agent の基本要件 | +| イベント永続化 | 🔜 v2 Phase B | 中 | 粗いイベントのみ DB 保存 | +| `resumeFrom` 再接続 | 🔜 v2 Phase B | 低 | 永続化されたイベントから再生 | +| `checkpoint()` | 🔜 v2 Phase C | 高 | 長時間実行に必要 | -v0.1 (現在の計画) の完成後、Phase A から段階的に実装していく。 +v1 の `subscribe()` をベースに、段階的に拡張していく。 diff --git a/docs/spec.md b/docs/spec.md index a415a1c3..2e828163 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -112,7 +112,9 @@ await durably.migrate() durably.start() // ジョブを登録して JobHandle を取得 -const syncUsersJob = durably.register(syncUsers) +const { syncUsers: syncUsersJob } = durably.register({ + syncUsers, +}) // trigger で実行 await syncUsersJob.trigger({ orgId: "org_123" }) @@ -154,6 +156,15 @@ interface RunFilter { `step.run` に渡す名前は、同一 Run 内で一意でなければならない。同じ名前のステップが複数回実行された場合はエラーとなる。成功したステップは再実行時に自動的にスキップされ、保存済みの戻り値が返される。この挙動は固定であり、ユーザーが選択する必要はない。 +`step.runId` プロパティで現在の Run の ID にアクセスできる。これは外部サービスへの通知や、ログに Run ID を含める場合に有用である。 + +```ts +const users = await step.run("fetch-users", async () => { + console.log(`Processing run: ${step.runId}`) + return api.fetchUsers(payload.orgId) +}) +``` + `step.run` の戻り値はステップ関数の戻り値から型推論される。 ```ts @@ -353,6 +364,38 @@ const failedRuns = await durably.getRuns({ `limit` は取得する最大件数、`offset` はスキップする件数を指定する。両方を組み合わせることで、ページ単位の取得が可能になる。 +### ジョブの取得 + +登録済みのジョブを名前で取得するには `getJob` メソッドを使う。 + +```ts +const job = durably.getJob('sync-users') +if (job) { + await job.trigger({ orgId: 'org_123' }) +} +``` + +`getJob` は登録済みのジョブがあれば `JobHandle` を返し、なければ `undefined` を返す。これは動的にジョブを取得したい場合(例: API ハンドラでジョブ名をパラメータとして受け取る場合)に有用である。 + +### Run のリアルタイム購読 + +Run の実行をリアルタイムで購読するには `subscribe` メソッドを使う。 + +```ts +const stream = durably.subscribe(runId) + +const reader = stream.getReader() +while (true) { + const { done, value } = await reader.read() + if (done) break + console.log(value) // DurablyEvent +} +``` + +`subscribe` は指定した Run の実行中に発火されるイベントを `ReadableStream` として返す。ストリームは `run:complete` または `run:fail` イベントが発火されると自動的にクローズされる。 + +これにより、UI でのリアルタイム進捗表示や、SSE(Server-Sent Events)を介したクライアントへのイベント配信が可能になる。 + ### ワーカー ワーカーは `start` 関数によって起動される。起動すると、一定間隔で `pending` 状態の Run を取得し、逐次実行する。 @@ -367,7 +410,7 @@ const durably = createDurably({ dialect }) await durably.migrate() // ジョブを登録 -durably.register(syncUsers) +durably.register({ syncUsers }) // ワーカーを起動 durably.start() @@ -398,6 +441,11 @@ await durably.migrate() ライブラリ内部で起きたことを外部に通知するためのイベントシステムを持つ。これにより、ログの永続化、外部サービスへの送信、リアルタイム UI 更新など、任意の処理を接続できる。 ```ts +durably.on('run:trigger', (event) => { + // { runId, jobName, payload, timestamp } + // ジョブがトリガーされた時(Worker 実行前) +}) + durably.on('run:start', (event) => { // { runId, jobName, payload, timestamp } }) @@ -410,6 +458,20 @@ durably.on('run:fail', (event) => { // { runId, jobName, error, failedStepName, timestamp } }) +durably.on('run:cancel', (event) => { + // { runId, jobName, timestamp } + // Run がキャンセルされた時 +}) + +durably.on('run:retry', (event) => { + // { runId, jobName, timestamp } + // Run がリトライされた時 +}) + +durably.on('run:progress', (event) => { + // { runId, jobName, progress: { current, total?, message? }, timestamp } +}) + durably.on('step:start', (event) => { // { runId, jobName, stepName, stepIndex, timestamp } }) @@ -448,6 +510,13 @@ interface BaseEvent { } // Run イベント +interface RunTriggerEvent extends BaseEvent { + type: 'run:trigger' + runId: string + jobName: string + payload: unknown +} + interface RunStartEvent extends BaseEvent { type: 'run:start' runId: string @@ -471,6 +540,25 @@ interface RunFailEvent extends BaseEvent { failedStepName: string } +interface RunCancelEvent extends BaseEvent { + type: 'run:cancel' + runId: string + jobName: string +} + +interface RunRetryEvent extends BaseEvent { + type: 'run:retry' + runId: string + jobName: string +} + +interface RunProgressEvent extends BaseEvent { + type: 'run:progress' + runId: string + jobName: string + progress: { current: number; total?: number; message?: string } +} + // Step イベント interface StepStartEvent extends BaseEvent { type: 'step:start' @@ -519,9 +607,13 @@ interface WorkerErrorEvent extends BaseEvent { // 全イベントの Union 型 type DurablyEvent = + | RunTriggerEvent | RunStartEvent | RunCompleteEvent | RunFailEvent + | RunCancelEvent + | RunRetryEvent + | RunProgressEvent | StepStartEvent | StepCompleteEvent | StepFailEvent @@ -781,16 +873,20 @@ Run の取得クエリは以下の条件を満たすものを一件取得する ### イベント発火タイミング -| イベント | 発火タイミング | -|----------|----------------| -| run:start | Run が running に遷移した直後 | -| run:complete | Run が completed に遷移した直後 | -| run:fail | Run が failed に遷移した直後 | -| step:start | ステップの実行を開始する直前 | -| step:complete | ステップが成功し DB に記録した直後 | -| step:fail | ステップが失敗し DB に記録した直後 | -| log:write | step.log が呼ばれた直後 | -| worker:error | ワーカー内部でエラーが発生した時(ハートビート失敗など) | +| イベント | 発火タイミング | +|----------------|----------------------------------------------------------| +| run:trigger | trigger() が呼ばれ、Run が pending として作成された直後 | +| run:start | Run が running に遷移した直後 | +| run:complete | Run が completed に遷移した直後 | +| run:fail | Run が failed に遷移した直後 | +| run:cancel | cancel() が呼ばれ、Run が cancelled に遷移した直後 | +| run:retry | retry() が呼ばれ、Run が pending に戻った直後 | +| run:progress | step.progress が呼ばれた直後 | +| step:start | ステップの実行を開始する直前 | +| step:complete | ステップが成功し DB に記録した直後 | +| step:fail | ステップが失敗し DB に記録した直後 | +| log:write | step.log が呼ばれた直後 | +| worker:error | ワーカー内部でエラーが発生した時(ハートビート失敗など) | ### 設定項目 @@ -891,7 +987,7 @@ await durably.migrate() durably.start() // ジョブを登録してトリガー -const syncUsersJob = durably.register(syncUsers) +const { syncUsers: syncUsersJob } = durably.register({ syncUsers }) await syncUsersJob.trigger({ orgId: 'org_123' }) ``` @@ -985,20 +1081,22 @@ class JobContextImpl implements JobContext { ```ts interface Storage { // Run 操作 - createRun(run: Run): Promise - updateRun(runId: string, data: Partial): Promise + createRun(input: CreateRunInput): Promise + batchCreateRuns(inputs: CreateRunInput[]): Promise + updateRun(runId: string, data: UpdateRunInput): Promise + deleteRun(runId: string): Promise getRun(runId: string): Promise getRuns(filter?: RunFilter): Promise getNextPendingRun(excludeConcurrencyKeys: string[]): Promise // Step 操作 - createStep(step: Step): Promise + createStep(input: CreateStepInput): Promise getSteps(runId: string): Promise getCompletedStep(runId: string, name: string): Promise - // Log 操作(withLogPersistence プラグイン用) - createLog?(log: Log): Promise - getLogs?(runId: string): Promise + // Log 操作 + createLog(input: CreateLogInput): Promise + getLogs(runId: string): Promise // v2 で追加予定: // createEvent?(event: DurablyEvent): Promise @@ -1036,10 +1134,11 @@ v2 では AI Agent ワークフロー対応として以下の機能が計画さ | 機能 | 概要 | |------|------| | `step.stream()` | ストリーミング出力をサポートするステップ | -| `subscribe()` | Run の実行をリアルタイムで購読(ReadableStream) | | `events` テーブル | 粗いイベント(step:*, run:*)の永続化 | | `checkpoint()` | 長時間実行中の中間状態保存 | +注: `subscribe()` は v1 で実装済み。詳細は「Run のリアルタイム購読」セクションを参照。 + ### v1 での準備事項 v1 実装時に以下を守ることで、v2 への移行がスムーズになる。 diff --git a/examples/browser-react-router-spa/.gitignore b/examples/browser-react-router-spa/.gitignore new file mode 100644 index 00000000..4e2bf0b3 --- /dev/null +++ b/examples/browser-react-router-spa/.gitignore @@ -0,0 +1,7 @@ +node_modules + +/.cache +/build +.env +.react-router +*.sqlite3 diff --git a/examples/browser-react-router-spa/README.md b/examples/browser-react-router-spa/README.md new file mode 100644 index 00000000..df518cb3 --- /dev/null +++ b/examples/browser-react-router-spa/README.md @@ -0,0 +1,71 @@ +# Browser-Only SPA Example (React Router v7) + +This example demonstrates Durably running entirely in the browser using React Router v7 in SPA mode. + +## Features + +- **React Router v7 SPA mode** - No server-side rendering, pure client-side app +- **SQLite WASM with OPFS** - Persistent storage in the browser +- **DurablyProvider** - React context for lifecycle management +- **Multiple jobs** - Image processing and data sync examples +- **Run history dashboard** - View, retry, cancel, and delete runs +- **Tailwind CSS** - Modern styling + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Start development server (generates types automatically) +pnpm dev +``` + +Open http://localhost:5173 + +> **Note:** Run `pnpm dev` at least once before `pnpm typecheck` to generate React Router type definitions. + +## Requirements + +### COOP/COEP Headers + +SQLite WASM requires cross-origin isolation. This is configured in `vite.config.ts`: + +```http +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin +``` + +### Secure Context + +Browser-only mode requires HTTPS or localhost for OPFS access. + +## Project Structure + +``` +app/ +├── root.tsx # DurablyProvider setup with SQLocalKysely +├── lib/ +│ └── jobs.ts # Job definitions (processImageJob, dataSyncJob) +└── routes/ + └── _index.tsx # Main page with job panels + └── _index/ + └── dashboard.tsx # Run history component +``` + +## Key Differences from Fullstack Mode + +| Aspect | Browser-Only (SPA) | Fullstack (SSR) | +| -------- | ----------------------- | -------------------------- | +| Database | SQLite WASM + OPFS | libsql/better-sqlite3 | +| Provider | `DurablyProvider` | No provider needed | +| Hooks | `useJob(jobDefinition)` | `durably.jobName.useJob()` | +| Data | Stays in browser | Server-side storage | +| Offline | Works offline | Requires server | + +## Try It + +1. Run a job and observe the progress +2. Reload the page during execution - it resumes automatically +3. Check the dashboard for run history +4. Try retry/cancel/delete actions diff --git a/examples/browser-react-router-spa/app/app.css b/examples/browser-react-router-spa/app/app.css new file mode 100644 index 00000000..f3902dce --- /dev/null +++ b/examples/browser-react-router-spa/app/app.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +html, +body { + @apply bg-gray-50; +} diff --git a/examples/browser-react-router-spa/app/entry.client.tsx b/examples/browser-react-router-spa/app/entry.client.tsx new file mode 100644 index 00000000..7e8a9375 --- /dev/null +++ b/examples/browser-react-router-spa/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { startTransition, StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { HydratedRouter } from 'react-router/dom' + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/examples/browser-react-router-spa/app/jobs/data-sync.ts b/examples/browser-react-router-spa/app/jobs/data-sync.ts new file mode 100644 index 00000000..1a3e9ebc --- /dev/null +++ b/examples/browser-react-router-spa/app/jobs/data-sync.ts @@ -0,0 +1,56 @@ +/** + * Data Sync Job + * + * Simulates syncing data with a remote server. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const dataSyncJob = defineJob({ + name: 'data-sync', + input: z.object({ userId: z.string() }), + output: z.object({ synced: z.number(), failed: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting sync for user: ${payload.userId}`) + + const items = await step.run('fetch-local', async () => { + step.progress(1, 4, 'Fetching local data...') + await delay(300) + return Array.from({ length: 10 }, (_, i) => ({ + id: `item-${i}`, + data: `Data for ${payload.userId}`, + })) + }) + + let synced = 0 + let failed = 0 + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const success = await step.run(`sync-item-${item.id}`, async () => { + step.progress(2 + Math.floor(i / 5), 4, `Syncing item ${i + 1}...`) + await delay(100) + return Math.random() > 0.1 // 90% success rate + }) + + if (success) { + synced++ + } else { + failed++ + step.log.warn(`Failed to sync item: ${item.id}`) + } + } + + await step.run('finalize', async () => { + step.progress(4, 4, 'Finalizing...') + await delay(200) + }) + + step.log.info(`Sync complete: ${synced} synced, ${failed} failed`) + + return { synced, failed } + }, +}) diff --git a/examples/browser-react-router-spa/app/jobs/import-csv.ts b/examples/browser-react-router-spa/app/jobs/import-csv.ts new file mode 100644 index 00000000..b2ce147b --- /dev/null +++ b/examples/browser-react-router-spa/app/jobs/import-csv.ts @@ -0,0 +1,92 @@ +/** + * CSV Import Job + * + * Demonstrates separation of steps (resumable units) and progress (UI feedback). + * - Steps: validate, import, finalize (3 resumable checkpoints) + * - Progress: fine-grained row-level feedback within each step + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const csvRowSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + amount: z.number(), +}) + +/** Output schema for type inference */ +const outputSchema = z.object({ imported: z.number(), failed: z.number() }) + +/** Output type for use in components */ +export type ImportCsvOutput = z.infer + +export const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ + filename: z.string(), + rows: z.array(csvRowSchema), + }), + output: outputSchema, + run: async (step, payload) => { + step.log.info( + `Starting import of ${payload.filename} (${payload.rows.length} rows)`, + ) + + // Step 1: Validate all rows + const validRows = await step.run('validate', async () => { + const valid: typeof payload.rows = [] + const invalid: { row: (typeof payload.rows)[0]; reason: string }[] = [] + + for (let i = 0; i < payload.rows.length; i++) { + const row = payload.rows[i] + step.progress(i + 1, payload.rows.length, `Validating ${row.name}...`) + await delay(50) + + if (row.amount < 0) { + invalid.push({ row, reason: `Invalid amount: ${row.amount}` }) + step.log.warn(`Validation failed for ${row.name}: negative amount`) + } else { + valid.push(row) + } + } + + step.log.info( + `Validation complete: ${valid.length} valid, ${invalid.length} invalid`, + ) + return { valid, invalidCount: invalid.length } + }) + + // Step 2: Import valid rows + const importResult = await step.run('import', async () => { + let imported = 0 + + for (let i = 0; i < validRows.valid.length; i++) { + const row = validRows.valid[i] + step.progress(i + 1, validRows.valid.length, `Importing ${row.name}...`) + await delay(80) + + // Simulate import + imported++ + step.log.info(`Imported: ${row.name} (${row.email}) - $${row.amount}`) + } + + return { imported } + }) + + // Step 3: Finalize + await step.run('finalize', async () => { + step.progress(1, 1, 'Finalizing...') + await delay(200) + step.log.info('Import finalized') + }) + + return { + imported: importResult.imported, + failed: validRows.invalidCount, + } + }, +}) diff --git a/examples/browser-react-router-spa/app/jobs/index.ts b/examples/browser-react-router-spa/app/jobs/index.ts new file mode 100644 index 00000000..e87adee6 --- /dev/null +++ b/examples/browser-react-router-spa/app/jobs/index.ts @@ -0,0 +1,10 @@ +/** + * Job Definitions + * + * Barrel export for all job definitions. + * 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' diff --git a/examples/browser-react-router-spa/app/jobs/process-image.ts b/examples/browser-react-router-spa/app/jobs/process-image.ts new file mode 100644 index 00000000..aa8197eb --- /dev/null +++ b/examples/browser-react-router-spa/app/jobs/process-image.ts @@ -0,0 +1,48 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string(), width: z.number() }), + output: z.object({ url: z.string(), size: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting image processing: ${payload.filename}`) + + // Download original image + const fileSize = await step.run('download', async () => { + step.progress(1, 3, 'Downloading...') + await delay(500) + return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB + }) + + step.log.info(`Downloaded: ${fileSize} bytes`) + + // Resize to target width + const resizedSize = await step.run('resize', async () => { + step.progress(2, 3, 'Resizing...') + await delay(600) + return Math.floor(fileSize * (payload.width / 1920)) + }) + + step.log.info(`Resized to: ${resizedSize} bytes`) + + // Upload to CDN + const url = await step.run('upload', async () => { + step.progress(3, 3, 'Uploading...') + await delay(400) + return `https://cdn.example.com/${payload.width}/${payload.filename}` + }) + + step.log.info(`Uploaded to: ${url}`) + + return { url, size: resizedSize } + }, +}) diff --git a/examples/browser-react-router-spa/app/lib/database.ts b/examples/browser-react-router-spa/app/lib/database.ts new file mode 100644 index 00000000..5443dec3 --- /dev/null +++ b/examples/browser-react-router-spa/app/lib/database.ts @@ -0,0 +1,9 @@ +/** + * Database Configuration + * + * SQLocal instance for SQLite WASM with OPFS backend. + */ + +import { SQLocalKysely } from 'sqlocal/kysely' + +export const sqlocal = new SQLocalKysely('example.sqlite3') diff --git a/examples/browser-react-router-spa/app/lib/durably.ts b/examples/browser-react-router-spa/app/lib/durably.ts new file mode 100644 index 00000000..16c9f0e5 --- /dev/null +++ b/examples/browser-react-router-spa/app/lib/durably.ts @@ -0,0 +1,25 @@ +/** + * Durably instance for browser-only mode + * + * This creates a singleton Durably instance that is shared across the app. + */ + +import { createDurably } from '@coji/durably' +import { dataSyncJob, processImageJob } from '~/jobs' +import { sqlocal } from './database' + +// Create and configure durably instance with chained register() +// register() returns a new Durably instance with type-safe jobs +const durably = createDurably({ + dialect: sqlocal.dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, +}).register({ + processImage: processImageJob, + dataSync: dataSyncJob, +}) + +await durably.init() + +export { durably } diff --git a/examples/browser-react-router-spa/app/root.tsx b/examples/browser-react-router-spa/app/root.tsx new file mode 100644 index 00000000..6cabe198 --- /dev/null +++ b/examples/browser-react-router-spa/app/root.tsx @@ -0,0 +1,82 @@ +import { DurablyProvider } from '@coji/durably-react' +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from 'react-router' +import type { Route } from './+types/root' +import './app.css' +import { durably } from './lib/durably' + +export function links() { + return [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + ] +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export default function App() { + return ( + + + + ) +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!' + let details = 'An unexpected error occurred.' + let stack: string | undefined + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error' + details = + error.status === 404 + ? 'The requested page could not be found.' + : error.statusText || details + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message + stack = error.stack + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ) +} diff --git a/examples/browser-react-router-spa/app/routes.ts b/examples/browser-react-router-spa/app/routes.ts new file mode 100644 index 00000000..6c129bf6 --- /dev/null +++ b/examples/browser-react-router-spa/app/routes.ts @@ -0,0 +1,3 @@ +import { type RouteConfig, index } from '@react-router/dev/routes' + +export default [index('routes/_index.tsx')] satisfies RouteConfig diff --git a/examples/browser-react-router-spa/app/routes/_index.tsx b/examples/browser-react-router-spa/app/routes/_index.tsx new file mode 100644 index 00000000..574480ec --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index.tsx @@ -0,0 +1,157 @@ +/** + * Browser-Only SPA Example + * + * This example demonstrates: + * - React Router v7 in SPA mode (ssr: false) + * - SQLite WASM with OPFS for browser-only persistence + * - DurablyProvider for context and lifecycle management + * - clientAction for Form-based job triggering (direct trigger in action) + * - useJob hook with initialRunId for monitoring jobs + */ + +import { useState } from 'react' +import { Form } from 'react-router' +import { sqlocal } from '~/lib/database' +import { durably } from '~/lib/durably' +import { Dashboard } from './_index/dashboard' +import { DataSyncForm } from './_index/data-sync-form' +import { DataSyncProgress } from './_index/data-sync-progress' +import { ImageProcessingForm } from './_index/image-processing-form' +import { ImageProcessingProgress } from './_index/image-processing-progress' + +export function meta() { + return [ + { title: 'Durably - Browser-Only SPA' }, + { name: 'description', content: 'Browser-only job processing with OPFS' }, + ] +} + +// clientAction: Trigger jobs directly in SPA mode +export async function clientAction({ request }: { request: Request }) { + const formData = await request.formData() + const intent = formData.get('intent') as string + + if (intent === 'image') { + const filename = formData.get('filename') as string + const width = Number(formData.get('width')) + const run = await durably.jobs.processImage.trigger({ filename, width }) + return { intent: 'image', runId: run.id } + } + + if (intent === 'sync') { + const userId = formData.get('userId') as string + const run = await durably.jobs.dataSync.trigger({ userId }) + return { intent: 'sync', runId: run.id } + } + + if (intent === 'reset') { + await durably.stop() + await sqlocal.deleteDatabaseFile() + location.reload() + return null + } + + return null +} + +export default function Index() { + const [activeJob, setActiveJob] = useState<'image' | 'sync'>('image') + + return ( +
+
+
+

+ Durably - Browser-Only SPA +

+

+ React Router v7 SPA mode with clientAction + Form +

+
+ +
+ {/* Left: Job Trigger + Progress */} +
+ {/* Job Selection */} +
+
+

Run Job

+
+ +
+ +
+
+
+ +
+ + +
+ + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Progress Display */} + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Right: Dashboard */} + +
+ +
+

+ All data is stored locally in your browser using SQLite WASM with + OPFS. +

+

+ Try reloading the page during job execution - it will resume + automatically! +

+
+
+
+ ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx new file mode 100644 index 00000000..f7571c1f --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx @@ -0,0 +1,335 @@ +/** + * Dashboard Component + * + * Displays run history with real-time updates and pagination. + * Uses browser-only mode hooks for direct durably access. + */ + +import type { Run } from '@coji/durably' +import { useDurably, useRuns } from '@coji/durably-react' +import { useState } from 'react' + +export function Dashboard() { + const { durably } = useDurably() + const { runs, page, hasMore, isLoading, refresh, nextPage, prevPage } = + useRuns({ + pageSize: 6, + }) + + 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) + if (run) { + setSelectedRun(run) + const stepsData = await durably.storage.getSteps(runId) + setSteps( + stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), + ) + } + } + + const handleRetry = async (runId: string) => { + if (!durably) return + await durably.retry(runId) + refresh() + } + + const handleCancel = async (runId: string) => { + if (!durably) return + await durably.cancel(runId) + refresh() + } + + const handleDelete = async (runId: string) => { + if (!durably) return + await durably.deleteRun(runId) + setSelectedRun(null) + refresh() + } + + const formatDate = (iso: string) => new Date(iso).toLocaleString() + + const statusClasses: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800', + } + + return ( +
+
+

Run History

+
+ {isLoading && ( + Refreshing... + )} + +
+
+ + {runs.length === 0 ? ( +

No runs yet

+ ) : ( + <> +
+ + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + ))} + +
+ ID + + Job + + Status + + Steps + + Progress + + Created + + Actions +
+ {run.id.slice(0, 8)}... + {run.jobName} + + {run.status} + + + {run.stepCount > 0 ? ( + + {run.stepCount} + + ) : ( + - + )} + + {run.progress ? ( +
+
+
0 ? 100 : 0}%`, + }} + /> +
+ + {run.progress.current} + {run.progress.total && `/${run.progress.total}`} + +
+ ) : ( + - + )} +
+ {formatDate(run.createdAt)} + +
+ + {(run.status === 'failed' || + run.status === 'cancelled') && ( + + )} + {(run.status === 'running' || + run.status === 'pending') && ( + + )} + {run.status !== 'running' && + run.status !== 'pending' && ( + + )} +
+
+
+ + {/* Pagination */} +
+ + Page {page + 1} + +
+ + )} + + {/* Run Details Modal */} + {selectedRun && ( +
+
+
+
+

Run Details

+ +
+ +
+
+ ID:{' '} + + {selectedRun.id} + +
+
+ Job:{' '} + {selectedRun.jobName} +
+
+ Status:{' '} + + {selectedRun.status} + +
+
+ Created:{' '} + {formatDate(selectedRun.createdAt)} +
+ + {selectedRun.progress && ( +
+ Progress:{' '} + {selectedRun.progress.current} + {selectedRun.progress.total + ? `/${selectedRun.progress.total}` + : ''}{' '} + {selectedRun.progress.message || ''} +
+ )} + + {selectedRun.error && ( +
+ Error:{' '} + {selectedRun.error} +
+ )} + + {selectedRun.output !== null && ( +
+ Output: +
+                      {JSON.stringify(selectedRun.output, null, 2)}
+                    
+
+ )} + +
+ Payload: +
+                    {JSON.stringify(selectedRun.payload, null, 2)}
+                  
+
+ + {steps.length > 0 && ( +
+ Steps: +
    + {steps.map((s) => ( +
  • + {s.name} + + {s.status} + +
  • + ))} +
+
+ )} +
+
+
+
+ )} +
+ ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx b/examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx new file mode 100644 index 00000000..fec71898 --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx @@ -0,0 +1,47 @@ +/** + * Data Sync Form Component + * + * Form for triggering data sync jobs via clientAction. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { clientAction } from '../_index' + +export function DataSyncForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'sync' ? actionData.runId : null + + return ( +
+ +
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx b/examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx new file mode 100644 index 00000000..c37a00ad --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx @@ -0,0 +1,42 @@ +/** + * Data Sync Progress Component + * + * Displays progress for the data sync job using initialRunId. + */ + +import { useJob } from '@coji/durably-react' +import { useActionData } from 'react-router' +import { dataSyncJob } from '~/jobs' +import type { clientAction } from '../_index' +import { RunProgress } from './run-progress' + +export function DataSyncProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'sync' ? actionData.runId : undefined + + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJob(dataSyncJob, { initialRunId: runId }) + + return ( + + ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx b/examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx new file mode 100644 index 00000000..2e877417 --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx @@ -0,0 +1,64 @@ +/** + * Image Processing Form Component + * + * Form for triggering image processing jobs via clientAction. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { clientAction } from '../_index' + +export function ImageProcessingForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'image' ? actionData.runId : null + + return ( +
+ +
+ + +
+
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx b/examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx new file mode 100644 index 00000000..3b869e51 --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx @@ -0,0 +1,42 @@ +/** + * Image Processing Progress Component + * + * Displays progress for the image processing job using initialRunId. + */ + +import { useJob } from '@coji/durably-react' +import { useActionData } from 'react-router' +import { processImageJob } from '~/jobs' +import type { clientAction } from '../_index' +import { RunProgress } from './run-progress' + +export function ImageProcessingProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'image' ? actionData.runId : undefined + + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJob(processImageJob, { initialRunId: runId }) + + return ( + + ) +} diff --git a/examples/browser-react-router-spa/app/routes/_index/run-progress.tsx b/examples/browser-react-router-spa/app/routes/_index/run-progress.tsx new file mode 100644 index 00000000..b167f90d --- /dev/null +++ b/examples/browser-react-router-spa/app/routes/_index/run-progress.tsx @@ -0,0 +1,125 @@ +/** + * RunProgress Component + * + * Displays real-time progress and result for browser-only jobs. + */ + +import type { LogEntry } from '@coji/durably-react' + +interface RunProgressProps { + progress: { current: number; total?: number; message?: string } | null + output: unknown + error: string | null + logs: LogEntry[] + isPending: boolean + isRunning: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean +} + +export function RunProgress({ + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, +}: RunProgressProps) { + // Don't render anything if no activity + if (!isPending && !isRunning && !isCompleted && !isFailed && !isCancelled) { + return null + } + + return ( + <> + {/* Pending State */} + {isPending && ( +
+
Waiting to start...
+
+ )} + + {/* Progress Display */} + {isRunning && progress && ( +
+
+ Progress + + {progress.current}/{progress.total || '?'} + +
+
+
+
+ {progress.message && ( +
{progress.message}
+ )} +
+ )} + + {/* Success Result */} + {isCompleted && output !== null && output !== undefined && ( +
+
Completed!
+
+            {JSON.stringify(output, null, 2)}
+          
+
+ )} + + {/* Error Result */} + {isFailed && ( +
+
Failed
+
{error}
+
+ )} + + {/* Cancelled Result */} + {isCancelled && ( +
+
Cancelled
+
+ The job was cancelled before completion. +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( +
+

Logs

+
+
    + {logs.map((log) => ( +
  • + + [{log.level}] + {' '} + {log.message} +
  • + ))} +
+
+
+ )} + + ) +} diff --git a/examples/browser-react-router-spa/biome.json b/examples/browser-react-router-spa/biome.json new file mode 100644 index 00000000..cb0ac4d9 --- /dev/null +++ b/examples/browser-react-router-spa/biome.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "extends": ["../../biome.json"], + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/examples/browser-react-router-spa/package.json b/examples/browser-react-router-spa/package.json new file mode 100644 index 00000000..3ae69d88 --- /dev/null +++ b/examples/browser-react-router-spa/package.json @@ -0,0 +1,41 @@ +{ + "name": "example-browser-react-router-spa", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "preview": "vite preview", + "typecheck": "react-router typegen && tsc", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "prettier --experimental-cli --check .", + "format:fix": "prettier --experimental-cli --write ." + }, + "dependencies": { + "@coji/durably": "workspace:*", + "@coji/durably-react": "workspace:*", + "@react-router/node": "7.11.0", + "isbot": "^5.1.32", + "kysely": "^0.28.9", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router": "7.11.0", + "sqlocal": "^0.16.0", + "zod": "^4.3.4" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.10", + "@react-router/dev": "7.11.0", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vite-tsconfig-paths": "^6.0.3" + } +} diff --git a/examples/browser-react-router-spa/prettier.config.js b/examples/browser-react-router-spa/prettier.config.js new file mode 100644 index 00000000..f28e092a --- /dev/null +++ b/examples/browser-react-router-spa/prettier.config.js @@ -0,0 +1,7 @@ +/** @type {import('prettier').Config} */ +export default { + semi: false, + singleQuote: true, + trailingComma: 'all', + plugins: ['prettier-plugin-organize-imports'], +} diff --git a/examples/browser-react-router-spa/public/favicon.ico b/examples/browser-react-router-spa/public/favicon.ico new file mode 100644 index 00000000..5dbdfcdd Binary files /dev/null and b/examples/browser-react-router-spa/public/favicon.ico differ diff --git a/examples/browser-react-router-spa/react-router.config.ts b/examples/browser-react-router-spa/react-router.config.ts new file mode 100644 index 00000000..d28d0110 --- /dev/null +++ b/examples/browser-react-router-spa/react-router.config.ts @@ -0,0 +1,6 @@ +import type { Config } from '@react-router/dev/config' + +export default { + // SPA mode - no server-side rendering + ssr: false, +} satisfies Config diff --git a/examples/browser-react-router-spa/tsconfig.json b/examples/browser-react-router-spa/tsconfig.json new file mode 100644 index 00000000..dc391a45 --- /dev/null +++ b/examples/browser-react-router-spa/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/examples/browser/vite.config.ts b/examples/browser-react-router-spa/vite.config.ts similarity index 64% rename from examples/browser/vite.config.ts rename to examples/browser-react-router-spa/vite.config.ts index 202a8d1f..204a0ec1 100644 --- a/examples/browser/vite.config.ts +++ b/examples/browser-react-router-spa/vite.config.ts @@ -1,7 +1,14 @@ +import { reactRouter } from '@react-router/dev/vite' +import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [ + tailwindcss(), + reactRouter(), + tsconfigPaths(), + // COOP/COEP headers for SQLite WASM (required for browser-only mode) { name: 'configure-response-headers', configureServer: (server) => { diff --git a/examples/browser-vite-react/.gitignore b/examples/browser-vite-react/.gitignore new file mode 100644 index 00000000..f8dce739 --- /dev/null +++ b/examples/browser-vite-react/.gitignore @@ -0,0 +1,8 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# SQLite database files +*.db diff --git a/examples/browser-vite-react/biome.json b/examples/browser-vite-react/biome.json new file mode 100644 index 00000000..cb0ac4d9 --- /dev/null +++ b/examples/browser-vite-react/biome.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "extends": ["../../biome.json"], + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/examples/browser-vite-react/index.html b/examples/browser-vite-react/index.html new file mode 100644 index 00000000..0e34048d --- /dev/null +++ b/examples/browser-vite-react/index.html @@ -0,0 +1,18 @@ + + + + + + Durably - Browser-Only Vite React + + + + + +
+ + + diff --git a/examples/react/package.json b/examples/browser-vite-react/package.json similarity index 82% rename from examples/react/package.json rename to examples/browser-vite-react/package.json index 14a0387d..b8a9dae0 100644 --- a/examples/react/package.json +++ b/examples/browser-vite-react/package.json @@ -1,5 +1,5 @@ { - "name": "example-react", + "name": "example-browser-vite-react", "private": true, "type": "module", "scripts": { @@ -14,19 +14,22 @@ }, "dependencies": { "@coji/durably": "workspace:*", + "@coji/durably-react": "workspace:*", "kysely": "^0.28.9", "react": "^19.2.3", "react-dom": "^19.2.3", "sqlocal": "^0.16.0", - "zod": "^4.2.1" + "zod": "^4.3.4" }, "devDependencies": { "@biomejs/biome": "^2.3.10", + "@tailwindcss/vite": "^4.1.18", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.3.0", + "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite": "^7.3.0" } diff --git a/examples/browser-vite-react/prettier.config.js b/examples/browser-vite-react/prettier.config.js new file mode 100644 index 00000000..48dce797 --- /dev/null +++ b/examples/browser-vite-react/prettier.config.js @@ -0,0 +1,7 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 80, + plugins: ['prettier-plugin-organize-imports'], +} diff --git a/examples/browser-vite-react/src/App.tsx b/examples/browser-vite-react/src/App.tsx new file mode 100644 index 00000000..3ade2321 --- /dev/null +++ b/examples/browser-vite-react/src/App.tsx @@ -0,0 +1,176 @@ +/** + * Browser-Only Vite React Example + * + * This example demonstrates: + * - SQLite WASM with OPFS for browser-only persistence + * - DurablyProvider for context and lifecycle management + * - useJob hook for job triggering and monitoring + * - Tailwind CSS for styling + */ + +import { DurablyProvider } from '@coji/durably-react' +import { useState } from 'react' +import { + Dashboard, + DataSyncForm, + DataSyncProgress, + ImageProcessingForm, + ImageProcessingProgress, +} from './components' +import { sqlocal } from './lib/database' +import { durably } from './lib/durably' + +function AppContent() { + const [activeJob, setActiveJob] = useState<'image' | 'sync'>('image') + const [imageRunId, setImageRunId] = useState(null) + const [syncRunId, setSyncRunId] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleImageSubmit = async (data: { + filename: string + width: number + }) => { + setIsSubmitting(true) + try { + const run = await durably.jobs.processImage.trigger(data) + setImageRunId(run.id) + } finally { + setIsSubmitting(false) + } + } + + const handleSyncSubmit = async (data: { userId: string }) => { + setIsSubmitting(true) + try { + const run = await durably.jobs.dataSync.trigger(data) + setSyncRunId(run.id) + } finally { + setIsSubmitting(false) + } + } + + const handleReset = async () => { + await durably.stop() + await sqlocal.deleteDatabaseFile() + location.reload() + } + + return ( +
+
+
+

+ Durably - Browser-Only Vite React +

+

+ Pure React with Vite and Tailwind CSS +

+
+ +
+ {/* Left: Job Trigger + Progress */} +
+ {/* Job Selection */} +
+
+

Run Job

+
+ + +
+
+ +
+ + +
+ + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Progress Display */} + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Right: Dashboard */} + +
+ +
+

+ All data is stored locally in your browser using SQLite WASM with + OPFS. +

+

+ Try reloading the page during job execution - it will resume + automatically! +

+
+
+
+ ) +} + +function Loading() { + return ( +
+ Loading... +
+ ) +} + +export function App() { + return ( + }> + + + ) +} diff --git a/examples/browser-vite-react/src/app.css b/examples/browser-vite-react/src/app.css new file mode 100644 index 00000000..f3902dce --- /dev/null +++ b/examples/browser-vite-react/src/app.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +html, +body { + @apply bg-gray-50; +} diff --git a/examples/browser-vite-react/src/components/dashboard.tsx b/examples/browser-vite-react/src/components/dashboard.tsx new file mode 100644 index 00000000..f7571c1f --- /dev/null +++ b/examples/browser-vite-react/src/components/dashboard.tsx @@ -0,0 +1,335 @@ +/** + * Dashboard Component + * + * Displays run history with real-time updates and pagination. + * Uses browser-only mode hooks for direct durably access. + */ + +import type { Run } from '@coji/durably' +import { useDurably, useRuns } from '@coji/durably-react' +import { useState } from 'react' + +export function Dashboard() { + const { durably } = useDurably() + const { runs, page, hasMore, isLoading, refresh, nextPage, prevPage } = + useRuns({ + pageSize: 6, + }) + + 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) + if (run) { + setSelectedRun(run) + const stepsData = await durably.storage.getSteps(runId) + setSteps( + stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), + ) + } + } + + const handleRetry = async (runId: string) => { + if (!durably) return + await durably.retry(runId) + refresh() + } + + const handleCancel = async (runId: string) => { + if (!durably) return + await durably.cancel(runId) + refresh() + } + + const handleDelete = async (runId: string) => { + if (!durably) return + await durably.deleteRun(runId) + setSelectedRun(null) + refresh() + } + + const formatDate = (iso: string) => new Date(iso).toLocaleString() + + const statusClasses: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800', + } + + return ( +
+
+

Run History

+
+ {isLoading && ( + Refreshing... + )} + +
+
+ + {runs.length === 0 ? ( +

No runs yet

+ ) : ( + <> +
+ + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + ))} + +
+ ID + + Job + + Status + + Steps + + Progress + + Created + + Actions +
+ {run.id.slice(0, 8)}... + {run.jobName} + + {run.status} + + + {run.stepCount > 0 ? ( + + {run.stepCount} + + ) : ( + - + )} + + {run.progress ? ( +
+
+
0 ? 100 : 0}%`, + }} + /> +
+ + {run.progress.current} + {run.progress.total && `/${run.progress.total}`} + +
+ ) : ( + - + )} +
+ {formatDate(run.createdAt)} + +
+ + {(run.status === 'failed' || + run.status === 'cancelled') && ( + + )} + {(run.status === 'running' || + run.status === 'pending') && ( + + )} + {run.status !== 'running' && + run.status !== 'pending' && ( + + )} +
+
+
+ + {/* Pagination */} +
+ + Page {page + 1} + +
+ + )} + + {/* Run Details Modal */} + {selectedRun && ( +
+
+
+
+

Run Details

+ +
+ +
+
+ ID:{' '} + + {selectedRun.id} + +
+
+ Job:{' '} + {selectedRun.jobName} +
+
+ Status:{' '} + + {selectedRun.status} + +
+
+ Created:{' '} + {formatDate(selectedRun.createdAt)} +
+ + {selectedRun.progress && ( +
+ Progress:{' '} + {selectedRun.progress.current} + {selectedRun.progress.total + ? `/${selectedRun.progress.total}` + : ''}{' '} + {selectedRun.progress.message || ''} +
+ )} + + {selectedRun.error && ( +
+ Error:{' '} + {selectedRun.error} +
+ )} + + {selectedRun.output !== null && ( +
+ Output: +
+                      {JSON.stringify(selectedRun.output, null, 2)}
+                    
+
+ )} + +
+ Payload: +
+                    {JSON.stringify(selectedRun.payload, null, 2)}
+                  
+
+ + {steps.length > 0 && ( +
+ Steps: +
    + {steps.map((s) => ( +
  • + {s.name} + + {s.status} + +
  • + ))} +
+
+ )} +
+
+
+
+ )} +
+ ) +} diff --git a/examples/browser-vite-react/src/components/data-sync-form.tsx b/examples/browser-vite-react/src/components/data-sync-form.tsx new file mode 100644 index 00000000..68f051a9 --- /dev/null +++ b/examples/browser-vite-react/src/components/data-sync-form.tsx @@ -0,0 +1,57 @@ +/** + * Data Sync Form Component + * + * Form for triggering data sync jobs. + */ + +import { useState } from 'react' + +interface DataSyncFormProps { + onSubmit: (data: { userId: string }) => void + isSubmitting: boolean + runId: string | null +} + +export function DataSyncForm({ + onSubmit, + isSubmitting, + runId, +}: DataSyncFormProps) { + const [userId, setUserId] = useState('user_123') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ userId }) + } + + return ( +
+
+ + setUserId(e.target.value)} + className="border border-gray-300 rounded-md px-3 py-2 w-full focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/browser-vite-react/src/components/data-sync-progress.tsx b/examples/browser-vite-react/src/components/data-sync-progress.tsx new file mode 100644 index 00000000..d7c0b121 --- /dev/null +++ b/examples/browser-vite-react/src/components/data-sync-progress.tsx @@ -0,0 +1,41 @@ +/** + * Data Sync Progress Component + * + * Displays progress for the data sync job. + */ + +import { useJob } from '@coji/durably-react' +import { dataSyncJob } from '../jobs' +import { RunProgress } from './run-progress' + +interface DataSyncProgressProps { + runId?: string +} + +export function DataSyncProgress({ runId }: DataSyncProgressProps) { + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJob(dataSyncJob, { initialRunId: runId }) + + return ( + + ) +} diff --git a/examples/browser-vite-react/src/components/image-processing-form.tsx b/examples/browser-vite-react/src/components/image-processing-form.tsx new file mode 100644 index 00000000..574340c3 --- /dev/null +++ b/examples/browser-vite-react/src/components/image-processing-form.tsx @@ -0,0 +1,75 @@ +/** + * Image Processing Form Component + * + * Form for triggering image processing jobs. + */ + +import { useState } from 'react' + +interface ImageProcessingFormProps { + onSubmit: (data: { filename: string; width: number }) => void + isSubmitting: boolean + runId: string | null +} + +export function ImageProcessingForm({ + onSubmit, + isSubmitting, + runId, +}: ImageProcessingFormProps) { + const [filename, setFilename] = useState('photo.jpg') + const [width, setWidth] = useState(800) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit({ filename, width }) + } + + return ( +
+
+ + setFilename(e.target.value)} + className="border border-gray-300 rounded-md px-3 py-2 w-full focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setWidth(Number(e.target.value))} + min={100} + max={4000} + className="border border-gray-300 rounded-md px-3 py-2 w-full focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/browser-vite-react/src/components/image-processing-progress.tsx b/examples/browser-vite-react/src/components/image-processing-progress.tsx new file mode 100644 index 00000000..abb31fd4 --- /dev/null +++ b/examples/browser-vite-react/src/components/image-processing-progress.tsx @@ -0,0 +1,43 @@ +/** + * Image Processing Progress Component + * + * Displays progress for the image processing job. + */ + +import { useJob } from '@coji/durably-react' +import { processImageJob } from '../jobs' +import { RunProgress } from './run-progress' + +interface ImageProcessingProgressProps { + runId?: string +} + +export function ImageProcessingProgress({ + runId, +}: ImageProcessingProgressProps) { + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJob(processImageJob, { initialRunId: runId }) + + return ( + + ) +} diff --git a/examples/browser-vite-react/src/components/index.ts b/examples/browser-vite-react/src/components/index.ts new file mode 100644 index 00000000..584908b5 --- /dev/null +++ b/examples/browser-vite-react/src/components/index.ts @@ -0,0 +1,10 @@ +/** + * Component Exports + */ + +export { Dashboard } from './dashboard' +export { DataSyncForm } from './data-sync-form' +export { DataSyncProgress } from './data-sync-progress' +export { ImageProcessingForm } from './image-processing-form' +export { ImageProcessingProgress } from './image-processing-progress' +export { RunProgress } from './run-progress' diff --git a/examples/browser-vite-react/src/components/run-progress.tsx b/examples/browser-vite-react/src/components/run-progress.tsx new file mode 100644 index 00000000..b167f90d --- /dev/null +++ b/examples/browser-vite-react/src/components/run-progress.tsx @@ -0,0 +1,125 @@ +/** + * RunProgress Component + * + * Displays real-time progress and result for browser-only jobs. + */ + +import type { LogEntry } from '@coji/durably-react' + +interface RunProgressProps { + progress: { current: number; total?: number; message?: string } | null + output: unknown + error: string | null + logs: LogEntry[] + isPending: boolean + isRunning: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean +} + +export function RunProgress({ + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, +}: RunProgressProps) { + // Don't render anything if no activity + if (!isPending && !isRunning && !isCompleted && !isFailed && !isCancelled) { + return null + } + + return ( + <> + {/* Pending State */} + {isPending && ( +
+
Waiting to start...
+
+ )} + + {/* Progress Display */} + {isRunning && progress && ( +
+
+ Progress + + {progress.current}/{progress.total || '?'} + +
+
+
+
+ {progress.message && ( +
{progress.message}
+ )} +
+ )} + + {/* Success Result */} + {isCompleted && output !== null && output !== undefined && ( +
+
Completed!
+
+            {JSON.stringify(output, null, 2)}
+          
+
+ )} + + {/* Error Result */} + {isFailed && ( +
+
Failed
+
{error}
+
+ )} + + {/* Cancelled Result */} + {isCancelled && ( +
+
Cancelled
+
+ The job was cancelled before completion. +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( +
+

Logs

+
+
    + {logs.map((log) => ( +
  • + + [{log.level}] + {' '} + {log.message} +
  • + ))} +
+
+
+ )} + + ) +} diff --git a/examples/browser-vite-react/src/jobs/data-sync.ts b/examples/browser-vite-react/src/jobs/data-sync.ts new file mode 100644 index 00000000..1a3e9ebc --- /dev/null +++ b/examples/browser-vite-react/src/jobs/data-sync.ts @@ -0,0 +1,56 @@ +/** + * Data Sync Job + * + * Simulates syncing data with a remote server. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const dataSyncJob = defineJob({ + name: 'data-sync', + input: z.object({ userId: z.string() }), + output: z.object({ synced: z.number(), failed: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting sync for user: ${payload.userId}`) + + const items = await step.run('fetch-local', async () => { + step.progress(1, 4, 'Fetching local data...') + await delay(300) + return Array.from({ length: 10 }, (_, i) => ({ + id: `item-${i}`, + data: `Data for ${payload.userId}`, + })) + }) + + let synced = 0 + let failed = 0 + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const success = await step.run(`sync-item-${item.id}`, async () => { + step.progress(2 + Math.floor(i / 5), 4, `Syncing item ${i + 1}...`) + await delay(100) + return Math.random() > 0.1 // 90% success rate + }) + + if (success) { + synced++ + } else { + failed++ + step.log.warn(`Failed to sync item: ${item.id}`) + } + } + + await step.run('finalize', async () => { + step.progress(4, 4, 'Finalizing...') + await delay(200) + }) + + step.log.info(`Sync complete: ${synced} synced, ${failed} failed`) + + return { synced, failed } + }, +}) diff --git a/examples/browser-vite-react/src/jobs/index.ts b/examples/browser-vite-react/src/jobs/index.ts new file mode 100644 index 00000000..404d49a4 --- /dev/null +++ b/examples/browser-vite-react/src/jobs/index.ts @@ -0,0 +1,9 @@ +/** + * Job Definitions + * + * Barrel export for all job definitions. + * When adding a new job, import and add it here. + */ + +export { dataSyncJob } from './data-sync' +export { processImageJob } from './process-image' diff --git a/examples/browser-vite-react/src/jobs/process-image.ts b/examples/browser-vite-react/src/jobs/process-image.ts new file mode 100644 index 00000000..621a81be --- /dev/null +++ b/examples/browser-vite-react/src/jobs/process-image.ts @@ -0,0 +1,48 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string(), width: z.number() }), + output: z.object({ url: z.string(), size: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting image processing: ${payload.filename}`) + + // Download original image + const fileSize = await step.run('download', async () => { + step.progress(1, 3, 'Downloading...') + await delay(300) + return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB + }) + + step.log.info(`Downloaded: ${fileSize} bytes`) + + // Resize to target width + const resizedSize = await step.run('resize', async () => { + step.progress(2, 3, 'Resizing...') + await delay(400) + return Math.floor(fileSize * (payload.width / 1920)) + }) + + step.log.info(`Resized to: ${resizedSize} bytes`) + + // Upload to CDN + const url = await step.run('upload', async () => { + step.progress(3, 3, 'Uploading...') + await delay(300) + return `https://cdn.example.com/${payload.width}/${payload.filename}` + }) + + step.log.info(`Uploaded to: ${url}`) + + return { url, size: resizedSize } + }, +}) diff --git a/examples/browser-vite-react/src/lib/database.ts b/examples/browser-vite-react/src/lib/database.ts new file mode 100644 index 00000000..5443dec3 --- /dev/null +++ b/examples/browser-vite-react/src/lib/database.ts @@ -0,0 +1,9 @@ +/** + * Database Configuration + * + * SQLocal instance for SQLite WASM with OPFS backend. + */ + +import { SQLocalKysely } from 'sqlocal/kysely' + +export const sqlocal = new SQLocalKysely('example.sqlite3') diff --git a/examples/browser-vite-react/src/lib/durably.ts b/examples/browser-vite-react/src/lib/durably.ts new file mode 100644 index 00000000..ab40e303 --- /dev/null +++ b/examples/browser-vite-react/src/lib/durably.ts @@ -0,0 +1,25 @@ +/** + * Durably instance for browser-only mode + * + * This creates a singleton Durably instance that is shared across the app. + */ + +import { createDurably } from '@coji/durably' +import { dataSyncJob, processImageJob } from '../jobs' +import { sqlocal } from './database' + +// Create and configure durably instance with chained register() +// register() returns a new Durably instance with type-safe jobs +const durably = createDurably({ + dialect: sqlocal.dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, +}).register({ + processImage: processImageJob, + dataSync: dataSyncJob, +}) + +await durably.init() + +export { durably } diff --git a/examples/react/src/main.tsx b/examples/browser-vite-react/src/main.tsx similarity index 92% rename from examples/react/src/main.tsx rename to examples/browser-vite-react/src/main.tsx index 983f944e..38b875fd 100644 --- a/examples/react/src/main.tsx +++ b/examples/browser-vite-react/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' +import './app.css' createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/examples/react/tsconfig.json b/examples/browser-vite-react/tsconfig.json similarity index 100% rename from examples/react/tsconfig.json rename to examples/browser-vite-react/tsconfig.json diff --git a/examples/browser/vercel.json b/examples/browser-vite-react/vercel.json similarity index 100% rename from examples/browser/vercel.json rename to examples/browser-vite-react/vercel.json diff --git a/examples/react/vite.config.ts b/examples/browser-vite-react/vite.config.ts similarity index 89% rename from examples/react/vite.config.ts rename to examples/browser-vite-react/vite.config.ts index d87cb90c..10e36a6f 100644 --- a/examples/react/vite.config.ts +++ b/examples/browser-vite-react/vite.config.ts @@ -1,8 +1,10 @@ +import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ + tailwindcss(), react(), { name: 'configure-response-headers', diff --git a/examples/browser/index.html b/examples/browser/index.html deleted file mode 100644 index 8bfe330f..00000000 --- a/examples/browser/index.html +++ /dev/null @@ -1,229 +0,0 @@ - - - - Durably Browser Example - - - -

Durably Browser Example

- -
- - -
- - -
-
- - - -
- -
- Status: Initializing... -
-
-

-    
- - -
-
-

Runs

- -
- - - - - - - - - - - - - - - -
IDJobStatusCreatedActions
Loading...
- -
- - - - diff --git a/examples/browser/package.json b/examples/browser/package.json deleted file mode 100644 index 758111fe..00000000 --- a/examples/browser/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "example-browser", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit", - "lint": "biome lint .", - "lint:fix": "biome lint --write .", - "format": "prettier --experimental-cli --check .", - "format:fix": "prettier --experimental-cli --write ." - }, - "dependencies": { - "@coji/durably": "workspace:*", - "kysely": "^0.28.9", - "sqlocal": "^0.16.0", - "zod": "^4.2.1" - }, - "devDependencies": { - "@biomejs/biome": "^2.3.10", - "prettier": "^3.7.4", - "prettier-plugin-organize-imports": "^4.3.0", - "typescript": "^5.9.3", - "vite": "^7.3.0" - } -} diff --git a/examples/browser/src/dashboard.ts b/examples/browser/src/dashboard.ts deleted file mode 100644 index 11bae294..00000000 --- a/examples/browser/src/dashboard.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Dashboard Module for Durably Browser Example - * - * Displays run history with status, details, and action buttons. - */ - -import type { Durably } from '@coji/durably' - -let durably: Durably - -/** - * Escape HTML special characters to prevent XSS - */ -function escapeHtml(unsafe: string): string { - return unsafe - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -const runsTbody = document.getElementById( - 'runs-tbody', -) as HTMLTableSectionElement -const refreshBtn = document.getElementById('refresh-btn') as HTMLButtonElement -const runDetailsEl = document.getElementById('run-details') as HTMLDivElement -const detailsContent = document.getElementById( - 'details-content', -) as HTMLDivElement - -export function initDashboard(durablyInstance: Durably) { - durably = durablyInstance - refreshBtn.addEventListener('click', refreshDashboard) -} - -export async function refreshDashboard() { - const runs = await durably.getRuns({ limit: 20 }) - - if (runs.length === 0) { - runsTbody.innerHTML = `No runs yet` - runDetailsEl.style.display = 'none' - return - } - - runsTbody.innerHTML = runs - .map( - (run) => ` - - ${run.id.slice(0, 8)}... - ${run.jobName} - ${run.status} - ${formatDate(run.createdAt)} - - - ${run.status === 'failed' ? `` : ''} - ${run.status === 'running' || run.status === 'pending' ? `` : ''} - ${run.status !== 'running' && run.status !== 'pending' ? `` : ''} - - - `, - ) - .join('') - - // Add event listeners - runsTbody.querySelectorAll('.view-btn').forEach((btn) => { - btn.addEventListener('click', () => - showRunDetails((btn as HTMLElement).dataset.id!), - ) - }) - runsTbody.querySelectorAll('.retry-btn').forEach((btn) => { - btn.addEventListener('click', async () => { - await durably.retry((btn as HTMLElement).dataset.id!) - refreshDashboard() - }) - }) - runsTbody.querySelectorAll('.cancel-btn').forEach((btn) => { - btn.addEventListener('click', async () => { - await durably.cancel((btn as HTMLElement).dataset.id!) - refreshDashboard() - }) - }) - runsTbody.querySelectorAll('.delete-btn').forEach((btn) => { - btn.addEventListener('click', async () => { - await durably.deleteRun((btn as HTMLElement).dataset.id!) - refreshDashboard() - }) - }) -} - -async function showRunDetails(runId: string) { - const run = await durably.getRun(runId) - if (!run) { - runDetailsEl.style.display = 'none' - return - } - - const steps = await durably.storage.getSteps(runId) - - detailsContent.innerHTML = ` -

ID: ${escapeHtml(run.id)}

-

Job: ${escapeHtml(run.jobName)}

-

Status: ${run.status}

-

Created: ${formatDate(run.createdAt)}

- ${run.progress ? `

Progress: ${run.progress.current}${run.progress.total ? `/${run.progress.total}` : ''} ${escapeHtml(run.progress.message || '')}

` : ''} - ${run.error ? `

Error: ${escapeHtml(run.error)}

` : ''} - ${run.output ? `

Output:

${escapeHtml(JSON.stringify(run.output, null, 2))}
` : ''} -

Payload:

-
${escapeHtml(JSON.stringify(run.payload, null, 2))}
- ${ - steps.length > 0 - ? ` -

Steps:

-
    - ${steps.map((s) => `
  • ${escapeHtml(s.name)}${s.status}
  • `).join('')} -
- ` - : '' - } - ` - runDetailsEl.style.display = 'block' -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleString() -} diff --git a/examples/browser/src/main.ts b/examples/browser/src/main.ts deleted file mode 100644 index 0b7f3501..00000000 --- a/examples/browser/src/main.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Browser Example for Durably - * - * Simple example showing basic durably usage in the browser. - * Demonstrates job resumption after page reload. - */ - -import { createDurably, defineJob } from '@coji/durably' -import { SQLocalKysely } from 'sqlocal/kysely' -import { z } from 'zod' -import { initDashboard, refreshDashboard } from './dashboard' - -// Initialize Durably -const sqlocal = new SQLocalKysely('example.sqlite3') -const { dialect, deleteDatabaseFile } = sqlocal - -const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) - -// Define job -const processImage = durably.register( - defineJob({ - name: 'process-image', - input: z.object({ filename: z.string(), width: z.number() }), - output: z.object({ url: z.string(), size: z.number() }), - run: async (step, payload) => { - // Download original image - const fileSize = await step.run('download', async () => { - await delay(300) - return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB - }) - - // Resize to target width - const resizedSize = await step.run('resize', async () => { - await delay(400) - return Math.floor(fileSize * (payload.width / 1920)) - }) - - // Upload to CDN - const url = await step.run('upload', async () => { - await delay(300) - return `https://cdn.example.com/${payload.width}/${payload.filename}` - }) - - return { url, size: resizedSize } - }, - }), -) - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -// ============================================ -// Demo Tab -// ============================================ - -const statusEl = document.getElementById('status') as HTMLElement -const stepEl = document.getElementById('step') as HTMLElement -const resultEl = document.getElementById('result') as HTMLPreElement -const runBtn = document.getElementById('run-btn') as HTMLButtonElement -const reloadBtn = document.getElementById('reload-btn') as HTMLButtonElement -const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement - -let userTriggered = false - -const statusText: Record = { - init: 'Initializing...', - ready: 'Ready', - running: 'Running', - resuming: '🔄 Resuming interrupted job...', - done: '✓ Completed', - error: '✗ Failed', -} - -function setStatus(status: string) { - statusEl.textContent = statusText[status] || status -} - -function setStep(step: string | null) { - stepEl.textContent = step ? `Step: ${step}` : '' -} - -function setResult(result: string | null, isError = false) { - resultEl.textContent = result || '' - resultEl.className = isError ? 'result error' : 'result' -} - -function setProcessing(processing: boolean) { - runBtn.disabled = processing - resetBtn.disabled = processing -} - -// Subscribe to events -durably.on('run:start', () => { - setStatus(userTriggered ? 'running' : 'resuming') - setProcessing(true) -}) - -durably.on('step:complete', (e) => { - setStep(e.stepName) -}) - -durably.on('run:complete', (e) => { - setResult(JSON.stringify(e.output, null, 2)) - setStep(null) - setStatus('done') - setProcessing(false) - userTriggered = false - refreshDashboard() -}) - -durably.on('run:fail', (e) => { - setResult(e.error, true) - setStep(null) - setStatus('error') - setProcessing(false) - userTriggered = false - refreshDashboard() -}) - -// Button handlers -runBtn.addEventListener('click', async () => { - userTriggered = true - setStatus('running') - setStep(null) - setResult(null) - await processImage.trigger({ filename: 'photo.jpg', width: 800 }) - refreshDashboard() -}) - -reloadBtn.addEventListener('click', () => { - location.reload() -}) - -resetBtn.addEventListener('click', async () => { - await durably.stop() - await deleteDatabaseFile() - location.reload() -}) - -// ============================================ -// Tab Navigation -// ============================================ - -const tabs = Array.from(document.querySelectorAll('.tab')) -const tabContents = Array.from(document.querySelectorAll('.tab-content')) - -for (const tab of tabs) { - tab.addEventListener('click', () => { - const tabName = (tab as HTMLElement).dataset.tab - for (const t of tabs) t.classList.remove('active') - for (const c of tabContents) c.classList.remove('active') - tab.classList.add('active') - document.getElementById(`${tabName}-tab`)?.classList.add('active') - - if (tabName === 'dashboard') { - refreshDashboard() - } - }) -} - -// ============================================ -// Initialize -// ============================================ - -initDashboard(durably) - -durably.migrate().then(() => { - durably.start() - setStatus('ready') - runBtn.disabled = false - reloadBtn.disabled = false - resetBtn.disabled = false - refreshDashboard() -}) diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json deleted file mode 100644 index 7fce9f12..00000000 --- a/examples/browser/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true, - "esModuleInterop": true, - "noEmit": true, - "lib": ["ES2022", "DOM"] - }, - "include": ["src/**/*.ts"] -} diff --git a/examples/fullstack-react-router/.dockerignore b/examples/fullstack-react-router/.dockerignore new file mode 100644 index 00000000..9b8d5147 --- /dev/null +++ b/examples/fullstack-react-router/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/examples/fullstack-react-router/.gitignore b/examples/fullstack-react-router/.gitignore new file mode 100644 index 00000000..4ba50657 --- /dev/null +++ b/examples/fullstack-react-router/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + +# SQLite database +local.db diff --git a/examples/fullstack-react-router/Dockerfile b/examples/fullstack-react-router/Dockerfile new file mode 100644 index 00000000..207bf937 --- /dev/null +++ b/examples/fullstack-react-router/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:20-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:20-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:20-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +WORKDIR /app +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/examples/fullstack-react-router/README.md b/examples/fullstack-react-router/README.md new file mode 100644 index 00000000..5c4780a2 --- /dev/null +++ b/examples/fullstack-react-router/README.md @@ -0,0 +1,87 @@ +# Welcome to React Router! + +A modern, production-ready template for building full-stack React applications using React Router. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) + +## Features + +- 🚀 Server-side rendering +- ⚡️ Hot Module Replacement (HMR) +- 📦 Asset bundling and optimization +- 🔄 Data loading and mutations +- 🔒 TypeScript by default +- 🎉 TailwindCSS for styling +- 📖 [React Router docs](https://reactrouter.com/) + +## Getting Started + +### Installation + +Install the dependencies: + +```bash +npm install +``` + +### Development + +Start the development server with HMR: + +```bash +npm run dev +``` + +Your application will be available at `http://localhost:5173`. + +## Building for Production + +Create a production build: + +```bash +npm run build +``` + +## Deployment + +### Docker Deployment + +To build and run using Docker: + +```bash +docker build -t my-app . + +# Run the container +docker run -p 3000:3000 my-app +``` + +The containerized application can be deployed to any platform that supports Docker, including: + +- AWS ECS +- Google Cloud Run +- Azure Container Apps +- Digital Ocean App Platform +- Fly.io +- Railway + +### DIY Deployment + +If you're familiar with deploying Node applications, the built-in app server is production-ready. + +Make sure to deploy the output of `npm run build` + +``` +├── package.json +├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) +├── build/ +│ ├── client/ # Static assets +│ └── server/ # Server-side code +``` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. + +--- + +Built with ❤️ using React Router. diff --git a/examples/fullstack-react-router/app/app.css b/examples/fullstack-react-router/app/app.css new file mode 100644 index 00000000..f3902dce --- /dev/null +++ b/examples/fullstack-react-router/app/app.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: + 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +html, +body { + @apply bg-gray-50; +} diff --git a/examples/fullstack-react-router/app/jobs/data-sync.ts b/examples/fullstack-react-router/app/jobs/data-sync.ts new file mode 100644 index 00000000..1a3e9ebc --- /dev/null +++ b/examples/fullstack-react-router/app/jobs/data-sync.ts @@ -0,0 +1,56 @@ +/** + * Data Sync Job + * + * Simulates syncing data with a remote server. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const dataSyncJob = defineJob({ + name: 'data-sync', + input: z.object({ userId: z.string() }), + output: z.object({ synced: z.number(), failed: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting sync for user: ${payload.userId}`) + + const items = await step.run('fetch-local', async () => { + step.progress(1, 4, 'Fetching local data...') + await delay(300) + return Array.from({ length: 10 }, (_, i) => ({ + id: `item-${i}`, + data: `Data for ${payload.userId}`, + })) + }) + + let synced = 0 + let failed = 0 + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const success = await step.run(`sync-item-${item.id}`, async () => { + step.progress(2 + Math.floor(i / 5), 4, `Syncing item ${i + 1}...`) + await delay(100) + return Math.random() > 0.1 // 90% success rate + }) + + if (success) { + synced++ + } else { + failed++ + step.log.warn(`Failed to sync item: ${item.id}`) + } + } + + await step.run('finalize', async () => { + step.progress(4, 4, 'Finalizing...') + await delay(200) + }) + + step.log.info(`Sync complete: ${synced} synced, ${failed} failed`) + + return { synced, failed } + }, +}) diff --git a/examples/fullstack-react-router/app/jobs/import-csv.ts b/examples/fullstack-react-router/app/jobs/import-csv.ts new file mode 100644 index 00000000..b2ce147b --- /dev/null +++ b/examples/fullstack-react-router/app/jobs/import-csv.ts @@ -0,0 +1,92 @@ +/** + * CSV Import Job + * + * Demonstrates separation of steps (resumable units) and progress (UI feedback). + * - Steps: validate, import, finalize (3 resumable checkpoints) + * - Progress: fine-grained row-level feedback within each step + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const csvRowSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + amount: z.number(), +}) + +/** Output schema for type inference */ +const outputSchema = z.object({ imported: z.number(), failed: z.number() }) + +/** Output type for use in components */ +export type ImportCsvOutput = z.infer + +export const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ + filename: z.string(), + rows: z.array(csvRowSchema), + }), + output: outputSchema, + run: async (step, payload) => { + step.log.info( + `Starting import of ${payload.filename} (${payload.rows.length} rows)`, + ) + + // Step 1: Validate all rows + const validRows = await step.run('validate', async () => { + const valid: typeof payload.rows = [] + const invalid: { row: (typeof payload.rows)[0]; reason: string }[] = [] + + for (let i = 0; i < payload.rows.length; i++) { + const row = payload.rows[i] + step.progress(i + 1, payload.rows.length, `Validating ${row.name}...`) + await delay(50) + + if (row.amount < 0) { + invalid.push({ row, reason: `Invalid amount: ${row.amount}` }) + step.log.warn(`Validation failed for ${row.name}: negative amount`) + } else { + valid.push(row) + } + } + + step.log.info( + `Validation complete: ${valid.length} valid, ${invalid.length} invalid`, + ) + return { valid, invalidCount: invalid.length } + }) + + // Step 2: Import valid rows + const importResult = await step.run('import', async () => { + let imported = 0 + + for (let i = 0; i < validRows.valid.length; i++) { + const row = validRows.valid[i] + step.progress(i + 1, validRows.valid.length, `Importing ${row.name}...`) + await delay(80) + + // Simulate import + imported++ + step.log.info(`Imported: ${row.name} (${row.email}) - $${row.amount}`) + } + + return { imported } + }) + + // Step 3: Finalize + await step.run('finalize', async () => { + step.progress(1, 1, 'Finalizing...') + await delay(200) + step.log.info('Import finalized') + }) + + return { + imported: importResult.imported, + failed: validRows.invalidCount, + } + }, +}) diff --git a/examples/fullstack-react-router/app/jobs/index.ts b/examples/fullstack-react-router/app/jobs/index.ts new file mode 100644 index 00000000..e87adee6 --- /dev/null +++ b/examples/fullstack-react-router/app/jobs/index.ts @@ -0,0 +1,10 @@ +/** + * Job Definitions + * + * Barrel export for all job definitions. + * 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' diff --git a/examples/fullstack-react-router/app/jobs/process-image.ts b/examples/fullstack-react-router/app/jobs/process-image.ts new file mode 100644 index 00000000..aa8197eb --- /dev/null +++ b/examples/fullstack-react-router/app/jobs/process-image.ts @@ -0,0 +1,48 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string(), width: z.number() }), + output: z.object({ url: z.string(), size: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting image processing: ${payload.filename}`) + + // Download original image + const fileSize = await step.run('download', async () => { + step.progress(1, 3, 'Downloading...') + await delay(500) + return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB + }) + + step.log.info(`Downloaded: ${fileSize} bytes`) + + // Resize to target width + const resizedSize = await step.run('resize', async () => { + step.progress(2, 3, 'Resizing...') + await delay(600) + return Math.floor(fileSize * (payload.width / 1920)) + }) + + step.log.info(`Resized to: ${resizedSize} bytes`) + + // Upload to CDN + const url = await step.run('upload', async () => { + step.progress(3, 3, 'Uploading...') + await delay(400) + return `https://cdn.example.com/${payload.width}/${payload.filename}` + }) + + step.log.info(`Uploaded to: ${url}`) + + return { url, size: resizedSize } + }, +}) diff --git a/examples/fullstack-react-router/app/lib/database.server.ts b/examples/fullstack-react-router/app/lib/database.server.ts new file mode 100644 index 00000000..09ffa9db --- /dev/null +++ b/examples/fullstack-react-router/app/lib/database.server.ts @@ -0,0 +1,12 @@ +/** + * Database Configuration + * + * libSQL dialect for server-side SQLite. + * Server-only - do not import in client code. + */ + +import { LibsqlDialect } from '@libsql/kysely-libsql' + +export const dialect = new LibsqlDialect({ + url: process.env.DATABASE_URL ?? 'file:./local.db', +}) diff --git a/examples/fullstack-react-router/app/lib/durably.server.ts b/examples/fullstack-react-router/app/lib/durably.server.ts new file mode 100644 index 00000000..e62361d0 --- /dev/null +++ b/examples/fullstack-react-router/app/lib/durably.server.ts @@ -0,0 +1,29 @@ +/** + * Durably Server Configuration + * + * Sets up Durably instance, registers jobs, and provides HTTP handler. + * Server-only - do not import in client code. + * + * Note: In development with HMR, this module may reload on changes. + * For production apps, consider using a singleton pattern to prevent + * multiple instances. + */ + +import { createDurably, createDurablyHandler } from '@coji/durably' +import { dataSyncJob, importCsvJob, processImageJob } from '~/jobs' +import { dialect } from './database.server' + +// Create Durably instance with registered jobs +export const durably = createDurably({ + dialect, +}).register({ + processImage: processImageJob, + dataSync: dataSyncJob, + importCsv: importCsvJob, +}) + +// HTTP handler for SSE streaming +export const durablyHandler = createDurablyHandler(durably) + +// Initialize database and start worker +await durably.init() diff --git a/examples/fullstack-react-router/app/root.tsx b/examples/fullstack-react-router/app/root.tsx new file mode 100644 index 00000000..f9f8bdfc --- /dev/null +++ b/examples/fullstack-react-router/app/root.tsx @@ -0,0 +1,75 @@ +import { + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from 'react-router' + +import type { Route } from './+types/root' +import './app.css' + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, +] + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export default function App() { + return +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!' + let details = 'An unexpected error occurred.' + let stack: string | undefined + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error' + details = + error.status === 404 + ? 'The requested page could not be found.' + : error.statusText || details + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message + stack = error.stack + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ) +} diff --git a/examples/fullstack-react-router/app/routes.ts b/examples/fullstack-react-router/app/routes.ts new file mode 100644 index 00000000..ed0b76c1 --- /dev/null +++ b/examples/fullstack-react-router/app/routes.ts @@ -0,0 +1,6 @@ +import { type RouteConfig, index, route } from '@react-router/dev/routes' + +export default [ + index('routes/_index.tsx'), + route('api/durably/*', 'routes/api.durably.$.ts'), +] satisfies RouteConfig diff --git a/examples/fullstack-react-router/app/routes/_index.tsx b/examples/fullstack-react-router/app/routes/_index.tsx new file mode 100644 index 00000000..c29c3c44 --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index.tsx @@ -0,0 +1,126 @@ +/** + * Full-Stack React Router Example + * + * This example demonstrates: + * - React Router v7 with server-side action + * - SSE streaming for real-time progress updates + * - action for Form-based job triggering + * - useJobRun hook for monitoring jobs via SSE + */ + +import { useState } from 'react' +import { durably } from '~/lib/durably.server' +import type { Route } from './+types/_index' +import { Dashboard } from './_index/dashboard' +import { DataSyncForm } from './_index/data-sync-form' +import { DataSyncProgress } from './_index/data-sync-progress' +import { ImageProcessingForm } from './_index/image-processing-form' +import { ImageProcessingProgress } from './_index/image-processing-progress' + +export function meta() { + return [ + { title: 'Durably - Full-Stack React Router' }, + { name: 'description', content: 'Full-stack job processing with SSE' }, + ] +} + +// Action: Trigger jobs +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + const intent = formData.get('intent') as string + + if (intent === 'image') { + const filename = formData.get('filename') as string + const width = Number(formData.get('width')) + const run = await durably.jobs.processImage.trigger({ filename, width }) + return { intent: 'image', runId: run.id } + } + + if (intent === 'sync') { + const userId = formData.get('userId') as string + const run = await durably.jobs.dataSync.trigger({ userId }) + return { intent: 'sync', runId: run.id } + } + + return null +} + +export default function Index() { + const [activeJob, setActiveJob] = useState<'image' | 'sync'>('image') + + return ( +
+
+
+

+ Durably - Full-Stack React Router +

+

+ React Router v7 with server action + SSE streaming +

+
+ +
+ {/* Left: Job Trigger + Progress */} +
+ {/* Job Selection */} +
+
+

Run Job

+
+ +
+ + +
+ + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Progress Display */} + {activeJob === 'image' ? ( + + ) : ( + + )} +
+ + {/* Right: Dashboard */} + +
+ +
+

Data is stored on the server using SQLite (Turso/libSQL).

+

+ Try reloading the page during job execution - progress updates via + SSE! +

+
+
+
+ ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx new file mode 100644 index 00000000..a9cd8211 --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -0,0 +1,343 @@ +/** + * Dashboard Component + * + * Displays run history with real-time updates via SSE and pagination. + * First page auto-subscribes to SSE for instant updates. + */ + +import type { RunRecord, StepRecord } from '@coji/durably-react/client' +import { useRunActions, useRuns } from '@coji/durably-react/client' +import { useState } from 'react' + +export function Dashboard() { + const { runs, isLoading, error, page, hasMore, nextPage, prevPage, refresh } = + useRuns({ + api: '/api/durably', + pageSize: 6, + }) + + const { + cancel, + retry, + deleteRun, + getRun, + getSteps, + isLoading: isActioning, + } = useRunActions({ + api: '/api/durably', + }) + + const [selectedRun, setSelectedRun] = useState(null) + const [steps, setSteps] = useState([]) + + const handleCancel = async (runId: string) => { + await cancel(runId) + refresh() + } + + const handleRetry = async (runId: string) => { + await retry(runId) + refresh() + } + + const handleDelete = async (runId: string) => { + await deleteRun(runId) + setSelectedRun(null) + refresh() + } + + const showDetails = async (runId: string) => { + const run = await getRun(runId) + if (run) { + setSelectedRun(run) + const stepsData = await getSteps(runId) + setSteps(stepsData) + } + } + + const formatDate = (iso: string) => new Date(iso).toLocaleString() + + const statusClasses: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + cancelled: 'bg-gray-100 text-gray-800', + } + + return ( +
+
+

Run History

+
+ {isLoading && ( + Refreshing... + )} + +
+
+ + {error &&
Error: {error}
} + + {runs.length === 0 ? ( +

No runs yet

+ ) : ( + <> +
+ + + + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + + + ))} + +
+ ID + + Job + + Status + + Steps + + Progress + + Created + + Actions +
+ {run.id.slice(0, 8)}... + {run.jobName} + + {run.status} + + + {run.stepCount > 0 ? ( + + {run.stepCount} + + ) : ( + - + )} + + {run.progress ? ( +
+
+
0 ? 100 : 0}%`, + }} + /> +
+ + {run.progress.current} + {run.progress.total && `/${run.progress.total}`} + +
+ ) : ( + - + )} +
+ {formatDate(run.createdAt)} + +
+ + {(run.status === 'failed' || + run.status === 'cancelled') && ( + + )} + {(run.status === 'running' || + run.status === 'pending') && ( + + )} + {run.status !== 'running' && + run.status !== 'pending' && ( + + )} +
+
+
+ + {/* Pagination */} +
+ + Page {page + 1} + +
+ + )} + + {/* Run Details Modal */} + {selectedRun && ( +
+
+
+
+

Run Details

+ +
+ +
+
+ ID:{' '} + + {selectedRun.id} + +
+
+ Job:{' '} + {selectedRun.jobName} +
+
+ Status:{' '} + + {selectedRun.status} + +
+
+ Created:{' '} + {formatDate(selectedRun.createdAt)} +
+ + {selectedRun.progress && ( +
+ Progress:{' '} + {selectedRun.progress.current} + {selectedRun.progress.total + ? `/${selectedRun.progress.total}` + : ''}{' '} + {selectedRun.progress.message || ''} +
+ )} + + {selectedRun.error && ( +
+ Error:{' '} + {selectedRun.error} +
+ )} + + {selectedRun.output !== null && ( +
+ Output: +
+                      {JSON.stringify(selectedRun.output, null, 2)}
+                    
+
+ )} + +
+ Payload: +
+                    {JSON.stringify(selectedRun.payload, null, 2)}
+                  
+
+ + {steps.length > 0 && ( +
+ Steps: +
    + {steps.map((s) => ( +
  • + {s.name} + + {s.status} + +
  • + ))} +
+
+ )} +
+
+
+
+ )} +
+ ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/data-sync-form.tsx b/examples/fullstack-react-router/app/routes/_index/data-sync-form.tsx new file mode 100644 index 00000000..e9f7a8fe --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/data-sync-form.tsx @@ -0,0 +1,47 @@ +/** + * Data Sync Form Component + * + * Form for triggering data sync jobs via server action. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { action } from '../_index' + +export function DataSyncForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'sync' ? actionData.runId : null + + return ( +
+ +
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx b/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx new file mode 100644 index 00000000..c698e8c8 --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx @@ -0,0 +1,44 @@ +/** + * Data Sync Progress Component + * + * Displays progress for the data sync job using useJobRun. + */ + +import { useJobRun } from '@coji/durably-react/client' +import { useActionData } from 'react-router' +import type { action } from '../_index' +import { RunProgress } from './run-progress' + +export function DataSyncProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'sync' ? actionData.runId : null + + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJobRun({ + api: '/api/durably', + runId, + }) + + return ( + + ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/image-processing-form.tsx b/examples/fullstack-react-router/app/routes/_index/image-processing-form.tsx new file mode 100644 index 00000000..e3bbe2e6 --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/image-processing-form.tsx @@ -0,0 +1,64 @@ +/** + * Image Processing Form Component + * + * Form for triggering image processing jobs via server action. + */ + +import { Form, useActionData, useNavigation } from 'react-router' +import type { action } from '../_index' + +export function ImageProcessingForm() { + const actionData = useActionData() + const navigation = useNavigation() + const isSubmitting = navigation.state === 'submitting' + const runId = actionData?.intent === 'image' ? actionData.runId : null + + return ( +
+ +
+ + +
+
+ + +
+ + {runId && ( +
+ Triggered: {runId.slice(0, 8)} +
+ )} +
+ ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx b/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx new file mode 100644 index 00000000..5d11af4f --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx @@ -0,0 +1,44 @@ +/** + * Image Processing Progress Component + * + * Displays progress for the image processing job using useJobRun. + */ + +import { useJobRun } from '@coji/durably-react/client' +import { useActionData } from 'react-router' +import type { action } from '../_index' +import { RunProgress } from './run-progress' + +export function ImageProcessingProgress() { + const actionData = useActionData() + const runId = actionData?.intent === 'image' ? actionData.runId : null + + const { + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, + } = useJobRun({ + api: '/api/durably', + runId, + }) + + return ( + + ) +} diff --git a/examples/fullstack-react-router/app/routes/_index/run-progress.tsx b/examples/fullstack-react-router/app/routes/_index/run-progress.tsx new file mode 100644 index 00000000..9611fa54 --- /dev/null +++ b/examples/fullstack-react-router/app/routes/_index/run-progress.tsx @@ -0,0 +1,125 @@ +/** + * RunProgress Component + * + * Displays real-time progress and result for jobs. + */ + +import type { LogEntry } from '@coji/durably-react/client' + +interface RunProgressProps { + progress: { current: number; total?: number; message?: string } | null + output: unknown + error: string | null + logs: LogEntry[] + isPending: boolean + isRunning: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean +} + +export function RunProgress({ + progress, + output, + error, + logs, + isPending, + isRunning, + isCompleted, + isFailed, + isCancelled, +}: RunProgressProps) { + // Don't render anything if no activity + if (!isPending && !isRunning && !isCompleted && !isFailed && !isCancelled) { + return null + } + + return ( + <> + {/* Pending State */} + {isPending && ( +
+
Waiting to start...
+
+ )} + + {/* Progress Display */} + {isRunning && progress && ( +
+
+ Progress + + {progress.current}/{progress.total || '?'} + +
+
+
+
+ {progress.message && ( +
{progress.message}
+ )} +
+ )} + + {/* Success Result */} + {isCompleted && output !== null && output !== undefined && ( +
+
Completed!
+
+            {JSON.stringify(output, null, 2)}
+          
+
+ )} + + {/* Error Result */} + {isFailed && ( +
+
Failed
+
{error}
+
+ )} + + {/* Cancelled Result */} + {isCancelled && ( +
+
Cancelled
+
+ The job was cancelled before completion. +
+
+ )} + + {/* Logs */} + {logs.length > 0 && ( +
+

Logs

+
+
    + {logs.map((log) => ( +
  • + + [{log.level}] + {' '} + {log.message} +
  • + ))} +
+
+
+ )} + + ) +} diff --git a/examples/fullstack-react-router/app/routes/api.durably.$.ts b/examples/fullstack-react-router/app/routes/api.durably.$.ts new file mode 100644 index 00000000..daa1494b --- /dev/null +++ b/examples/fullstack-react-router/app/routes/api.durably.$.ts @@ -0,0 +1,22 @@ +/** + * Durably API Route (Splat) + * + * GET /api/durably/subscribe?runId=xxx - SSE stream for single run + * GET /api/durably/runs/subscribe?jobName=xxx - SSE stream for run updates + * GET /api/durably/runs - List runs + * GET /api/durably/run?runId=xxx - Get single run + * POST /api/durably/trigger - Trigger a job + * POST /api/durably/retry?runId=xxx - Retry a failed run + * POST /api/durably/cancel?runId=xxx - Cancel a run + */ + +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} diff --git a/examples/fullstack-react-router/biome.json b/examples/fullstack-react-router/biome.json new file mode 100644 index 00000000..cb0ac4d9 --- /dev/null +++ b/examples/fullstack-react-router/biome.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "extends": ["../../biome.json"], + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/examples/fullstack-react-router/package.json b/examples/fullstack-react-router/package.json new file mode 100644 index 00000000..d3742b96 --- /dev/null +++ b/examples/fullstack-react-router/package.json @@ -0,0 +1,41 @@ +{ + "name": "example-fullstack-react-router", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "prettier --experimental-cli --check .", + "format:fix": "prettier --experimental-cli --write ." + }, + "dependencies": { + "@coji/durably": "workspace:*", + "@coji/durably-react": "workspace:*", + "@libsql/kysely-libsql": "^0.4.1", + "@react-router/node": "7.11.0", + "@react-router/serve": "7.11.0", + "isbot": "^5.1.31", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router": "7.11.0", + "zod": "^4.3.4" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.10", + "@react-router/dev": "7.11.0", + "@tailwindcss/vite": "^4.1.13", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2", + "vite": "^7.1.7", + "vite-tsconfig-paths": "^6.0.3" + } +} diff --git a/examples/fullstack-react-router/prettier.config.js b/examples/fullstack-react-router/prettier.config.js new file mode 100644 index 00000000..48dce797 --- /dev/null +++ b/examples/fullstack-react-router/prettier.config.js @@ -0,0 +1,7 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 80, + plugins: ['prettier-plugin-organize-imports'], +} diff --git a/examples/fullstack-react-router/public/favicon.ico b/examples/fullstack-react-router/public/favicon.ico new file mode 100644 index 00000000..5dbdfcdd Binary files /dev/null and b/examples/fullstack-react-router/public/favicon.ico differ diff --git a/examples/fullstack-react-router/react-router.config.ts b/examples/fullstack-react-router/react-router.config.ts new file mode 100644 index 00000000..d5306db8 --- /dev/null +++ b/examples/fullstack-react-router/react-router.config.ts @@ -0,0 +1,7 @@ +import type { Config } from '@react-router/dev/config' + +export default { + // Config options... + // Server-side render by default, to enable SPA mode set this to `false` + ssr: true, +} satisfies Config diff --git a/examples/fullstack-react-router/tsconfig.json b/examples/fullstack-react-router/tsconfig.json new file mode 100644 index 00000000..dc391a45 --- /dev/null +++ b/examples/fullstack-react-router/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/examples/fullstack-react-router/vite.config.ts b/examples/fullstack-react-router/vite.config.ts new file mode 100644 index 00000000..de677b2b --- /dev/null +++ b/examples/fullstack-react-router/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from '@react-router/dev/vite' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' +import tsconfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], +}) diff --git a/examples/node/basic.ts b/examples/node/basic.ts deleted file mode 100644 index e631a600..00000000 --- a/examples/node/basic.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Node.js Example for Durably - * - * This example shows basic usage of Durably with Turso/libSQL. - * Same job definition as browser/react examples for comparison. - */ - -import { createDurably, defineJob } from '@coji/durably' -import { LibsqlDialect } from '@libsql/kysely-libsql' -import { z } from 'zod' - -// Turso の場合は環境変数から URL と authToken を取得 -// ローカル開発では file:local.db を使用 -const dialect = new LibsqlDialect({ - url: process.env.TURSO_DATABASE_URL ?? 'file:local.db', - authToken: process.env.TURSO_AUTH_TOKEN, -}) - -const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) - -// Image processing job with sequential steps -const processImage = durably.register( - defineJob({ - name: 'process-image', - input: z.object({ filename: z.string() }), - output: z.object({ url: z.string() }), - run: async (step, payload) => { - // Step 1: Download - const data = await step.run('download', async () => { - await new Promise((r) => setTimeout(r, 500)) - return { size: 1024000 } - }) - - // Step 2: Resize - await step.run('resize', async () => { - await new Promise((r) => setTimeout(r, 500)) - return { width: 800, height: 600, size: data.size / 2 } - }) - - // Step 3: Upload - const uploaded = await step.run('upload', async () => { - await new Promise((r) => setTimeout(r, 500)) - return { url: `https://cdn.example.com/${payload.filename}` } - }) - - return { url: uploaded.url } - }, - }), -) - -// Subscribe to events -durably.on('run:start', (event) => { - console.log(`[run:start] ${event.jobName}`) -}) - -durably.on('step:complete', (event) => { - console.log(`[step:complete] ${event.stepName}`) -}) - -durably.on('run:complete', (event) => { - console.log( - `[run:complete] output=${JSON.stringify(event.output)} duration=${event.duration}ms`, - ) -}) - -durably.on('run:fail', (event) => { - console.log(`[run:fail] ${event.error}`) -}) - -// Main -async function main() { - console.log('Durably Node.js Example') - console.log('=======================\n') - - await durably.migrate() - console.log('Migration completed') - - durably.start() - console.log('Worker started\n') - - // Trigger job and wait for completion - const { id, output } = await processImage.triggerAndWait({ - filename: 'photo.jpg', - }) - console.log(`\nRun ${id} completed`) - console.log(`Output: ${JSON.stringify(output)}`) - - // Show stats - const runs = await durably.storage.getRuns() - console.log(`\nDatabase Stats:`) - console.log(` Pending: ${runs.filter((r) => r.status === 'pending').length}`) - console.log(` Running: ${runs.filter((r) => r.status === 'running').length}`) - console.log( - ` Completed: ${runs.filter((r) => r.status === 'completed').length}`, - ) - console.log(` Failed: ${runs.filter((r) => r.status === 'failed').length}`) - - // Cleanup - await durably.stop() - await durably.db.destroy() - console.log('\nDone!') -} - -main().catch(console.error) diff --git a/examples/react/index.html b/examples/react/index.html deleted file mode 100644 index 6baf955f..00000000 --- a/examples/react/index.html +++ /dev/null @@ -1,197 +0,0 @@ - - - - Durably React Example - - - -
- - - diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx deleted file mode 100644 index ab1e5d1a..00000000 --- a/examples/react/src/App.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * React Example for Durably - * - * Simple example showing basic durably usage with React. - * Demonstrates job resumption after page reload. - * - * Structure follows the pattern that will be provided by @coji/durably-react: - * - lib/durably.ts: Singleton durably instance - * - hooks/useDurably.ts: React lifecycle management hook - * - jobs/*.ts: Job definitions - */ - -import { useState } from 'react' -import { Dashboard } from './Dashboard' -import { useDurably } from './hooks/useDurably' -import { processImage } from './jobs/processImage' -import { deleteDatabaseFile, durably } from './lib/durably' -import { styles } from './styles' - -// Links -const GITHUB_REPO = 'https://github.com/coji/durably' -const SOURCE_CODE = `${GITHUB_REPO}/tree/main/examples/react` - -// Main App -export function App() { - const { - status, - currentStep, - result, - markUserTriggered, - setRefreshDashboard, - refreshDashboard, - } = useDurably() - const [activeTab, setActiveTab] = useState<'demo' | 'dashboard'>('demo') - const isProcessing = status === 'running' || status === 'resuming' - - const statusText: Record = { - init: 'Initializing...', - ready: 'Ready', - running: 'Running', - resuming: '🔄 Resuming interrupted job...', - done: '✓ Completed', - error: '✗ Failed', - } - - const handleRun = async () => { - markUserTriggered() - await processImage.trigger({ filename: 'photo.jpg', width: 800 }) - refreshDashboard() - } - - const handleReset = async () => { - await durably.stop() - await deleteDatabaseFile() - location.reload() - } - - return ( -
-
-

Durably React Example

- -
- -
- - -
- - {activeTab === 'demo' && ( - <> -
- - - -
- -
-
- Status: {statusText[status]} -
- {currentStep && ( -
- Step: {currentStep} -
- )} -
- - {result && ( -
{result}
- )} - - )} - - {activeTab === 'dashboard' && ( - - )} -
- ) -} diff --git a/examples/react/src/Dashboard.tsx b/examples/react/src/Dashboard.tsx deleted file mode 100644 index 00789161..00000000 --- a/examples/react/src/Dashboard.tsx +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Dashboard Component for Durably React Example - * - * Displays run history with status, details, and action buttons. - */ - -import type { Durably, Run } from '@coji/durably' -import { useCallback, useEffect, useState } from 'react' - -// Styles -const styles = { - table: { - width: '100%', - borderCollapse: 'collapse' as const, - fontSize: '0.875rem', - }, - th: { - padding: '0.5rem', - textAlign: 'left' as const, - borderBottom: '1px solid #e0e0e0', - background: '#f5f5f5', - }, - td: { - padding: '0.5rem', - textAlign: 'left' as const, - borderBottom: '1px solid #e0e0e0', - }, - badge: (status: string) => ({ - display: 'inline-block', - padding: '0.125rem 0.5rem', - borderRadius: '9999px', - fontSize: '0.75rem', - fontWeight: 500, - background: - status === 'pending' - ? '#fff3cd' - : status === 'running' - ? '#cce5ff' - : status === 'completed' - ? '#d4edda' - : status === 'failed' - ? '#f8d7da' - : '#e2e3e5', - color: - status === 'pending' - ? '#856404' - : status === 'running' - ? '#004085' - : status === 'completed' - ? '#155724' - : status === 'failed' - ? '#721c24' - : '#383d41', - }), - runId: { fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }, - actionBtn: { - padding: '0.25rem 0.5rem', - fontSize: '0.75rem', - marginLeft: '0.25rem', - cursor: 'pointer', - }, - details: { - marginTop: '1.5rem', - padding: '1rem', - background: '#f9f9f9', - borderRadius: '4px', - }, - result: { - background: '#f5f5f5', - padding: '1rem', - borderRadius: '4px', - fontFamily: 'monospace', - whiteSpace: 'pre-wrap' as const, - }, - stepsList: { listStyle: 'none', padding: 0, margin: 0 }, - stepsItem: { - padding: '0.5rem', - borderBottom: '1px solid #e0e0e0', - display: 'flex', - justifyContent: 'space-between', - }, -} - -interface DashboardProps { - durably: Durably - onMount: (refresh: () => void) => void -} - -export function Dashboard({ durably, onMount }: DashboardProps) { - const [runs, setRuns] = useState([]) - const [selectedRun, setSelectedRun] = useState(null) - const [steps, setSteps] = useState< - { index: number; name: string; status: string }[] - >([]) - - const refresh = useCallback(async () => { - const data = await durably.getRuns({ limit: 20 }) - setRuns(data) - }, [durably]) - - useEffect(() => { - refresh() - onMount(refresh) - }, [refresh, onMount]) - - const showDetails = async (runId: string) => { - const run = await durably.getRun(runId) - if (run) { - setSelectedRun(run) - const stepsData = await durably.storage.getSteps(runId) - setSteps( - stepsData.map((s, i) => ({ index: i, name: s.name, status: s.status })), - ) - } - } - - const handleRetry = async (runId: string) => { - await durably.retry(runId) - refresh() - } - - const handleCancel = async (runId: string) => { - await durably.cancel(runId) - refresh() - } - - const handleDelete = async (runId: string) => { - await durably.deleteRun(runId) - setSelectedRun(null) - refresh() - } - - const formatDate = (iso: string) => new Date(iso).toLocaleString() - - return ( -
-
-

Runs

- -
- - - - - - - - - - - - - {runs.length === 0 ? ( - - - - ) : ( - runs.map((run) => ( - - - - - - - - )) - )} - -
IDJobStatusCreatedActions
- No runs yet -
- {run.id.slice(0, 8)}... - {run.jobName} - {run.status} - {formatDate(run.createdAt)} - - {run.status === 'failed' && ( - - )} - {(run.status === 'running' || run.status === 'pending') && ( - - )} - {run.status !== 'running' && run.status !== 'pending' && ( - - )} -
- - {selectedRun && ( -
-

Run Details

-

- ID:{' '} - {selectedRun.id} -

-

- Job: {selectedRun.jobName} -

-

- Status:{' '} - - {selectedRun.status} - -

-

- Created: {formatDate(selectedRun.createdAt)} -

- {selectedRun.progress && ( -

- Progress: {selectedRun.progress.current} - {selectedRun.progress.total - ? `/${selectedRun.progress.total}` - : ''}{' '} - {selectedRun.progress.message || ''} -

- )} - {selectedRun.error && ( -

- Error:{' '} - {selectedRun.error} -

- )} - {selectedRun.output !== null && ( - <> -

- Output: -

-
-                {JSON.stringify(selectedRun.output, null, 2)}
-              
- - )} -

- Payload: -

-
-            {JSON.stringify(selectedRun.payload, null, 2)}
-          
- {steps.length > 0 && ( - <> -

- Steps: -

-
    - {steps.map((s) => ( -
  • - {s.name} - - {s.status} - -
  • - ))} -
- - )} -
- )} -
- ) -} diff --git a/examples/react/src/hooks/useDurably.ts b/examples/react/src/hooks/useDurably.ts deleted file mode 100644 index d1b47ede..00000000 --- a/examples/react/src/hooks/useDurably.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * useDurably hook - * - * Manages durably lifecycle in React applications. - * In the future, this hook will be provided by @coji/durably-react. - */ - -import { useEffect, useRef, useState } from 'react' -import { durably } from '../lib/durably' - -export type DurablyStatus = - | 'init' - | 'ready' - | 'running' - | 'resuming' - | 'done' - | 'error' - -export function useDurably() { - const [status, setStatus] = useState('init') - const [currentStep, setCurrentStep] = useState(null) - const [result, setResult] = useState(null) - const userTriggered = useRef(false) - const refreshDashboardRef = useRef<(() => void) | null>(null) - - useEffect(() => { - let cancelled = false - - const unsubscribes = [ - durably.on('run:start', () => { - if (!cancelled) { - setStatus(userTriggered.current ? 'running' : 'resuming') - } - }), - durably.on('step:complete', (e) => { - if (!cancelled) { - setCurrentStep(e.stepName) - } - }), - durably.on('run:complete', (e) => { - if (!cancelled) { - setResult(JSON.stringify(e.output, null, 2)) - setCurrentStep(null) - setStatus('done') - userTriggered.current = false - refreshDashboardRef.current?.() - } - }), - durably.on('run:fail', (e) => { - if (!cancelled) { - setResult(e.error) - setCurrentStep(null) - setStatus('error') - userTriggered.current = false - refreshDashboardRef.current?.() - } - }), - ] - - durably - .migrate() - .then(() => { - if (!cancelled) { - durably.start() - setStatus('ready') - } - }) - .catch((err) => { - if (!cancelled) { - console.error('Durably migration failed:', err) - setStatus('error') - } - }) - - return () => { - cancelled = true - for (const fn of unsubscribes) fn() - durably.stop() - } - }, []) - - const markUserTriggered = () => { - userTriggered.current = true - setStatus('running') - setCurrentStep(null) - setResult(null) - } - - const setRefreshDashboard = (fn: () => void) => { - refreshDashboardRef.current = fn - } - - const refreshDashboard = () => { - refreshDashboardRef.current?.() - } - - return { - status, - currentStep, - result, - markUserTriggered, - setRefreshDashboard, - refreshDashboard, - durably, - } -} diff --git a/examples/react/src/jobs/processImage.ts b/examples/react/src/jobs/processImage.ts deleted file mode 100644 index 6333b217..00000000 --- a/examples/react/src/jobs/processImage.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Process Image Job - * - * Example job that simulates image processing with multiple steps. - */ - -import { defineJob } from '@coji/durably' -import { z } from 'zod' -import { durably } from '../lib/durably' - -const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -export const processImage = durably.register( - defineJob({ - name: 'process-image', - input: z.object({ filename: z.string(), width: z.number() }), - output: z.object({ url: z.string(), size: z.number() }), - run: async (step, payload) => { - // Download original image - const fileSize = await step.run('download', async () => { - await delay(300) - return Math.floor(Math.random() * 1000000) + 500000 // 500KB-1.5MB - }) - - // Resize to target width - const resizedSize = await step.run('resize', async () => { - await delay(400) - return Math.floor(fileSize * (payload.width / 1920)) - }) - - // Upload to CDN - const url = await step.run('upload', async () => { - await delay(300) - return `https://cdn.example.com/${payload.width}/${payload.filename}` - }) - - return { url, size: resizedSize } - }, - }), -) diff --git a/examples/react/src/lib/durably.ts b/examples/react/src/lib/durably.ts deleted file mode 100644 index 2fd41024..00000000 --- a/examples/react/src/lib/durably.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Durably singleton instance - * - * This module exports a singleton durably instance for use throughout the app. - * - * NOTE: This simple singleton pattern does NOT handle HMR (Hot Module Replacement). - * During development, if this file is modified, a full page reload is required. - * - * In the future, @coji/durably-react will provide DurablyProvider that handles - * HMR and StrictMode correctly using dialectFactory pattern. - * See: docs/spec-react.md - */ - -import { createDurably } from '@coji/durably' -import { SQLocalKysely } from 'sqlocal/kysely' - -const sqlocal = new SQLocalKysely('example.sqlite3') -export const { dialect, deleteDatabaseFile } = sqlocal - -export const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) diff --git a/examples/react/src/styles.ts b/examples/react/src/styles.ts deleted file mode 100644 index e9cb7061..00000000 --- a/examples/react/src/styles.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Shared styles for Durably React Example - */ - -export const styles = { - container: { - padding: '2rem', - fontFamily: 'system-ui', - maxWidth: '800px', - margin: '0 auto', - }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '1.5rem', - }, - title: { - margin: 0, - fontSize: '1.5rem', - }, - links: { - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - fontSize: '0.875rem', - }, - linkSeparator: { - color: '#999', - }, - tabs: { - display: 'flex', - gap: 0, - marginBottom: '1.5rem', - borderBottom: '2px solid #e0e0e0', - }, - tab: (active: boolean) => ({ - padding: '0.75rem 1.5rem', - background: 'none', - border: 'none', - fontSize: '1rem', - cursor: 'pointer', - borderBottom: active ? '2px solid #007bff' : '2px solid transparent', - marginBottom: '-2px', - color: active ? '#007bff' : '#666', - fontWeight: active ? 500 : 400, - }), - buttons: { display: 'flex', gap: '1rem', marginBottom: '2rem' }, - result: (isError: boolean) => ({ - background: isError ? '#fee' : '#f5f5f5', - padding: '1rem', - borderRadius: '4px', - fontFamily: 'monospace', - whiteSpace: 'pre-wrap' as const, - }), -} diff --git a/examples/react/vercel.json b/examples/react/vercel.json deleted file mode 100644 index ae3c460f..00000000 --- a/examples/react/vercel.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "headers": [ - { - "source": "/(.*)", - "headers": [ - { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }, - { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" } - ] - } - ] -} diff --git a/examples/server-node/.gitignore b/examples/server-node/.gitignore new file mode 100644 index 00000000..4ce1e340 --- /dev/null +++ b/examples/server-node/.gitignore @@ -0,0 +1,5 @@ +# Dependencies +node_modules/ + +# SQLite database files +*.db diff --git a/examples/server-node/basic.ts b/examples/server-node/basic.ts new file mode 100644 index 00000000..6f8224ba --- /dev/null +++ b/examples/server-node/basic.ts @@ -0,0 +1,60 @@ +/** + * Node.js Example for Durably + * + * This example shows basic usage of Durably with Turso/libSQL. + * Same job definition as browser/react examples for comparison. + */ + +import { durably } from './lib/durably' + +// Subscribe to events +durably.on('run:start', (event) => { + console.log(`[run:start] ${event.jobName}`) +}) + +durably.on('step:complete', (event) => { + console.log(`[step:complete] ${event.stepName}`) +}) + +durably.on('run:complete', (event) => { + console.log( + `[run:complete] output=${JSON.stringify(event.output)} duration=${event.duration}ms`, + ) +}) + +durably.on('run:fail', (event) => { + console.log(`[run:fail] ${event.error}`) +}) + +// Main +async function main() { + console.log('Durably Node.js Example') + console.log('=======================\n') + + await durably.init() + console.log('Initialized\n') + + // Trigger job and wait for completion + const { id, output } = await durably.jobs.processImage.triggerAndWait({ + filename: 'photo.jpg', + }) + console.log(`\nRun ${id} completed`) + console.log(`Output: ${JSON.stringify(output)}`) + + // Show stats + const runs = await durably.storage.getRuns() + console.log(`\nDatabase Stats:`) + console.log(` Pending: ${runs.filter((r) => r.status === 'pending').length}`) + console.log(` Running: ${runs.filter((r) => r.status === 'running').length}`) + console.log( + ` Completed: ${runs.filter((r) => r.status === 'completed').length}`, + ) + console.log(` Failed: ${runs.filter((r) => r.status === 'failed').length}`) + + // Cleanup + await durably.stop() + await durably.db.destroy() + console.log('\nDone!') +} + +main().catch(console.error) diff --git a/examples/server-node/biome.json b/examples/server-node/biome.json new file mode 100644 index 00000000..6455521c --- /dev/null +++ b/examples/server-node/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "extends": ["../../biome.json"] +} diff --git a/examples/server-node/jobs/index.ts b/examples/server-node/jobs/index.ts new file mode 100644 index 00000000..23b287c3 --- /dev/null +++ b/examples/server-node/jobs/index.ts @@ -0,0 +1,8 @@ +/** + * Job Definitions + * + * Barrel export for all job definitions. + * When adding a new job, import and add it here. + */ + +export { processImageJob } from './process-image' diff --git a/examples/server-node/jobs/process-image.ts b/examples/server-node/jobs/process-image.ts new file mode 100644 index 00000000..aa5810a2 --- /dev/null +++ b/examples/server-node/jobs/process-image.ts @@ -0,0 +1,37 @@ +/** + * Process Image Job + * + * Simulates image processing with multiple steps. + */ + +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string() }), + output: z.object({ url: z.string() }), + run: async (step, payload) => { + // Step 1: Download + const data = await step.run('download', async () => { + await delay(500) + return { size: 1024000 } + }) + + // Step 2: Resize + await step.run('resize', async () => { + await delay(500) + return { width: 800, height: 600, size: data.size / 2 } + }) + + // Step 3: Upload + const uploaded = await step.run('upload', async () => { + await delay(500) + return { url: `https://cdn.example.com/${payload.filename}` } + }) + + return { url: uploaded.url } + }, +}) diff --git a/examples/server-node/lib/database.ts b/examples/server-node/lib/database.ts new file mode 100644 index 00000000..17ade5cd --- /dev/null +++ b/examples/server-node/lib/database.ts @@ -0,0 +1,13 @@ +/** + * Database Configuration + * + * libSQL/Turso dialect for server-side SQLite. + * Uses environment variables for Turso connection, falls back to local file. + */ + +import { LibsqlDialect } from '@libsql/kysely-libsql' + +export const dialect = new LibsqlDialect({ + url: process.env.TURSO_DATABASE_URL ?? 'file:local.db', + authToken: process.env.TURSO_AUTH_TOKEN, +}) diff --git a/examples/server-node/lib/durably.ts b/examples/server-node/lib/durably.ts new file mode 100644 index 00000000..e3368501 --- /dev/null +++ b/examples/server-node/lib/durably.ts @@ -0,0 +1,20 @@ +/** + * Durably Server Configuration + * + * Sets up Durably instance and registers jobs. + */ + +import { createDurably } from '@coji/durably' +import { processImageJob } from '../jobs' +import { dialect } from './database' + +// Create and configure durably instance with chained register() +// register() returns a new Durably instance with type-safe jobs +export const durably = createDurably({ + dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, +}).register({ + processImage: processImageJob, +}) diff --git a/examples/node/package.json b/examples/server-node/package.json similarity index 92% rename from examples/node/package.json rename to examples/server-node/package.json index 8aec017d..2f53477b 100644 --- a/examples/node/package.json +++ b/examples/server-node/package.json @@ -1,5 +1,5 @@ { - "name": "example-node", + "name": "example-server-node", "private": true, "type": "module", "scripts": { @@ -15,7 +15,7 @@ "@libsql/client": "^0.15.15", "@libsql/kysely-libsql": "^0.4.1", "kysely": "^0.28.9", - "zod": "^4.2.1" + "zod": "^4.3.4" }, "devDependencies": { "@biomejs/biome": "^2.3.10", diff --git a/examples/server-node/prettier.config.js b/examples/server-node/prettier.config.js new file mode 100644 index 00000000..48dce797 --- /dev/null +++ b/examples/server-node/prettier.config.js @@ -0,0 +1,7 @@ +export default { + semi: false, + singleQuote: true, + trailingComma: 'all', + printWidth: 80, + plugins: ['prettier-plugin-organize-imports'], +} diff --git a/examples/node/tsconfig.json b/examples/server-node/tsconfig.json similarity index 100% rename from examples/node/tsconfig.json rename to examples/server-node/tsconfig.json diff --git a/package.json b/package.json index 06d282d9..03b2937e 100644 --- a/package.json +++ b/package.json @@ -5,25 +5,28 @@ "license": "MIT", "packageManager": "pnpm@10.26.0", "scripts": { - "build": "pnpm -r build", - "test": "pnpm -r test", - "test:node": "pnpm --filter @coji/durably test:node", - "test:browser": "pnpm --filter @coji/durably test:browser", - "test:react": "pnpm --filter @coji/durably test:react", - "dev:node": "pnpm --filter example-node dev", - "dev:browser": "pnpm --filter example-browser dev", - "dev:react": "pnpm --filter example-react dev", - "typecheck": "pnpm -r typecheck", - "lint": "pnpm -r lint", + "build": "turbo run build", + "test": "turbo run test", + "test:node": "turbo run test:node --filter=@coji/durably", + "test:browser": "turbo run test:browser --filter=@coji/durably", + "test:react": "turbo run test:react --filter=@coji/durably", + "dev:node": "turbo run dev --filter=example-node", + "dev:browser": "turbo run dev --filter=example-browser", + "dev:react": "turbo run dev --filter=example-react", + "typecheck": "turbo run typecheck", + "lint": "turbo run lint", "lint:fix": "pnpm -r lint:fix", - "format": "pnpm -r format", + "format": "turbo run format", "format:fix": "pnpm -r format:fix", - "validate": "pnpm format && pnpm lint && pnpm typecheck && pnpm test" + "validate": "turbo run format lint typecheck test" }, "devDependencies": { + "@biomejs/biome": "^2.3.10", "@types/node": "^25.0.3", - "cc-hooks-ts": "2.0.70", + "@vitest/coverage-v8": "4.0.16", + "cc-hooks-ts": "2.0.76", "tsx": "^4.21.0", + "turbo": "2.7.2", "typescript": "^5.9.3" }, "pnpm": { diff --git a/packages/durably-react/README.md b/packages/durably-react/README.md new file mode 100644 index 00000000..d18e5cca --- /dev/null +++ b/packages/durably-react/README.md @@ -0,0 +1,102 @@ +# @coji/durably-react + +React bindings for [Durably](https://github.com/coji/durably) - step-oriented resumable batch execution. + +**[Documentation](https://coji.github.io/durably/)** | **[GitHub](https://github.com/coji/durably)** + +> **Note:** This package is ESM-only. CommonJS is not supported. + +## Installation + +```bash +# Browser mode (with SQLocal) +npm install @coji/durably-react @coji/durably kysely zod sqlocal + +# Server-connected mode (client only) +npm install @coji/durably-react +``` + +## Quick Start + +```tsx +import { Suspense } from 'react' +import { createDurably, defineJob } from '@coji/durably' +import { DurablyProvider, useJob } from '@coji/durably-react' +import { SQLocalKysely } from 'sqlocal/kysely' +import { z } from 'zod' + +const myJob = defineJob({ + name: 'my-job', + input: z.object({ id: z.string() }), + run: async (step, payload) => { + await step.run('step-1', async () => { + /* ... */ + }) + }, +}) + +// Initialize Durably +async function initDurably() { + const sqlocal = new SQLocalKysely('app.sqlite3') + const durably = createDurably({ dialect: sqlocal.dialect }).register({ + myJob, + }) + await durably.init() // migrate + start + return durably +} +const durablyPromise = initDurably() + +function App() { + return ( + Loading...}> + + + + + ) +} + +function MyComponent() { + const { trigger, isRunning, isCompleted } = useJob(myJob) + return ( + + ) +} +``` + +## Server-Connected Mode + +For full-stack apps, use hooks from `@coji/durably-react/client`: + +```tsx +import { useJob } from '@coji/durably-react/client' + +function MyComponent() { + const { trigger, status, output, isRunning } = useJob< + { id: string }, + { result: number } + >({ + api: '/api/durably', + jobName: 'my-job', + }) + + return ( + + ) +} +``` + +## Documentation + +For full documentation, visit [coji.github.io/durably](https://coji.github.io/durably/). + +- [React Guide](https://coji.github.io/durably/guide/react) - Browser mode with hooks +- [Full-Stack Guide](https://coji.github.io/durably/guide/full-stack) - Server-connected mode + +## License + +MIT diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md new file mode 100644 index 00000000..f2ceffc6 --- /dev/null +++ b/packages/durably-react/docs/llms.md @@ -0,0 +1,630 @@ +# Durably React - LLM Documentation + +> React bindings for Durably - step-oriented resumable batch execution. + +## Requirements + +- **React 19+** (uses `React.use()` for Promise resolution) + +## Overview + +`@coji/durably-react` provides React hooks for triggering and monitoring Durably jobs. It supports two modes: + +1. **Browser Hooks**: Run Durably entirely in the browser with SQLite WASM (OPFS) +2. **Server Hooks**: Connect to a remote Durably server via HTTP/SSE + +## Installation + +```bash +# Browser mode - runs Durably in the browser +pnpm add @coji/durably-react @coji/durably kysely zod sqlocal + +# Server mode - connects to Durably server +pnpm add @coji/durably-react +``` + +## Browser Hooks + +Import from `@coji/durably-react` for browser-complete mode. + +### DurablyProvider + +Wraps your app and provides the Durably instance to all hooks: + +```tsx +import { DurablyProvider } from '@coji/durably-react' +import { createDurably } from '@coji/durably' +import { SQLocalKysely } from 'sqlocal/kysely' + +// Create and initialize Durably +async function initDurably() { + const sqlocal = new SQLocalKysely('app.sqlite3') + const durably = createDurably({ dialect: sqlocal.dialect }) + await durably.init() + return durably +} + +const durablyPromise = initDurably() + +// With fallback prop (recommended) +function App() { + return ( + Loading...}> + + + ) +} + +// Or with external Suspense +function AppAlt() { + return ( + Loading...}> + + + + + ) +} +``` + +**Props:** + +- `durably: Durably | Promise` - Durably instance or Promise (should be initialized via `await durably.init()`) +- `fallback?: ReactNode` - Fallback to show while Promise resolves (wraps in Suspense automatically) + +### useDurably + +Access the Durably instance directly: + +```tsx +import { useDurably } from '@coji/durably-react' + +function Component() { + const { durably } = useDurably() + + // Use durably instance directly + const handleGetRuns = async () => { + const runs = await durably.getRuns() + } +} +``` + +**Return type:** + +```ts +interface UseDurablyResult { + durably: Durably +} +``` + +### useJob + +Trigger and monitor a job: + +```tsx +import { defineJob } from '@coji/durably' +import { useJob } from '@coji/durably-react' +import { z } from 'zod' + +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { + const data = await step.run('process', () => process(payload.value)) + return { result: data.length } + }, +}) + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + isCancelled, + currentRunId, + reset, + } = useJob(myJob, { + initialRunId: undefined, + autoResume: true, // Auto-resume pending/running jobs (default: true) + followLatest: true, // Switch to tracking new runs (default: true) + }) + + // Trigger job + const handleClick = async () => { + const { runId } = await trigger({ value: 'test' }) + console.log('Started:', runId) + } + + // Or trigger and wait for result + const handleSync = async () => { + const { runId, output } = await triggerAndWait({ value: 'test' }) + console.log('Result:', output.result) + } + + return ( +
+ +

Status: {status}

+ {progress && ( +

+ Progress: {progress.current}/{progress.total} +

+ )} + {isCompleted &&

Result: {output?.result}

} + {isFailed &&

Error: {error}

} + +
+ ) +} +``` + +**Options:** + +```ts +interface UseJobOptions { + initialRunId?: string // Initial Run ID to subscribe to + autoResume?: boolean // Auto-resume pending/running jobs (default: true) + followLatest?: boolean // Switch to tracking new runs (default: true) +} +``` + +**Return type:** + +```ts +interface UseJobResult { + trigger: (input: TInput) => Promise<{ runId: string }> + triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }> + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null + output: TOutput | null + error: string | null + logs: LogEntry[] + progress: Progress | null + isRunning: boolean + isPending: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean + currentRunId: string | null + reset: () => void +} +``` + +### useJobRun + +Subscribe to an existing run by ID: + +```tsx +import { useJobRun } from '@coji/durably-react' + +function RunMonitor({ runId }: { runId: string | null }) { + const { + status, + output, + error, + progress, + logs, + isRunning, + isCompleted, + isFailed, + } = useJobRun<{ result: number }>({ runId }) + + if (!runId) return
No run selected
+ + return ( +
+

Status: {status}

+ {isCompleted &&

Output: {JSON.stringify(output)}

} +
+ ) +} +``` + +### useJobLogs + +Subscribe to logs from a run: + +```tsx +import { useJobLogs } from '@coji/durably-react' + +function LogViewer({ runId }: { runId: string | null }) { + const { logs, clearLogs } = useJobLogs({ + runId, + maxLogs: 100, // Optional: limit stored logs + }) + + return ( +
+ +
    + {logs.map((log) => ( +
  • + [{log.level}] {log.message} + {log.data &&
    {JSON.stringify(log.data)}
    } +
  • + ))} +
+
+ ) +} +``` + +### useRuns + +List runs with filtering and real-time updates: + +```tsx +import { useRuns } from '@coji/durably-react' + +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 + }) + + return ( +
+ + {runs.map((run) => ( +
+ {run.jobName}: {run.status} +
+ ))} +
+ ) +} +``` + +## Server Hooks + +Import from `@coji/durably-react/client` for server-connected mode. + +### createDurablyClient + +Create a type-safe client for all registered jobs (recommended): + +```tsx +// Server: register jobs (app/lib/durably.server.ts) +import { createDurably, createDurablyHandler } from '@coji/durably' + +export const durably = createDurably({ dialect }).register({ + importCsv: importCsvJob, + syncUsers: syncUsersJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.init() + +// Client: create typed client (app/lib/durably.client.ts) +import type { durably } from '~/lib/durably.server' +import { createDurablyClient } from '@coji/durably-react/client' + +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) + +// In your component - fully type-safe with autocomplete +function CsvImporter() { + const { trigger, output, isRunning } = durablyClient.importCsv.useJob() + + return ( + + ) +} + +// Subscribe to an existing run +function RunViewer({ runId }: { runId: string }) { + const { status, output, progress } = durablyClient.importCsv.useRun(runId) + return
Status: {status}
+} + +// Subscribe to logs +function LogViewer({ runId }: { runId: string }) { + const { logs } = durablyClient.importCsv.useLogs(runId) + return
{logs.map(l => l.message).join('\n')}
+} +``` + +### Client useJob + +Direct hook when not using `createDurablyClient`: + +```tsx +import { useJob } from '@coji/durably-react/client' + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isCompleted, + currentRunId, + reset, + } = useJob< + { userId: string }, // Input type + { count: number } // Output type + >({ + api: '/api/durably', + jobName: 'sync-data', + initialRunId: undefined, // Optional: resume existing run + }) + + const handleClick = async () => { + const { runId } = await trigger({ userId: 'user_123' }) + console.log('Started:', runId) + } + + return +} +``` + +### Client useJobRun + +```tsx +import { useJobRun } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { status, output, error, progress, logs } = useJobRun<{ + count: number + }>({ + api: '/api/durably', + runId, + }) + + return
Status: {status}
+} +``` + +### Client useJobLogs + +```tsx +import { useJobLogs } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { logs, clearLogs } = useJobLogs({ + api: '/api/durably', + runId, + maxLogs: 50, + }) + + return ( +
    + {logs.map((log) => ( +
  • {log.message}
  • + ))} +
+ ) +} +``` + +### Client useRuns + +List runs with pagination and real-time updates: + +```tsx +import { useRuns } from '@coji/durably-react/client' + +function Dashboard() { + const { + runs, + page, + hasMore, + isLoading, + nextPage, + prevPage, + goToPage, + refresh, + } = useRuns({ + api: '/api/durably', + jobName: 'sync-data', // Optional: filter by job + status: 'running', // Optional: filter by status + pageSize: 20, // Optional: items per page + }) + + return ( +
+ {runs.map((run) => ( +
+ {run.jobName}: {run.status} +
+ ))} + + +
+ ) +} +``` + +### Client useRunActions + +Actions for runs (retry, cancel, delete): + +```tsx +import { useRunActions } from '@coji/durably-react/client' + +function RunActions({ runId, status }: { runId: string; status: string }) { + const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + useRunActions({ + api: '/api/durably', + }) + + return ( +
+ {(status === 'failed' || status === 'cancelled') && ( + + )} + {(status === 'pending' || status === 'running') && ( + + )} + + {error && {error}} +
+ ) +} +``` + +## Server Handler Setup + +On your server, use `createDurablyHandler`: + +```ts +// app/lib/durably.server.ts +import { createDurably } from '@coji/durably' +import { createDurablyHandler } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' + +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) + +export const durably = createDurably({ dialect }).register({ + syncData: syncDataJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.init() + +// app/routes/api.durably.$.ts (React Router / Remix) +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +## Type Definitions + +```ts +type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface Progress { + current: number + total?: number + message?: string +} + +interface LogEntry { + id: string + runId: string + stepName: string | null + level: 'info' | 'warn' | 'error' + message: string + data: unknown + timestamp: string +} +``` + +## Common Patterns + +### Loading States + +```tsx +function Component() { + const { isRunning, trigger } = useJob(myJob) + + return ( + + ) +} +``` + +### Error Handling + +```tsx +function Component() { + const { trigger, error, isFailed, reset } = useJob(myJob) + + const handleClick = async () => { + try { + await trigger({ value: 'test' }) + } catch (e) { + console.error('Trigger failed:', e) + } + } + + if (isFailed) { + return ( +
+

Error: {error}

+ +
+ ) + } + + return +} +``` + +### Progress Tracking + +```tsx +function Component() { + const { trigger, progress, isRunning } = useJob(progressJob) + + return ( +
+ + {isRunning && progress && ( +
+ +

{progress.message}

+
+ )} +
+ ) +} +``` + +### Reconnecting to Existing Run + +```tsx +function Component({ existingRunId }: { existingRunId?: string }) { + const { status, output } = useJob(myJob, { + initialRunId: existingRunId, + }) + + // Will automatically subscribe to the existing run + return
Status: {status}
+} +``` + +## License + +MIT diff --git a/packages/durably-react/package.json b/packages/durably-react/package.json new file mode 100644 index 00000000..02e26b3f --- /dev/null +++ b/packages/durably-react/package.json @@ -0,0 +1,82 @@ +{ + "name": "@coji/durably-react", + "version": "0.6.0", + "description": "React bindings for Durably - step-oriented resumable batch execution", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup", + "test": "pnpm test:react", + "test:react": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "prettier --experimental-cli --check .", + "format:fix": "prettier --experimental-cli --write ." + }, + "keywords": [ + "react", + "hooks", + "durably", + "batch", + "job", + "queue", + "workflow", + "durable" + ], + "author": "coji", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/coji/durably.git" + }, + "bugs": { + "url": "https://github.com/coji/durably/issues" + }, + "homepage": "https://github.com/coji/durably#readme", + "peerDependencies": { + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + }, + "peerDependenciesMeta": { + "@coji/durably": { + "optional": true + } + }, + "devDependencies": { + "@biomejs/biome": "^2.3.10", + "@coji/durably": "workspace:*", + "@testing-library/react": "^16.3.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/browser": "^4.0.16", + "@vitest/browser-playwright": "4.0.16", + "kysely": "^0.28.9", + "playwright": "^1.57.0", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sqlocal": "^0.16.0", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.16", + "zod": "^4.3.4" + } +} diff --git a/packages/durably-react/src/client.ts b/packages/durably-react/src/client.ts new file mode 100644 index 00000000..01fbe68c --- /dev/null +++ b/packages/durably-react/src/client.ts @@ -0,0 +1,47 @@ +// @coji/durably-react/client - Server-connected mode +// This entry point is for connecting to a remote Durably server via HTTP/SSE + +// Type-safe client factories (recommended) +export { createDurablyClient } from './client/create-durably-client' +export type { + CreateDurablyClientOptions, + DurablyClient, + JobClient, +} from './client/create-durably-client' + +export { createJobHooks } from './client/create-job-hooks' +export type { CreateJobHooksOptions, JobHooks } from './client/create-job-hooks' + +// Low-level hooks (for advanced use cases) +export { useJob } from './client/use-job' +export type { UseJobClientOptions, UseJobClientResult } from './client/use-job' + +export { useJobRun } from './client/use-job-run' +export type { + UseJobRunClientOptions, + UseJobRunClientResult, +} from './client/use-job-run' + +export { useJobLogs } from './client/use-job-logs' +export type { + UseJobLogsClientOptions, + UseJobLogsClientResult, +} from './client/use-job-logs' + +export { useRuns } from './client/use-runs' +export type { + ClientRun, + UseRunsClientOptions, + UseRunsClientResult, +} from './client/use-runs' + +export { useRunActions } from './client/use-run-actions' +export type { + RunRecord, + StepRecord, + UseRunActionsClientOptions, + UseRunActionsClientResult, +} from './client/use-run-actions' + +// Re-export shared types +export type { LogEntry, Progress, RunStatus } from './types' diff --git a/packages/durably-react/src/client/create-durably-client.ts b/packages/durably-react/src/client/create-durably-client.ts new file mode 100644 index 00000000..df2b6665 --- /dev/null +++ b/packages/durably-react/src/client/create-durably-client.ts @@ -0,0 +1,132 @@ +import type { JobDefinition } from '@coji/durably' +import { useJob, type UseJobClientResult } from './use-job' +import { useJobLogs, type UseJobLogsClientResult } from './use-job-logs' +import { useJobRun, type UseJobRunClientResult } from './use-job-run' + +/** + * Extract input type from a JobDefinition or JobHandle + */ +type InferInput = + T extends JobDefinition + ? TInput extends Record + ? TInput + : Record + : T extends { trigger: (input: infer TInput) => unknown } + ? TInput extends Record + ? TInput + : Record + : Record + +/** + * Extract output type from a JobDefinition or JobHandle + */ +type InferOutput = + T extends JobDefinition + ? TOutput extends Record + ? TOutput + : Record + : T extends { + trigger: (input: unknown) => Promise<{ output?: infer TOutput }> + } + ? TOutput extends Record + ? TOutput + : Record + : Record + +/** + * Type-safe hooks for a specific job + */ +export interface JobClient { + /** + * Hook for triggering and monitoring the job + */ + useJob: () => UseJobClientResult + + /** + * Hook for subscribing to an existing run by ID + */ + useRun: (runId: string | null) => UseJobRunClientResult + + /** + * Hook for subscribing to logs from a run + */ + useLogs: ( + runId: string | null, + options?: { maxLogs?: number }, + ) => UseJobLogsClientResult +} + +/** + * Options for createDurablyClient + */ +export interface CreateDurablyClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string +} + +/** + * A type-safe client with hooks for each registered job + */ +export type DurablyClient> = { + [K in keyof TJobs]: JobClient, InferOutput> +} + +/** + * Create a type-safe Durably client with hooks for all registered jobs. + * + * @example + * ```tsx + * // Server: register jobs + * // app/lib/durably.server.ts + * export const jobs = durably.register({ + * importCsv: importCsvJob, + * syncUsers: syncUsersJob, + * }) + * + * // Client: create typed client + * // app/lib/durably.client.ts + * import type { jobs } from '~/lib/durably.server' + * import { createDurablyClient } from '@coji/durably-react/client' + * + * export const durably = createDurablyClient({ + * api: '/api/durably', + * }) + * + * // In your component - fully type-safe with autocomplete + * function CsvImporter() { + * const { trigger, output, isRunning } = durably.importCsv.useJob() + * + * return ( + * + * ) + * } + * ``` + */ +export function createDurablyClient>( + options: CreateDurablyClientOptions, +): DurablyClient { + const { api } = options + + // Create a proxy that generates job clients on demand + return new Proxy({} as DurablyClient, { + get(_target, jobKey: string) { + return { + useJob: () => { + return useJob({ api, jobName: jobKey }) + }, + + useRun: (runId: string | null) => { + return useJobRun({ api, runId }) + }, + + useLogs: (runId: string | null, logsOptions?: { maxLogs?: number }) => { + return useJobLogs({ api, runId, maxLogs: logsOptions?.maxLogs }) + }, + } + }, + }) +} diff --git a/packages/durably-react/src/client/create-job-hooks.ts b/packages/durably-react/src/client/create-job-hooks.ts new file mode 100644 index 00000000..772e2050 --- /dev/null +++ b/packages/durably-react/src/client/create-job-hooks.ts @@ -0,0 +1,110 @@ +import type { JobDefinition } from '@coji/durably' +import { useJob, type UseJobClientResult } from './use-job' +import { useJobLogs, type UseJobLogsClientResult } from './use-job-logs' +import { useJobRun, type UseJobRunClientResult } from './use-job-run' + +/** + * Extract input type from a JobDefinition + */ +type InferInput = + T extends JobDefinition + ? TInput extends Record + ? TInput + : Record + : Record + +/** + * Extract output type from a JobDefinition + */ +type InferOutput = + T extends JobDefinition + ? TOutput extends Record + ? TOutput + : Record + : Record + +/** + * Options for createJobHooks + */ +export interface CreateJobHooksOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string + /** + * Job name (must match the server-side job name) + */ + jobName: string +} + +/** + * Type-safe hooks for a specific job + */ +export interface JobHooks { + /** + * Hook for triggering and monitoring the job + */ + useJob: () => UseJobClientResult + + /** + * Hook for subscribing to an existing run by ID + */ + useRun: (runId: string | null) => UseJobRunClientResult + + /** + * Hook for subscribing to logs from a run + */ + useLogs: ( + runId: string | null, + options?: { maxLogs?: number }, + ) => UseJobLogsClientResult +} + +/** + * Create type-safe hooks for a specific job. + * + * @example + * ```tsx + * // Import job type from server (type-only import is safe) + * import type { importCsvJob } from '~/lib/durably.server' + * import { createJobHooks } from '@coji/durably-react/client' + * + * const importCsv = createJobHooks({ + * api: '/api/durably', + * jobName: 'import-csv', + * }) + * + * // In your component - fully type-safe + * function CsvImporter() { + * const { trigger, output, progress, isRunning } = importCsv.useJob() + * + * return ( + * + * ) + * } + * ``` + */ +export function createJobHooks< + // biome-ignore lint/suspicious/noExplicitAny: TJob needs to accept any JobDefinition + TJob extends JobDefinition, +>( + options: CreateJobHooksOptions, +): JobHooks, InferOutput> { + const { api, jobName } = options + + return { + useJob: () => { + return useJob, InferOutput>({ api, jobName }) + }, + + useRun: (runId: string | null) => { + return useJobRun>({ api, runId }) + }, + + useLogs: (runId: string | null, logsOptions?: { maxLogs?: number }) => { + return useJobLogs({ api, runId, maxLogs: logsOptions?.maxLogs }) + }, + } +} diff --git a/packages/durably-react/src/client/index.ts b/packages/durably-react/src/client/index.ts new file mode 100644 index 00000000..ae88c2e6 --- /dev/null +++ b/packages/durably-react/src/client/index.ts @@ -0,0 +1,47 @@ +/** + * Server-connected (client mode) exports + * Use these when connecting to a remote Durably server via HTTP/SSE + */ + +export { createDurablyClient } from './create-durably-client' +export type { + CreateDurablyClientOptions, + DurablyClient, + JobClient, +} from './create-durably-client' + +export { createJobHooks } from './create-job-hooks' +export type { CreateJobHooksOptions, JobHooks } from './create-job-hooks' + +export { useJob } from './use-job' +export type { UseJobClientOptions, UseJobClientResult } from './use-job' + +export { useJobRun } from './use-job-run' +export type { + UseJobRunClientOptions, + UseJobRunClientResult, +} from './use-job-run' + +export { useJobLogs } from './use-job-logs' +export type { + UseJobLogsClientOptions, + UseJobLogsClientResult, +} from './use-job-logs' + +export { useRuns } from './use-runs' +export type { + ClientRun, + UseRunsClientOptions, + UseRunsClientResult, +} from './use-runs' + +export { useRunActions } from './use-run-actions' +export type { + RunRecord, + StepRecord, + UseRunActionsClientOptions, + UseRunActionsClientResult, +} from './use-run-actions' + +// Re-export types for convenience +export type { LogEntry, Progress, RunStatus } from '../types' diff --git a/packages/durably-react/src/client/use-job-logs.ts b/packages/durably-react/src/client/use-job-logs.ts new file mode 100644 index 00000000..8e237b0a --- /dev/null +++ b/packages/durably-react/src/client/use-job-logs.ts @@ -0,0 +1,48 @@ +import type { LogEntry } from '../types' +import { useSSESubscription } from './use-sse-subscription' + +export interface UseJobLogsClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string + /** + * The run ID to subscribe to logs for + */ + runId: string | null + /** + * Maximum number of logs to keep (default: unlimited) + */ + maxLogs?: number +} + +export interface UseJobLogsClientResult { + /** + * Whether the hook is ready (always true for client mode) + */ + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Clear all logs + */ + clearLogs: () => void +} + +/** + * Hook for subscribing to logs from a run via server API. + * Uses EventSource for SSE subscription. + */ +export function useJobLogs( + options: UseJobLogsClientOptions, +): UseJobLogsClientResult { + const { api, runId, maxLogs } = options + + const subscription = useSSESubscription(api, runId, { maxLogs }) + + return { + logs: subscription.logs, + clearLogs: subscription.clearLogs, + } +} diff --git a/packages/durably-react/src/client/use-job-run.ts b/packages/durably-react/src/client/use-job-run.ts new file mode 100644 index 00000000..6dfba8ef --- /dev/null +++ b/packages/durably-react/src/client/use-job-run.ts @@ -0,0 +1,137 @@ +import { useEffect, useRef } from 'react' +import type { LogEntry, Progress, RunStatus } from '../types' +import { useSSESubscription } from './use-sse-subscription' + +export interface UseJobRunClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string + /** + * The run ID to subscribe to + */ + runId: string | null + /** + * Callback when run starts (transitions to pending/running) + */ + onStart?: () => void + /** + * Callback when run completes successfully + */ + onComplete?: () => void + /** + * Callback when run fails + */ + onFail?: () => void +} + +export interface UseJobRunClientResult { + /** + * Whether the hook is ready (always true for client mode) + */ + /** + * Current run status + */ + status: RunStatus | null + /** + * Output from completed run + */ + output: TOutput | null + /** + * Error message from failed run + */ + error: string | null + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Current progress + */ + progress: Progress | null + /** + * Whether a run is currently running + */ + isRunning: boolean + /** + * Whether a run is pending + */ + isPending: boolean + /** + * Whether the run completed successfully + */ + isCompleted: boolean + /** + * Whether the run failed + */ + isFailed: boolean + /** + * Whether the run was cancelled + */ + isCancelled: boolean +} + +/** + * Hook for subscribing to an existing run via server API. + * Uses EventSource for SSE subscription. + */ +export function useJobRun( + options: UseJobRunClientOptions, +): UseJobRunClientResult { + const { api, runId, onStart, onComplete, onFail } = options + + const subscription = useSSESubscription(api, runId) + + // If we have a runId but no status yet, treat as pending + const effectiveStatus = subscription.status ?? (runId ? 'pending' : null) + + const isCompleted = effectiveStatus === 'completed' + const isFailed = effectiveStatus === 'failed' + const isPending = effectiveStatus === 'pending' + const isRunning = effectiveStatus === 'running' + const isCancelled = effectiveStatus === 'cancelled' + + // Track previous status to detect transitions (use effectiveStatus, not subscription.status) + const prevStatusRef = useRef(null) + + useEffect(() => { + const prevStatus = prevStatusRef.current + prevStatusRef.current = effectiveStatus + + // Only fire callbacks on status transitions + if (prevStatus !== effectiveStatus) { + // Fire onStart when transitioning from null to pending/running + if (prevStatus === null && (isPending || isRunning) && onStart) { + onStart() + } + if (isCompleted && onComplete) { + onComplete() + } + if (isFailed && onFail) { + onFail() + } + } + }, [ + effectiveStatus, + isPending, + isRunning, + isCompleted, + isFailed, + onStart, + onComplete, + onFail, + ]) + + return { + status: effectiveStatus, + output: subscription.output, + error: subscription.error, + logs: subscription.logs, + progress: subscription.progress, + isRunning, + isPending, + isCompleted, + isFailed, + isCancelled, + } +} diff --git a/packages/durably-react/src/client/use-job.ts b/packages/durably-react/src/client/use-job.ts new file mode 100644 index 00000000..f93f3547 --- /dev/null +++ b/packages/durably-react/src/client/use-job.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState } from 'react' +import type { LogEntry, Progress, RunStatus } from '../types' +import { useSSESubscription } from './use-sse-subscription' + +export interface UseJobClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string + /** + * Job name to trigger + */ + jobName: string + /** + * Initial Run ID to subscribe to (for reconnection scenarios) + * When provided, the hook will immediately start subscribing to this run + */ + initialRunId?: string +} + +export interface UseJobClientResult { + /** + * Whether the hook is ready (always true for client mode) + */ + /** + * Trigger the job with the given input + */ + trigger: (input: TInput) => Promise<{ runId: string }> + /** + * Trigger and wait for completion + */ + triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }> + /** + * Current run status + */ + status: RunStatus | null + /** + * Output from completed run + */ + output: TOutput | null + /** + * Error message from failed run + */ + error: string | null + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Current progress + */ + progress: Progress | null + /** + * Whether a run is currently running + */ + isRunning: boolean + /** + * Whether a run is pending + */ + isPending: boolean + /** + * Whether the run completed successfully + */ + isCompleted: boolean + /** + * Whether the run failed + */ + isFailed: boolean + /** + * Current run ID + */ + currentRunId: string | null + /** + * Reset all state + */ + reset: () => void +} + +/** + * Hook for triggering and subscribing to jobs via server API. + * Uses fetch for triggering and EventSource for SSE subscription. + */ +export function useJob< + TInput extends Record = Record, + TOutput extends Record = Record, +>(options: UseJobClientOptions): UseJobClientResult { + const { api, jobName, initialRunId } = options + + const [currentRunId, setCurrentRunId] = useState( + initialRunId ?? null, + ) + const [isPending, setIsPending] = useState(false) + + const subscription = useSSESubscription(api, currentRunId) + + const trigger = useCallback( + async (input: TInput): Promise<{ runId: string }> => { + // Reset state + subscription.reset() + setIsPending(true) + + const response = await fetch(`${api}/trigger`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ jobName, input }), + }) + + if (!response.ok) { + setIsPending(false) + const errorText = await response.text() + throw new Error(errorText || `HTTP ${response.status}`) + } + + const { runId } = (await response.json()) as { runId: string } + setCurrentRunId(runId) + + return { runId } + }, + [api, jobName, subscription.reset], + ) + + const triggerAndWait = useCallback( + async (input: TInput): Promise<{ runId: string; output: TOutput }> => { + const { runId } = await trigger(input) + + return new Promise((resolve, reject) => { + const checkInterval = setInterval(() => { + if (subscription.status === 'completed' && subscription.output) { + clearInterval(checkInterval) + resolve({ runId, output: subscription.output }) + } else if (subscription.status === 'failed') { + clearInterval(checkInterval) + reject(new Error(subscription.error ?? 'Job failed')) + } else if (subscription.status === 'cancelled') { + clearInterval(checkInterval) + reject(new Error('Job cancelled')) + } + }, 50) + }) + }, + [trigger, subscription.status, subscription.output, subscription.error], + ) + + const reset = useCallback(() => { + subscription.reset() + setCurrentRunId(null) + setIsPending(false) + }, [subscription.reset]) + + // Compute effective status (pending overrides null when we've triggered but SSE hasn't started) + const effectiveStatus = subscription.status ?? (isPending ? 'pending' : null) + + // Clear pending when we get a real status + useEffect(() => { + if (subscription.status && isPending) { + setIsPending(false) + } + }, [subscription.status, isPending]) + + return { + trigger, + triggerAndWait, + status: effectiveStatus, + output: subscription.output, + error: subscription.error, + logs: subscription.logs, + progress: subscription.progress, + isRunning: effectiveStatus === 'running', + isPending: effectiveStatus === 'pending', + isCompleted: effectiveStatus === 'completed', + isFailed: effectiveStatus === 'failed', + currentRunId, + reset, + } +} diff --git a/packages/durably-react/src/client/use-run-actions.ts b/packages/durably-react/src/client/use-run-actions.ts new file mode 100644 index 00000000..0a26b473 --- /dev/null +++ b/packages/durably-react/src/client/use-run-actions.ts @@ -0,0 +1,281 @@ +import { useCallback, useState } from 'react' + +/** + * Run record returned from the server API + */ +export interface RunRecord { + id: string + jobName: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + payload: unknown + output: unknown | null + error: string | null + progress: { current: number; total?: number; message?: string } | null + currentStepIndex: number + stepCount: number + createdAt: string + startedAt: string | null + completedAt: string | null +} + +/** + * Step record returned from the server API + */ +export interface StepRecord { + name: string + status: 'completed' | 'failed' + output: unknown +} + +export interface UseRunActionsClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string +} + +export interface UseRunActionsClientResult { + /** + * Retry a failed or cancelled run + */ + retry: (runId: string) => Promise + /** + * Cancel a pending or running run + */ + cancel: (runId: string) => Promise + /** + * Delete a run (only completed, failed, or cancelled runs) + */ + deleteRun: (runId: string) => Promise + /** + * Get a single run by ID + */ + getRun: (runId: string) => Promise + /** + * Get steps for a run + */ + getSteps: (runId: string) => Promise + /** + * Whether an action is in progress + */ + isLoading: boolean + /** + * Error message from last action + */ + error: string | null +} + +/** + * Hook for run actions (retry, cancel) via server API. + * + * @example + * ```tsx + * function RunActions({ runId, status }: { runId: string; status: string }) { + * const { retry, cancel, isLoading, error } = useRunActions({ + * api: '/api/durably', + * }) + * + * return ( + *
+ * {status === 'failed' && ( + * + * )} + * {(status === 'pending' || status === 'running') && ( + * + * )} + * {error && {error}} + *
+ * ) + * } + * ``` + */ +export function useRunActions( + options: UseRunActionsClientOptions, +): UseRunActionsClientResult { + const { api } = options + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const retry = useCallback( + async (runId: string) => { + setIsLoading(true) + setError(null) + + try { + const url = `${api}/retry?runId=${encodeURIComponent(runId)}` + const response = await fetch(url, { method: 'POST' }) + + if (!response.ok) { + let errorMessage = `Failed to retry: ${response.statusText}` + try { + const data = await response.json() + if (data.error) { + errorMessage = data.error + } + } catch { + // Response is not JSON, use statusText + } + throw new Error(errorMessage) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + throw err + } finally { + setIsLoading(false) + } + }, + [api], + ) + + const cancel = useCallback( + async (runId: string) => { + setIsLoading(true) + setError(null) + + try { + const url = `${api}/cancel?runId=${encodeURIComponent(runId)}` + const response = await fetch(url, { method: 'POST' }) + + if (!response.ok) { + let errorMessage = `Failed to cancel: ${response.statusText}` + try { + const data = await response.json() + if (data.error) { + errorMessage = data.error + } + } catch { + // Response is not JSON, use statusText + } + throw new Error(errorMessage) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + throw err + } finally { + setIsLoading(false) + } + }, + [api], + ) + + const deleteRun = useCallback( + async (runId: string) => { + setIsLoading(true) + setError(null) + + try { + const url = `${api}/run?runId=${encodeURIComponent(runId)}` + const response = await fetch(url, { method: 'DELETE' }) + + if (!response.ok) { + let errorMessage = `Failed to delete: ${response.statusText}` + try { + const data = await response.json() + if (data.error) { + errorMessage = data.error + } + } catch { + // Response is not JSON, use statusText + } + throw new Error(errorMessage) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + throw err + } finally { + setIsLoading(false) + } + }, + [api], + ) + + const getRun = useCallback( + async (runId: string): Promise => { + setIsLoading(true) + setError(null) + + try { + const url = `${api}/run?runId=${encodeURIComponent(runId)}` + const response = await fetch(url) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + let errorMessage = `Failed to get run: ${response.statusText}` + try { + const data = await response.json() + if (data.error) { + errorMessage = data.error + } + } catch { + // Response is not JSON, use statusText + } + throw new Error(errorMessage) + } + + return (await response.json()) as RunRecord + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + throw err + } finally { + setIsLoading(false) + } + }, + [api], + ) + + const getSteps = useCallback( + async (runId: string): Promise => { + setIsLoading(true) + setError(null) + + try { + const url = `${api}/steps?runId=${encodeURIComponent(runId)}` + const response = await fetch(url) + + if (!response.ok) { + let errorMessage = `Failed to get steps: ${response.statusText}` + try { + const data = await response.json() + if (data.error) { + errorMessage = data.error + } + } catch { + // Response is not JSON, use statusText + } + throw new Error(errorMessage) + } + + return (await response.json()) as StepRecord[] + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + throw err + } finally { + setIsLoading(false) + } + }, + [api], + ) + + return { + retry, + cancel, + deleteRun, + getRun, + getSteps, + isLoading, + error, + } +} diff --git a/packages/durably-react/src/client/use-runs.ts b/packages/durably-react/src/client/use-runs.ts new file mode 100644 index 00000000..2ade34bb --- /dev/null +++ b/packages/durably-react/src/client/use-runs.ts @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { Progress, RunStatus } 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 +} + +/** + * SSE notification event from /runs/subscribe + */ +type RunUpdateEvent = + | { + type: + | 'run:trigger' + | 'run:start' + | 'run:complete' + | 'run:fail' + | 'run:cancel' + | 'run:retry' + runId: string + jobName: string + } + | { type: 'run:progress'; runId: string; jobName: string; progress: Progress } + | { + type: 'step:start' | 'step:complete' + runId: string + jobName: string + stepName: string + stepIndex: number + } + | { + type: 'step:fail' + runId: string + jobName: string + stepName: string + stepIndex: number + error: string + } + | { + type: 'log:write' + runId: string + stepName: string | null + level: 'info' | 'warn' | 'error' + message: string + data: unknown + } + +export interface UseRunsClientOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string + /** + * Filter by job name + */ + jobName?: string + /** + * Filter by status + */ + status?: RunStatus + /** + * Number of runs per page + * @default 10 + */ + pageSize?: number +} + +export interface UseRunsClientResult { + /** + * List of runs for the current page + */ + runs: ClientRun[] + /** + * Current page (0-indexed) + */ + page: number + /** + * Whether there are more pages + */ + hasMore: boolean + /** + * Whether data is being loaded + */ + isLoading: boolean + /** + * Error message if fetch failed + */ + error: string | null + /** + * Go to the next page + */ + nextPage: () => void + /** + * Go to the previous page + */ + prevPage: () => void + /** + * Go to a specific page + */ + goToPage: (page: number) => void + /** + * Refresh the current page + */ + refresh: () => Promise +} + +/** + * Hook for listing runs via server API with pagination. + * First page (page 0) automatically subscribes to SSE for real-time updates. + * Other pages are static and require manual refresh. + * + * @example + * ```tsx + * function RunHistory() { + * const { runs, page, hasMore, nextPage, prevPage, refresh } = useRuns({ + * api: '/api/durably', + * jobName: 'import-csv', + * pageSize: 10, + * }) + * + * return ( + *
+ * {runs.map(run => ( + *
{run.jobName}: {run.status}
+ * ))} + * + * + * + *
+ * ) + * } + * ``` + */ +export function useRuns(options: UseRunsClientOptions): UseRunsClientResult { + const { api, jobName, status, pageSize = 10 } = options + + const [runs, setRuns] = useState([]) + const [page, setPage] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const isMountedRef = useRef(true) + const eventSourceRef = useRef(null) + + const refresh = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const params = new URLSearchParams() + if (jobName) params.set('jobName', jobName) + if (status) params.set('status', status) + params.set('limit', String(pageSize + 1)) + params.set('offset', String(page * pageSize)) + + const url = `${api}/runs?${params.toString()}` + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to fetch runs: ${response.statusText}`) + } + + const data = (await response.json()) as ClientRun[] + + if (isMountedRef.current) { + setHasMore(data.length > pageSize) + setRuns(data.slice(0, pageSize)) + } + } catch (err) { + if (isMountedRef.current) { + setError(err instanceof Error ? err.message : 'Unknown error') + } + } finally { + if (isMountedRef.current) { + setIsLoading(false) + } + } + }, [api, jobName, status, pageSize, page]) + + // Initial fetch + useEffect(() => { + isMountedRef.current = true + refresh() + + return () => { + isMountedRef.current = false + } + }, [refresh]) + + // SSE subscription for first page only + useEffect(() => { + // Only subscribe to SSE on first page + if (page !== 0) { + // Clean up any existing connection when navigating away from first page + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + return + } + + // Build SSE URL + const params = new URLSearchParams() + if (jobName) params.set('jobName', jobName) + const sseUrl = `${api}/runs/subscribe${params.toString() ? `?${params.toString()}` : ''}` + + const eventSource = new EventSource(sseUrl) + eventSourceRef.current = eventSource + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as RunUpdateEvent + // On run lifecycle events, refresh the list + if ( + data.type === 'run:trigger' || + data.type === 'run:start' || + data.type === 'run:complete' || + data.type === 'run:fail' || + data.type === 'run:cancel' || + data.type === 'run:retry' + ) { + refresh() + } + // On progress update, update the run in place + if (data.type === 'run:progress') { + setRuns((prev) => + prev.map((run) => + run.id === data.runId ? { ...run, progress: data.progress } : run, + ), + ) + } + // On step complete, update currentStepIndex + if (data.type === 'step:complete') { + setRuns((prev) => + prev.map((run) => + run.id === data.runId + ? { ...run, currentStepIndex: data.stepIndex + 1 } + : run, + ), + ) + } + // On step start or fail, refresh to get latest state + if (data.type === 'step:start' || data.type === 'step:fail') { + refresh() + } + // log:write is handled by useJobLogs, not useRuns + } catch { + // Ignore parse errors + } + } + + eventSource.onerror = () => { + // EventSource will automatically reconnect + } + + return () => { + eventSource.close() + eventSourceRef.current = null + } + }, [api, jobName, page, refresh]) + + const nextPage = useCallback(() => { + if (hasMore) { + setPage((p) => p + 1) + } + }, [hasMore]) + + const prevPage = useCallback(() => { + setPage((p) => Math.max(0, p - 1)) + }, []) + + const goToPage = useCallback((newPage: number) => { + setPage(Math.max(0, newPage)) + }, []) + + return { + runs, + page, + hasMore, + isLoading, + error, + nextPage, + prevPage, + goToPage, + refresh, + } +} diff --git a/packages/durably-react/src/client/use-sse-subscription.ts b/packages/durably-react/src/client/use-sse-subscription.ts new file mode 100644 index 00000000..48d6b74a --- /dev/null +++ b/packages/durably-react/src/client/use-sse-subscription.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { DurablyEvent, LogEntry, Progress, RunStatus } from '../types' + +export interface SSESubscriptionState { + status: RunStatus | null + output: TOutput | null + error: string | null + logs: LogEntry[] + progress: Progress | null +} + +export interface UseSSESubscriptionOptions { + /** + * Maximum number of logs to keep (0 = unlimited) + */ + maxLogs?: number +} + +export interface UseSSESubscriptionResult< + TOutput = unknown, +> extends SSESubscriptionState { + /** + * Clear all logs + */ + clearLogs: () => void + /** + * Reset all state + */ + reset: () => void +} + +/** + * Internal hook for subscribing to run events via SSE. + * Used by client-mode hooks (useJob, useJobRun, useJobLogs). + */ +export function useSSESubscription( + api: string | null, + runId: string | null, + options?: UseSSESubscriptionOptions, +): UseSSESubscriptionResult { + const [status, setStatus] = useState(null) + const [output, setOutput] = useState(null) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const [progress, setProgress] = useState(null) + + const eventSourceRef = useRef(null) + const runIdRef = useRef(runId) + const prevRunIdRef = useRef(null) + + const maxLogs = options?.maxLogs ?? 0 + + // Reset state when runId changes + if (prevRunIdRef.current !== runId) { + prevRunIdRef.current = runId + // Only reset if this isn't the initial render (runIdRef already set) + if (runIdRef.current !== runId) { + setStatus(null) + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + } + } + runIdRef.current = runId + + // Subscribe to SSE events + useEffect(() => { + if (!api || !runId) return + + const url = `${api}/subscribe?runId=${encodeURIComponent(runId)}` + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as DurablyEvent + if (data.runId !== runIdRef.current) return + + switch (data.type) { + case 'run:start': + setStatus('running') + break + case 'run:complete': + setStatus('completed') + setOutput(data.output as TOutput) + break + case 'run:fail': + setStatus('failed') + setError(data.error) + break + case 'run:cancel': + setStatus('cancelled') + break + case 'run:retry': + setStatus('pending') + setError(null) + break + case 'run:progress': + setProgress(data.progress) + break + case 'log:write': + setLogs((prev) => { + const newLog: LogEntry = { + id: crypto.randomUUID(), + runId: data.runId, + stepName: null, + level: data.level, + message: data.message, + data: data.data, + timestamp: new Date().toISOString(), + } + const newLogs = [...prev, newLog] + if (maxLogs > 0 && newLogs.length > maxLogs) { + return newLogs.slice(-maxLogs) + } + return newLogs + }) + break + } + } catch { + // Ignore parse errors + } + } + + eventSource.onerror = () => { + setError('Connection failed') + eventSource.close() + } + + return () => { + eventSource.close() + eventSourceRef.current = null + } + }, [api, runId, maxLogs]) + + const clearLogs = useCallback(() => { + setLogs([]) + }, []) + + const reset = useCallback(() => { + setStatus(null) + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + }, []) + + return { + status, + output, + error, + logs, + progress, + clearLogs, + reset, + } +} diff --git a/packages/durably-react/src/context.tsx b/packages/durably-react/src/context.tsx new file mode 100644 index 00000000..008e4cf5 --- /dev/null +++ b/packages/durably-react/src/context.tsx @@ -0,0 +1,81 @@ +import type { Durably } from '@coji/durably' +import { Suspense, createContext, use, useContext, type ReactNode } from 'react' + +interface DurablyContextValue { + durably: Durably +} + +const DurablyContext = createContext(null) + +export interface DurablyProviderProps { + /** + * Durably instance or Promise that resolves to one. + * The instance should already be initialized via `await durably.init()`. + * + * When passing a Promise, wrap the provider with Suspense or use the fallback prop. + * + * @example + * // With Suspense (recommended) + * }> + * + * + * + * + * + * @example + * // With fallback prop + * }> + * + * + */ + durably: Durably | Promise + /** + * Fallback to show while waiting for the Durably Promise to resolve. + * This wraps the provider content in a Suspense boundary automatically. + */ + fallback?: ReactNode + children: ReactNode +} + +/** + * Internal component that uses the `use()` hook to resolve the Promise + */ +function DurablyProviderInner({ + durably: durablyOrPromise, + children, +}: Omit) { + const durably = + durablyOrPromise instanceof Promise + ? use(durablyOrPromise) + : durablyOrPromise + + return ( + + {children} + + ) +} + +export function DurablyProvider({ + durably, + fallback, + children, +}: DurablyProviderProps) { + const inner = ( + {children} + ) + + if (fallback !== undefined) { + return {inner} + } + + return inner +} + +export function useDurably(): DurablyContextValue { + const context = useContext(DurablyContext) + if (!context) { + throw new Error('useDurably must be used within a DurablyProvider') + } + return context +} diff --git a/packages/durably-react/src/hooks/use-job-logs.ts b/packages/durably-react/src/hooks/use-job-logs.ts new file mode 100644 index 00000000..ff3b87ed --- /dev/null +++ b/packages/durably-react/src/hooks/use-job-logs.ts @@ -0,0 +1,41 @@ +import { useDurably } from '../context' +import type { LogEntry } from '../types' +import { useRunSubscription } from './use-run-subscription' + +export interface UseJobLogsOptions { + /** + * The run ID to subscribe to logs for + */ + runId: string | null + /** + * Maximum number of logs to keep (default: unlimited) + */ + maxLogs?: number +} + +export interface UseJobLogsResult { + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Clear all logs + */ + clearLogs: () => void +} + +/** + * Hook for subscribing to logs from a run. + * Use this when you only need logs, not full run status. + */ +export function useJobLogs(options: UseJobLogsOptions): UseJobLogsResult { + const { durably } = useDurably() + const { runId, maxLogs } = options + + const subscription = useRunSubscription(durably, runId, { maxLogs }) + + return { + logs: subscription.logs, + clearLogs: subscription.clearLogs, + } +} diff --git a/packages/durably-react/src/hooks/use-job-run.ts b/packages/durably-react/src/hooks/use-job-run.ts new file mode 100644 index 00000000..a9597517 --- /dev/null +++ b/packages/durably-react/src/hooks/use-job-run.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react' +import { useDurably } from '../context' +import type { LogEntry, Progress, RunStatus } from '../types' +import { useRunSubscription } from './use-run-subscription' + +export interface UseJobRunOptions { + /** + * The run ID to subscribe to + */ + runId: string | null +} + +export interface UseJobRunResult { + /** + * Current run status + */ + status: RunStatus | null + /** + * Output from completed run + */ + output: TOutput | null + /** + * Error message from failed run + */ + error: string | null + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Current progress + */ + progress: Progress | null + /** + * Whether a run is currently running + */ + isRunning: boolean + /** + * Whether a run is pending + */ + isPending: boolean + /** + * Whether the run completed successfully + */ + isCompleted: boolean + /** + * Whether the run failed + */ + isFailed: boolean + /** + * Whether the run was cancelled + */ + isCancelled: boolean +} + +/** + * Hook for subscribing to an existing run by ID. + * Use this when you have a runId and want to track its status. + */ +export function useJobRun( + options: UseJobRunOptions, +): UseJobRunResult { + const { durably } = useDurably() + const { runId } = options + + const subscription = useRunSubscription(durably, runId) + + // Fetch initial state when runId changes + const fetchedRef = useRef>(new Set()) + + useEffect(() => { + if (!durably || !runId || fetchedRef.current.has(runId)) return + + // Mark as fetched to avoid duplicate fetches + fetchedRef.current.add(runId) + + // Try to fetch current run state + // Note: We need to use internal APIs or polling here + // For now, we rely on event-based updates + }, [durably, runId]) + + return { + status: subscription.status, + output: subscription.output, + error: subscription.error, + logs: subscription.logs, + progress: subscription.progress, + isRunning: subscription.status === 'running', + isPending: subscription.status === 'pending', + isCompleted: subscription.status === 'completed', + isFailed: subscription.status === 'failed', + isCancelled: subscription.status === 'cancelled', + } +} diff --git a/packages/durably-react/src/hooks/use-job.ts b/packages/durably-react/src/hooks/use-job.ts new file mode 100644 index 00000000..8b86c1fa --- /dev/null +++ b/packages/durably-react/src/hooks/use-job.ts @@ -0,0 +1,353 @@ +import type { JobDefinition, JobHandle } from '@coji/durably' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDurably } from '../context' +import type { LogEntry, Progress, RunStatus } from '../types' + +export interface UseJobOptions { + /** + * Initial Run ID to subscribe to (for reconnection scenarios) + */ + initialRunId?: string + /** + * Automatically resume tracking any pending or running job on initialization. + * If a pending or running run exists for this job, the hook will subscribe to it. + * @default true + */ + autoResume?: boolean + /** + * Automatically switch to tracking the latest running job when a new run starts. + * When true, the hook will update to track any new run for this job as soon as it starts running. + * When false, the hook will only track the run that was triggered or explicitly set. + * @default true + */ + followLatest?: boolean +} + +export interface UseJobResult { + /** + * Trigger the job with the given input + */ + trigger: (input: TInput) => Promise<{ runId: string }> + /** + * Trigger and wait for completion + */ + triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }> + /** + * Current run status + */ + status: RunStatus | null + /** + * Output from completed run + */ + output: TOutput | null + /** + * Error message from failed run + */ + error: string | null + /** + * Logs collected during execution + */ + logs: LogEntry[] + /** + * Current progress + */ + progress: Progress | null + /** + * Whether a run is currently running + */ + isRunning: boolean + /** + * Whether a run is pending + */ + isPending: boolean + /** + * Whether the run completed successfully + */ + isCompleted: boolean + /** + * Whether the run failed + */ + isFailed: boolean + /** + * Whether the run was cancelled + */ + isCancelled: boolean + /** + * Current run ID + */ + currentRunId: string | null + /** + * Reset all state + */ + reset: () => void +} + +export function useJob< + TName extends string, + TInput extends Record, + // biome-ignore lint/suspicious/noConfusingVoidType: TOutput can be void for jobs without return value + TOutput extends Record | void, +>( + jobDefinition: JobDefinition, + options?: UseJobOptions, +): UseJobResult { + const { durably } = useDurably() + + const [status, setStatus] = useState(null) + const [output, setOutput] = useState(null) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const [progress, setProgress] = useState(null) + const [currentRunId, setCurrentRunId] = useState( + options?.initialRunId ?? null, + ) + + const jobHandleRef = useRef | null>(null) + // Use ref to track the latest runId for event filtering + const currentRunIdRef = useRef(currentRunId) + currentRunIdRef.current = currentRunId + + // Register job and set up event listeners + useEffect(() => { + if (!durably) return + + // Register the job (use fixed key for simpler type handling) + const d = durably.register({ + _job: jobDefinition, + }) + const jobHandle = d.jobs._job + jobHandleRef.current = jobHandle + + // Subscribe to each event type separately + const unsubscribes: (() => void)[] = [] + + unsubscribes.push( + durably.on('run:start', (event) => { + // Check if this is a run for our job + if (event.jobName !== jobDefinition.name) return + + // If followLatest is disabled, only update if this is our current run + if (options?.followLatest === false) { + if (event.runId !== currentRunIdRef.current) return + setStatus('running') + return + } + + // Switch to tracking the running job (followLatest: true, default) + setCurrentRunId(event.runId) + currentRunIdRef.current = event.runId + setStatus('running') + // Reset output/error when switching to a new run + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + }), + ) + + unsubscribes.push( + durably.on('run:complete', (event) => { + if (event.runId !== currentRunIdRef.current) return + setStatus('completed') + setOutput(event.output as TOutput) + }), + ) + + unsubscribes.push( + durably.on('run:fail', (event) => { + if (event.runId !== currentRunIdRef.current) return + setStatus('failed') + setError(event.error) + }), + ) + + unsubscribes.push( + durably.on('run:progress', (event) => { + if (event.runId !== currentRunIdRef.current) return + setProgress(event.progress) + }), + ) + + unsubscribes.push( + durably.on('log:write', (event) => { + if (event.runId !== currentRunIdRef.current) return + setLogs((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + runId: event.runId, + stepName: event.stepName, + level: event.level, + message: event.message, + data: event.data, + timestamp: new Date().toISOString(), + }, + ]) + }), + ) + + // If we have an initialRunId, fetch its current state + if (options?.initialRunId && currentRunIdRef.current) { + jobHandle.getRun(currentRunIdRef.current).then((run) => { + if (run) { + setStatus(run.status as RunStatus) + if (run.status === 'completed' && run.output) { + setOutput(run.output as TOutput) + } + if (run.status === 'failed' && run.error) { + setError(run.error) + } + } + }) + } + + // Auto-resume: find any pending or running runs for this job (default: true) + if (options?.autoResume !== false && !options?.initialRunId) { + ;(async () => { + // First check for running runs + const runningRuns = await jobHandle.getRuns({ status: 'running' }) + if (runningRuns.length > 0) { + const run = runningRuns[0] + setCurrentRunId(run.id) + currentRunIdRef.current = run.id + setStatus(run.status as RunStatus) + return + } + + // Then check for pending runs + const pendingRuns = await jobHandle.getRuns({ status: 'pending' }) + if (pendingRuns.length > 0) { + const run = pendingRuns[0] + setCurrentRunId(run.id) + currentRunIdRef.current = run.id + setStatus(run.status as RunStatus) + } + })() + } + + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + }, [ + durably, + jobDefinition, + options?.initialRunId, + options?.autoResume, + options?.followLatest, + ]) + + // Update state when currentRunId changes (for initialRunId scenario) + useEffect(() => { + if (!durably || !currentRunId) return + + const jobHandle = jobHandleRef.current + if (jobHandle && options?.initialRunId) { + jobHandle.getRun(currentRunId).then((run) => { + if (run) { + setStatus(run.status as RunStatus) + if (run.status === 'completed' && run.output) { + setOutput(run.output as TOutput) + } + if (run.status === 'failed' && run.error) { + setError(run.error) + } + } + }) + } + }, [durably, currentRunId, options?.initialRunId]) + + const trigger = useCallback( + async (input: TInput): Promise<{ runId: string }> => { + const jobHandle = jobHandleRef.current + if (!jobHandle) { + throw new Error('Job not ready') + } + + // Reset state + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + + const run = await jobHandle.trigger(input) + setCurrentRunId(run.id) + currentRunIdRef.current = run.id + setStatus('pending') + + return { runId: run.id } + }, + [], + ) + + const triggerAndWait = useCallback( + async (input: TInput): Promise<{ runId: string; output: TOutput }> => { + const jobHandle = jobHandleRef.current + if (!jobHandle || !durably) { + throw new Error('Job not ready') + } + + // Reset state + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + + const run = await jobHandle.trigger(input) + setCurrentRunId(run.id) + currentRunIdRef.current = run.id + setStatus('pending') + + // Wait for completion + return new Promise((resolve, reject) => { + const checkCompletion = async () => { + const updatedRun = await jobHandle.getRun(run.id) + if (!updatedRun) { + reject(new Error('Run not found')) + return + } + + if (updatedRun.status === 'completed') { + resolve({ runId: run.id, output: updatedRun.output as TOutput }) + } else if (updatedRun.status === 'failed') { + reject(new Error(updatedRun.error ?? 'Job failed')) + } else if (updatedRun.status === 'cancelled') { + reject(new Error('Job cancelled')) + } else { + // Still running, check again + setTimeout(checkCompletion, 50) + } + } + checkCompletion() + }) + }, + [durably], + ) + + const reset = useCallback(() => { + setStatus(null) + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + setCurrentRunId(null) + }, []) + + return { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning: status === 'running', + isPending: status === 'pending', + isCompleted: status === 'completed', + isFailed: status === 'failed', + isCancelled: status === 'cancelled', + currentRunId, + reset, + } +} diff --git a/packages/durably-react/src/hooks/use-run-subscription.ts b/packages/durably-react/src/hooks/use-run-subscription.ts new file mode 100644 index 00000000..d5ed8add --- /dev/null +++ b/packages/durably-react/src/hooks/use-run-subscription.ts @@ -0,0 +1,156 @@ +import type { Durably } from '@coji/durably' +import { useEffect, useRef, useState } from 'react' +import type { LogEntry, Progress, RunStatus } from '../types' + +export interface RunSubscriptionState { + status: RunStatus | null + output: TOutput | null + error: string | null + logs: LogEntry[] + progress: Progress | null +} + +export interface UseRunSubscriptionOptions { + /** + * Maximum number of logs to keep (0 = unlimited) + */ + maxLogs?: number +} + +export interface UseRunSubscriptionResult< + TOutput = unknown, +> extends RunSubscriptionState { + /** + * Clear all logs + */ + clearLogs: () => void + /** + * Reset all state + */ + reset: () => void +} + +/** + * Internal hook for subscribing to run events. + * Shared by useJob, useJobRun, and useJobLogs. + */ +export function useRunSubscription( + durably: Durably | null, + runId: string | null, + options?: UseRunSubscriptionOptions, +): UseRunSubscriptionResult { + const [status, setStatus] = useState(null) + const [output, setOutput] = useState(null) + const [error, setError] = useState(null) + const [logs, setLogs] = useState([]) + const [progress, setProgress] = useState(null) + + // Use ref to track the latest runId for event filtering + const runIdRef = useRef(runId) + runIdRef.current = runId + + const maxLogs = options?.maxLogs ?? 0 + + // Subscribe to events + useEffect(() => { + if (!durably || !runId) return + + const unsubscribes: (() => void)[] = [] + + unsubscribes.push( + durably.on('run:start', (event) => { + if (event.runId !== runIdRef.current) return + setStatus('running') + }), + ) + + unsubscribes.push( + durably.on('run:complete', (event) => { + if (event.runId !== runIdRef.current) return + setStatus('completed') + setOutput(event.output as TOutput) + }), + ) + + unsubscribes.push( + durably.on('run:fail', (event) => { + if (event.runId !== runIdRef.current) return + setStatus('failed') + setError(event.error) + }), + ) + + unsubscribes.push( + durably.on('run:cancel', (event) => { + if (event.runId !== runIdRef.current) return + setStatus('cancelled') + }), + ) + + unsubscribes.push( + durably.on('run:retry', (event) => { + if (event.runId !== runIdRef.current) return + setStatus('pending') + setError(null) + }), + ) + + unsubscribes.push( + durably.on('run:progress', (event) => { + if (event.runId !== runIdRef.current) return + setProgress(event.progress) + }), + ) + + unsubscribes.push( + durably.on('log:write', (event) => { + if (event.runId !== runIdRef.current) return + setLogs((prev) => { + const newLog: LogEntry = { + id: crypto.randomUUID(), + runId: event.runId, + stepName: event.stepName, + level: event.level, + message: event.message, + data: event.data, + timestamp: new Date().toISOString(), + } + const newLogs = [...prev, newLog] + // Apply maxLogs limit if set + if (maxLogs > 0 && newLogs.length > maxLogs) { + return newLogs.slice(-maxLogs) + } + return newLogs + }) + }), + ) + + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + }, [durably, runId, maxLogs]) + + const clearLogs = () => { + setLogs([]) + } + + const reset = () => { + setStatus(null) + setOutput(null) + setError(null) + setLogs([]) + setProgress(null) + } + + return { + status, + output, + error, + logs, + progress, + clearLogs, + reset, + } +} diff --git a/packages/durably-react/src/hooks/use-runs.ts b/packages/durably-react/src/hooks/use-runs.ts new file mode 100644 index 00000000..1cd2bc94 --- /dev/null +++ b/packages/durably-react/src/hooks/use-runs.ts @@ -0,0 +1,162 @@ +import type { Run } from '@coji/durably' +import { useCallback, useEffect, useState } from 'react' +import { useDurably } from '../context' + +export interface UseRunsOptions { + /** + * Filter by job name + */ + jobName?: string + /** + * Filter by status + */ + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + /** + * Number of runs per page + * @default 10 + */ + pageSize?: number + /** + * Subscribe to real-time updates + * @default true + */ + realtime?: boolean +} + +export interface UseRunsResult { + /** + * List of runs for the current page + */ + runs: Run[] + /** + * Current page (0-indexed) + */ + page: number + /** + * Whether there are more pages + */ + hasMore: boolean + /** + * Whether data is being loaded + */ + isLoading: boolean + /** + * Go to the next page + */ + nextPage: () => void + /** + * Go to the previous page + */ + prevPage: () => void + /** + * Go to a specific page + */ + goToPage: (page: number) => void + /** + * Refresh the current page + */ + refresh: () => Promise +} + +/** + * Hook for listing runs with pagination and real-time updates. + * + * @example + * ```tsx + * function Dashboard() { + * const { runs, page, hasMore, nextPage, prevPage, isLoading } = useRuns({ + * pageSize: 20, + * }) + * + * return ( + *
+ * {runs.map(run => ( + *
{run.jobName}: {run.status}
+ * ))} + * + * + *
+ * ) + * } + * ``` + */ +export function useRuns(options?: UseRunsOptions): UseRunsResult { + const { durably } = useDurably() + const pageSize = options?.pageSize ?? 10 + const realtime = options?.realtime ?? true + + const [runs, setRuns] = useState([]) + const [page, setPage] = useState(0) + const [hasMore, setHasMore] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const refresh = useCallback(async () => { + if (!durably) return + + setIsLoading(true) + try { + const data = await durably.getRuns({ + jobName: options?.jobName, + status: options?.status, + limit: pageSize + 1, + offset: page * pageSize, + }) + setHasMore(data.length > pageSize) + setRuns(data.slice(0, pageSize)) + } finally { + setIsLoading(false) + } + }, [durably, options?.jobName, options?.status, pageSize, page]) + + // Initial fetch and subscribe to events + useEffect(() => { + if (!durably) return + + refresh() + + if (!realtime) return + + const unsubscribes = [ + durably.on('run:trigger', refresh), + durably.on('run:start', refresh), + durably.on('run:complete', refresh), + durably.on('run:fail', refresh), + durably.on('run:cancel', refresh), + durably.on('run:retry', refresh), + durably.on('run:progress', refresh), + durably.on('step:start', refresh), + durably.on('step:complete', refresh), + ] + + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + }, [durably, refresh, realtime]) + + const nextPage = useCallback(() => { + if (hasMore) { + setPage((p) => p + 1) + } + }, [hasMore]) + + const prevPage = useCallback(() => { + setPage((p) => Math.max(0, p - 1)) + }, []) + + const goToPage = useCallback((newPage: number) => { + setPage(Math.max(0, newPage)) + }, []) + + return { + runs, + page, + hasMore, + isLoading, + nextPage, + prevPage, + goToPage, + refresh, + } +} diff --git a/packages/durably-react/src/index.ts b/packages/durably-react/src/index.ts new file mode 100644 index 00000000..3e75bb57 --- /dev/null +++ b/packages/durably-react/src/index.ts @@ -0,0 +1,14 @@ +// @coji/durably-react - Browser-complete mode +// This entry point is for running Durably entirely in the browser with OPFS + +export { DurablyProvider, useDurably } from './context' +export type { DurablyProviderProps } from './context' +export { useJob } from './hooks/use-job' +export type { UseJobOptions, UseJobResult } from './hooks/use-job' +export { useJobLogs } from './hooks/use-job-logs' +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 { DurablyEvent, LogEntry, Progress, RunStatus } from './types' diff --git a/packages/durably-react/src/types.ts b/packages/durably-react/src/types.ts new file mode 100644 index 00000000..d1f7ce16 --- /dev/null +++ b/packages/durably-react/src/types.ts @@ -0,0 +1,67 @@ +// Shared type definitions for @coji/durably-react + +export type RunStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + +export interface Progress { + current: number + total?: number + message?: string +} + +export interface LogEntry { + id: string + runId: string + stepName: string | null + level: 'info' | 'warn' | 'error' + message: string + data: unknown + timestamp: string +} + +// SSE event types (sent from server) +export type DurablyEvent = + | { type: 'run:start'; runId: string; jobName: string; payload: unknown } + | { + type: 'run:complete' + runId: string + jobName: string + output: unknown + duration: number + } + | { type: 'run:fail'; runId: string; jobName: string; error: string } + | { type: 'run:cancel'; runId: string; jobName: string } + | { type: 'run:retry'; runId: string; jobName: string } + | { + type: 'run:progress' + runId: string + jobName: string + progress: Progress + } + | { + type: 'step:start' + runId: string + jobName: string + stepName: string + stepIndex: number + } + | { + type: 'step:complete' + runId: string + jobName: string + stepName: string + stepIndex: number + output: unknown + } + | { + type: 'log:write' + runId: string + jobName: string + level: 'info' | 'warn' | 'error' + message: string + data: unknown + } diff --git a/packages/durably-react/tests/browser/provider.test.tsx b/packages/durably-react/tests/browser/provider.test.tsx new file mode 100644 index 00000000..5eea0319 --- /dev/null +++ b/packages/durably-react/tests/browser/provider.test.tsx @@ -0,0 +1,84 @@ +/** + * DurablyProvider Tests + * + * Test DurablyProvider initialization and context + */ + +import type { Durably } from '@coji/durably' +import { render, renderHook, waitFor } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { DurablyProvider, useDurably } from '../../src' +import { createTestDurably } from '../helpers/create-test-durably' + +describe('DurablyProvider', () => { + // Track all instances created during tests for cleanup + const instances: Durably[] = [] + + afterEach(async () => { + for (const instance of instances) { + try { + await instance.stop() + } catch { + // Ignore errors from already stopped instances + } + } + instances.length = 0 + await new Promise((r) => setTimeout(r, 200)) + }) + + it('provides Durably instance via context', async () => { + const durably = await createTestDurably() + instances.push(durably) + + const { result } = renderHook(() => useDurably(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.durably).toBe(durably) + }) + + it('works correctly in StrictMode', async () => { + const durably = await createTestDurably() + instances.push(durably) + + function TestComponent() { + const { durably: d } = useDurably() + return
{d ? 'has-durably' : 'no-durably'}
+ } + + const { getByTestId } = render( + + + + + , + ) + + await waitFor(() => { + expect(getByTestId('status').textContent).toBe('has-durably') + }) + }) + + it('provides the same durably instance from useDurably', async () => { + const durably = await createTestDurably() + instances.push(durably) + + const { result } = renderHook(() => useDurably(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + // Should be the exact same instance + expect(result.current.durably).toBe(durably) + }) + + it('throws when useDurably is used outside provider', () => { + expect(() => { + renderHook(() => useDurably()) + }).toThrow('useDurably must be used within a DurablyProvider') + }) +}) diff --git a/packages/durably-react/tests/browser/use-job-logs.test.tsx b/packages/durably-react/tests/browser/use-job-logs.test.tsx new file mode 100644 index 00000000..06004ab7 --- /dev/null +++ b/packages/durably-react/tests/browser/use-job-logs.test.tsx @@ -0,0 +1,184 @@ +/** + * useJobLogs Tests + * + * Test useJobLogs hook for subscribing to logs + */ + +import { defineJob, type Durably } from '@coji/durably' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { DurablyProvider, useDurably, useJobLogs } from '../../src' +import { createTestDurably } from '../helpers/create-test-durably' + +// Test job that generates logs with delay to ensure we can subscribe +const loggingJob = defineJob({ + name: 'logging-job-logs', + input: z.object({ count: z.number() }), + run: async (context, payload) => { + for (let i = 0; i < payload.count; i++) { + context.log.info(`Log ${i + 1}`) + await context.run(`step${i}`, async () => { + await new Promise((r) => setTimeout(r, 30)) + return `done${i}` + }) + } + }, +}) + +describe('useJobLogs', () => { + const instances: Durably[] = [] + + afterEach(async () => { + for (const instance of instances) { + try { + await instance.stop() + } catch { + // Ignore errors from already stopped instances + } + } + instances.length = 0 + await new Promise((r) => setTimeout(r, 200)) + }) + + const createWrapper = (durably: Durably) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) + } + + it('collects logs for run', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobLogs({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + _job: loggingJob, + }) + const run = await d.jobs._job.trigger({ count: 3 }) + result.current.setRunId(run.id) + + await waitFor( + () => { + expect(result.current.logs.length).toBeGreaterThan(0) + }, + { timeout: 3000 }, + ) + + // Check log structure + const log = result.current.logs[0] + expect(log.message).toBeDefined() + expect(log.level).toBe('info') + expect(log.runId).toBe(run.id) + }) + + it('handles null runId', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJobLogs({ runId: null }), { + wrapper: createWrapper(durably), + }) + + // With null runId, logs should be empty + expect(result.current.logs).toEqual([]) + }) + + it('respects maxLogs limit', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobLogs({ runId, maxLogs: 5 }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + _job: loggingJob, + }) + const run = await d.jobs._job.trigger({ count: 10 }) + result.current.setRunId(run.id) + + // Wait for job to complete + await new Promise((r) => setTimeout(r, 500)) + + // Should have at most 5 logs + expect(result.current.logs.length).toBeLessThanOrEqual(5) + }) + + it('clears logs on clearLogs call', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobLogs({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + _job: loggingJob, + }) + const run = await d.jobs._job.trigger({ count: 3 }) + result.current.setRunId(run.id) + + // Wait for job to complete and logs to be collected + await new Promise((r) => setTimeout(r, 500)) + + await waitFor( + () => { + expect(result.current.logs.length).toBeGreaterThan(0) + }, + { timeout: 3000 }, + ) + + // Clear logs after job is done + result.current.clearLogs() + + // Wait for the state update to propagate + await waitFor(() => { + expect(result.current.logs.length).toBe(0) + }) + }) +}) diff --git a/packages/durably-react/tests/browser/use-job-run.test.tsx b/packages/durably-react/tests/browser/use-job-run.test.tsx new file mode 100644 index 00000000..9005079b --- /dev/null +++ b/packages/durably-react/tests/browser/use-job-run.test.tsx @@ -0,0 +1,499 @@ +/** + * useJobRun Tests + * + * Test useJobRun hook for subscribing to existing runs + */ + +import { defineJob, type Durably } from '@coji/durably' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { DurablyProvider, useDurably, useJobRun } from '../../src' +import { createTestDurably } from '../helpers/create-test-durably' + +// Test job definitions - use slow jobs to ensure we can subscribe before completion +const testJob = defineJob({ + name: 'test-job-run', + input: z.object({ input: z.string() }), + output: z.object({ result: z.string() }), + run: async (context, payload) => { + await context.run('process', async () => { + await new Promise((r) => setTimeout(r, 50)) + }) + return { result: `processed: ${payload.input}` } + }, +}) + +const failingJob = defineJob({ + name: 'failing-job-run', + input: z.object({ input: z.string() }), + run: async (context) => { + await context.run('fail', async () => { + await new Promise((r) => setTimeout(r, 50)) + }) + throw new Error('Job failed') + }, +}) + +const progressJob = defineJob({ + name: 'progress-job-run', + input: z.object({ input: z.string() }), + output: z.object({ done: z.boolean() }), + run: async (context) => { + context.progress(1, 2, 'Step 1') + await context.run('step1', async () => { + await new Promise((r) => setTimeout(r, 50)) + }) + context.progress(2, 2, 'Step 2') + return { done: true } + }, +}) + +describe('useJobRun', () => { + const instances: Durably[] = [] + + afterEach(async () => { + for (const instance of instances) { + try { + await instance.stop() + } catch { + // Ignore errors from already stopped instances + } + } + instances.length = 0 + await new Promise((r) => setTimeout(r, 200)) + }) + + const createWrapper = (durably: Durably) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) + } + + it('subscribes to run by id', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + // Use a combined hook that triggers then subscribes + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + // Trigger job and set runId + const d = durably.register({ _job: testJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + + // Update runId to start subscription + result.current.setRunId(run.id) + + // Should eventually see the run complete + await waitFor( + () => { + expect(result.current.status).not.toBeNull() + }, + { timeout: 3000 }, + ) + }) + + it('handles null runId', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJobRun({ runId: null }), { + wrapper: createWrapper(durably), + }) + + // With null runId, status should remain null + expect(result.current.status).toBeNull() + expect(result.current.output).toBeNull() + expect(result.current.error).toBeNull() + }) + + it('provides output when run completes', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun<{ result: string }>({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ _job: testJob }) + const run = await d.jobs._job.trigger({ input: 'hello' }) + result.current.setRunId(run.id) + + await waitFor( + () => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ result: 'processed: hello' }) + }, + { timeout: 3000 }, + ) + }) + + it('provides error when run fails', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + _job: failingJob, + }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + await waitFor( + () => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Job failed') + }, + { timeout: 3000 }, + ) + }) + + it('updates status when run is cancelled', async () => { + const durably = await createTestDurably({ + pollingInterval: 50, + autoStart: false, + }) + instances.push(durably) + + // Worker is not started, so we can cancel before the job runs + const noAutoStartWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: noAutoStartWrapper, + }) + + const d = durably.register({ _job: testJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + // Cancel the pending run (worker is not running) + await durably.cancel(run.id) + + await waitFor( + () => { + expect(result.current.status).toBe('cancelled') + expect(result.current.isCancelled).toBe(true) + }, + { timeout: 3000 }, + ) + }) + + it('resets status when run is retried', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ _job: failingJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + // Wait for the run to fail + await waitFor( + () => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Job failed') + }, + { timeout: 3000 }, + ) + + // Stop worker so retry doesn't immediately re-run + await durably.stop() + + // Retry the run + await durably.retry(run.id) + + await waitFor( + () => { + expect(result.current.status).toBe('pending') + expect(result.current.error).toBeNull() + expect(result.current.isPending).toBe(true) + }, + { timeout: 3000 }, + ) + }) + + it('tracks retry from failed through completion', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + // Job that fails first time, succeeds on retry (use attempt counter via steps) + let attemptCount = 0 + const retryableJob = defineJob({ + name: 'retryable-job', + input: z.object({ input: z.string() }), + output: z.object({ result: z.string() }), + run: async (context, payload) => { + attemptCount++ + await context.run('process', async () => { + await new Promise((r) => setTimeout(r, 30)) + }) + if (attemptCount === 1) { + throw new Error('First attempt failed') + } + return { result: `success: ${payload.input}` } + }, + }) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun<{ result: string }>({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ _job: retryableJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + // Wait for the run to fail + await waitFor( + () => { + expect(result.current.status).toBe('failed') + }, + { timeout: 3000 }, + ) + + // Retry the run (worker is still running, will pick it up) + await durably.retry(run.id) + + // Should track through to completion + await waitFor( + () => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ result: 'success: test' }) + }, + { timeout: 3000 }, + ) + }) + + it('tracks retry from cancelled through completion', async () => { + const durably = await createTestDurably({ + pollingInterval: 50, + autoStart: false, + }) + instances.push(durably) + + // Worker is not started, so we can control when the worker runs + const noAutoStartWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun<{ result: string }>({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: noAutoStartWrapper, + }) + + const d = durably.register({ _job: testJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + // Cancel the pending run + await durably.cancel(run.id) + + await waitFor( + () => { + expect(result.current.status).toBe('cancelled') + expect(result.current.isCancelled).toBe(true) + }, + { timeout: 3000 }, + ) + + // Retry the run + await durably.retry(run.id) + + // Should see pending + await waitFor( + () => { + expect(result.current.status).toBe('pending') + }, + { timeout: 3000 }, + ) + + // Start the worker to process the retry + durably.start() + + // Should track through to completion + await waitFor( + () => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ result: 'processed: test' }) + }, + { timeout: 3000 }, + ) + }) + + it('tracks progress updates', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + _job: progressJob, + }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + // Should eventually see progress or complete + await waitFor( + () => { + expect( + result.current.progress !== null || + result.current.status === 'completed', + ).toBe(true) + }, + { timeout: 3000 }, + ) + }) + + it('provides boolean helpers', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + function useTriggerAndSubscribe() { + const { durably: _ } = useDurably() + const [runId, setRunId] = useState(null) + const subscription = useJobRun({ runId }) + + return { + ...subscription, + + runId, + setRunId, + } + } + + const { result } = renderHook(() => useTriggerAndSubscribe(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ _job: testJob }) + const run = await d.jobs._job.trigger({ input: 'test' }) + result.current.setRunId(run.id) + + await waitFor( + () => { + expect(result.current.isCompleted).toBe(true) + }, + { timeout: 3000 }, + ) + + expect(result.current.isRunning).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isFailed).toBe(false) + }) +}) diff --git a/packages/durably-react/tests/browser/use-job.test.tsx b/packages/durably-react/tests/browser/use-job.test.tsx new file mode 100644 index 00000000..b6ccffcf --- /dev/null +++ b/packages/durably-react/tests/browser/use-job.test.tsx @@ -0,0 +1,427 @@ +/** + * useJob Tests + * + * Test useJob hook for browser-complete mode + */ + +import { defineJob, type Durably } from '@coji/durably' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { DurablyProvider, useJob } from '../../src' +import { createTestDurably } from '../helpers/create-test-durably' + +// Test job definitions +const testJob = defineJob({ + name: 'test-job', + input: z.object({ input: z.string() }), + output: z.object({ success: z.boolean() }), + run: async (_context, payload) => { + return { success: payload.input === 'test' } + }, +}) + +const failingJob = defineJob({ + name: 'failing-job', + input: z.object({ input: z.string() }), + run: async () => { + throw new Error('Something went wrong') + }, +}) + +const progressJob = defineJob({ + name: 'progress-job', + input: z.object({ input: z.string() }), + output: z.object({ done: z.boolean() }), + run: async (context) => { + context.progress(1, 3, 'Step 1') + await context.run('step1', () => 'done') + context.progress(2, 3, 'Step 2') + await context.run('step2', () => 'done') + context.progress(3, 3, 'Step 3') + return { done: true } + }, +}) + +const loggingJob = defineJob({ + name: 'logging-job', + input: z.object({ input: z.string() }), + run: async (context) => { + context.log.info('Starting') + await context.run('work', () => 'done') + context.log.info('Completed') + }, +}) + +const longRunningJob = defineJob({ + name: 'long-running-job', + input: z.object({ input: z.string() }), + output: z.object({ done: z.boolean() }), + run: async (context) => { + // Simulate a long-running job by waiting + await context.run('wait', async () => { + await new Promise((resolve) => setTimeout(resolve, 5000)) + }) + return { done: true } + }, +}) + +describe('useJob', () => { + // Track all instances created during tests for cleanup + const instances: Durably[] = [] + + afterEach(async () => { + for (const instance of instances) { + try { + await instance.stop() + } catch { + // Ignore errors from already stopped instances + } + } + instances.length = 0 + await new Promise((r) => setTimeout(r, 200)) + }) + + // Helper to create wrapper with a fresh durably instance + const createWrapper = (durably: Durably) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) + } + + it('returns trigger function that executes job', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + const { runId } = await result.current.trigger({ input: 'test' }) + + expect(runId).toBeDefined() + expect(typeof runId).toBe('string') + }) + + it('updates status from pending to running to completed', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + expect(result.current.status).toBeNull() + + result.current.trigger({ input: 'test' }) + + // Status should be pending or already progressing + // (fast execution may skip pending state) + await waitFor(() => { + expect(result.current.status).not.toBeNull() + }) + + // Then eventually complete + await waitFor(() => { + expect(result.current.status).toBe('completed') + }) + }) + + it('provides output when completed', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(result.current.output).toEqual({ success: true }) + }) + }) + + it('provides error when failed', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(failingJob), { + wrapper: createWrapper(durably), + }) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Something went wrong') + }) + }) + + it('updates progress during execution', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(progressJob), { + wrapper: createWrapper(durably), + }) + + result.current.trigger({ input: 'test' }) + + // Eventually should see progress (may not catch all intermediate states) + await waitFor(() => { + expect(result.current.progress).not.toBeNull() + }) + + // Wait for completion + await waitFor(() => { + expect(result.current.status).toBe('completed') + }) + }) + + it('collects logs during execution', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(loggingJob), { + wrapper: createWrapper(durably), + }) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(result.current.logs.length).toBeGreaterThanOrEqual(1) + }) + + // Check log structure + const log = result.current.logs[0] + expect(log.message).toBeDefined() + expect(log.level).toBe('info') + }) + + it('provides boolean helpers', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + expect(result.current.isRunning).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isCompleted).toBe(false) + expect(result.current.isFailed).toBe(false) + + result.current.trigger({ input: 'test' }) + + // Wait for some state (may skip pending if fast) + await waitFor(() => { + expect( + result.current.isPending || + result.current.isRunning || + result.current.isCompleted, + ).toBe(true) + }) + + // completed state + await waitFor(() => { + expect(result.current.isCompleted).toBe(true) + }) + + expect(result.current.isRunning).toBe(false) + expect(result.current.isPending).toBe(false) + expect(result.current.isFailed).toBe(false) + }) + + it('triggerAndWait resolves with output', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + const { runId, output } = await result.current.triggerAndWait({ + input: 'test', + }) + + expect(runId).toBeDefined() + expect(output).toEqual({ success: true }) + }) + + it('triggerAndWait rejects on failure', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(failingJob), { + wrapper: createWrapper(durably), + }) + + await expect( + result.current.triggerAndWait({ input: 'test' }), + ).rejects.toThrow('Something went wrong') + }) + + it('reset clears all state', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + await result.current.trigger({ input: 'test' }) + await waitFor(() => expect(result.current.isCompleted).toBe(true)) + + result.current.reset() + + // Wait for reset to take effect + await waitFor(() => { + expect(result.current.status).toBeNull() + }) + expect(result.current.output).toBeNull() + expect(result.current.currentRunId).toBeNull() + }) + + it('sets initialRunId as currentRunId', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const fakeRunId = 'test-run-123' + + const { result } = renderHook( + () => useJob(testJob, { initialRunId: fakeRunId }), + { wrapper: createWrapper(durably) }, + ) + + // Should have the initial runId set + expect(result.current.currentRunId).toBe(fakeRunId) + }) + + it('unsubscribes on unmount', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result, unmount } = renderHook(() => useJob(testJob), { + wrapper: createWrapper(durably), + }) + + result.current.trigger({ input: 'test' }) + + // Unmount while running + unmount() + + // No errors should occur (memory leak test) + await new Promise((r) => setTimeout(r, 100)) + }) + + describe('followLatest option', () => { + it('switches to latest running job by default (followLatest: true)', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const slowJob = defineJob({ + name: 'slow-job', + input: z.object({ id: z.number() }), + output: z.object({ id: z.number() }), + run: async (context, payload) => { + await context.run('work', async () => { + await new Promise((r) => setTimeout(r, 200)) + }) + return { id: payload.id } + }, + }) + + const { result } = renderHook(() => useJob(slowJob), { + wrapper: createWrapper(durably), + }) + + // Trigger first job + const { runId: firstRunId } = await result.current.trigger({ id: 1 }) + + await waitFor(() => { + expect(result.current.currentRunId).toBe(firstRunId) + }) + + // Trigger second job while first is still running + const { runId: secondRunId } = await result.current.trigger({ id: 2 }) + + // Should switch to the second job when it starts running + await waitFor(() => { + expect(result.current.currentRunId).toBe(secondRunId) + }) + }) + + it('stays on current run when followLatest: false and external run starts', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + // This test verifies that followLatest: false keeps tracking the current run + // even when run:start events fire (from the worker starting jobs) + const slowJob = defineJob({ + name: 'slow-job-no-follow', + input: z.object({ id: z.number() }), + output: z.object({ id: z.number() }), + run: async (context, payload) => { + await context.run('work', async () => { + await new Promise((r) => setTimeout(r, 300)) + }) + return { id: payload.id } + }, + }) + + const { result } = renderHook( + () => useJob(slowJob, { followLatest: false }), + { wrapper: createWrapper(durably) }, + ) + + // Trigger first job + const { runId: firstRunId } = await result.current.trigger({ id: 1 }) + + // Wait for it to start running (status becomes 'running') + await waitFor(() => { + expect(result.current.status).toBe('running') + expect(result.current.currentRunId).toBe(firstRunId) + }) + + // Wait for the first run to complete - with followLatest: false, + // it should stay on firstRunId and eventually complete + await waitFor( + () => { + expect(result.current.status).toBe('completed') + expect(result.current.currentRunId).toBe(firstRunId) + }, + { timeout: 5000 }, + ) + + // Verify output is from the first job + expect(result.current.output).toEqual({ id: 1 }) + }) + }) + + it('triggerAndWait rejects on cancelled', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useJob(longRunningJob), { + wrapper: createWrapper(durably), + }) + + // Start the long-running job and get the promise + const waitPromise = result.current.triggerAndWait({ input: 'test' }) + + // Wait for the job to start running + await waitFor(() => { + expect(result.current.currentRunId).not.toBeNull() + expect(result.current.status).toBe('running') + }) + + // Cancel the job + const runId = result.current.currentRunId! + await durably.cancel(runId) + + // The promise should reject with 'Job cancelled' + await expect(waitPromise).rejects.toThrow('Job cancelled') + }) +}) diff --git a/packages/durably-react/tests/browser/use-runs.test.tsx b/packages/durably-react/tests/browser/use-runs.test.tsx new file mode 100644 index 00000000..0e734666 --- /dev/null +++ b/packages/durably-react/tests/browser/use-runs.test.tsx @@ -0,0 +1,270 @@ +/** + * useRuns Tests + * + * Test useRuns hook for browser-complete mode + */ + +import { defineJob, type Durably } from '@coji/durably' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { DurablyProvider, useRuns } from '../../src' +import { createTestDurably } from '../helpers/create-test-durably' + +// Test job definition +const testJob = defineJob({ + name: 'test-job-runs', + input: z.object({ value: z.number() }), + run: async (context, payload) => { + await context.run('work', async () => { + await new Promise((r) => setTimeout(r, 50)) + return payload.value * 2 + }) + }, +}) + +describe('useRuns', () => { + const instances: Durably[] = [] + + afterEach(async () => { + for (const instance of instances) { + try { + await instance.stop() + } catch { + // Ignore errors from already stopped instances + } + } + instances.length = 0 + await new Promise((r) => setTimeout(r, 200)) + }) + + const createWrapper = (durably: Durably) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) + } + + it('returns empty runs initially', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns(), { + wrapper: createWrapper(durably), + }) + + expect(result.current.runs).toEqual([]) + expect(result.current.page).toBe(0) + expect(result.current.hasMore).toBe(false) + }) + + it('lists runs after job execution', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns(), { + wrapper: createWrapper(durably), + }) + + // Trigger a job using the durably instance directly + const d = durably.register({ testJobHandle: testJob }) + await d.jobs.testJobHandle.trigger({ value: 10 }) + + // Wait for runs to update + await waitFor(() => { + expect(result.current.runs.length).toBeGreaterThan(0) + }) + + expect(result.current.runs[0].jobName).toBe('test-job-runs') + }) + + it('filters by jobName', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const otherJob = defineJob({ + name: 'other-job', + input: z.object({ x: z.string() }), + run: async () => {}, + }) + + const { result } = renderHook(() => useRuns({ jobName: 'test-job-runs' }), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ + testJobHandle: testJob, + otherJobHandle: otherJob, + }) + + await d.jobs.testJobHandle.trigger({ value: 1 }) + await d.jobs.otherJobHandle.trigger({ x: 'test' }) + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + }) + + expect(result.current.runs[0].jobName).toBe('test-job-runs') + }) + + it('filters by status', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns({ status: 'completed' }), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + // Trigger and wait for completion + const run = await d.jobs.testJobHandle.trigger({ value: 5 }) + + // Wait for run to complete + await waitFor( + async () => { + const runData = await d.jobs.testJobHandle.getRun(run.id) + expect(runData?.status).toBe('completed') + }, + { timeout: 5000 }, + ) + + // Refresh to get completed runs + await result.current.refresh() + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + expect(result.current.runs[0].status).toBe('completed') + }) + }) + + it('supports pagination', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns({ pageSize: 2 }), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + // Create 3 runs + await d.jobs.testJobHandle.trigger({ value: 1 }) + await d.jobs.testJobHandle.trigger({ value: 2 }) + await d.jobs.testJobHandle.trigger({ value: 3 }) + + await waitFor(() => { + expect(result.current.runs.length).toBe(2) + expect(result.current.hasMore).toBe(true) + }) + + // Go to next page + result.current.nextPage() + + await waitFor(() => { + expect(result.current.page).toBe(1) + expect(result.current.runs.length).toBe(1) + expect(result.current.hasMore).toBe(false) + }) + + // Go back + result.current.prevPage() + + await waitFor(() => { + expect(result.current.page).toBe(0) + }) + }) + + it('goToPage navigates directly', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns({ pageSize: 1 }), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + // Create 3 runs + await d.jobs.testJobHandle.trigger({ value: 1 }) + await d.jobs.testJobHandle.trigger({ value: 2 }) + await d.jobs.testJobHandle.trigger({ value: 3 }) + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + }) + + result.current.goToPage(2) + + await waitFor(() => { + expect(result.current.page).toBe(2) + }) + }) + + it('refresh reloads data', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + // Initially empty + expect(result.current.runs).toEqual([]) + + await d.jobs.testJobHandle.trigger({ value: 42 }) + + // Manually refresh + await result.current.refresh() + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + }) + }) + + it('updates in real-time by default', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns(), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + expect(result.current.runs.length).toBe(0) + + // Trigger job - should update automatically via events + await d.jobs.testJobHandle.trigger({ value: 99 }) + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + }) + }) + + it('disables real-time updates when realtime=false', async () => { + const durably = await createTestDurably({ pollingInterval: 50 }) + instances.push(durably) + + const { result } = renderHook(() => useRuns({ realtime: false }), { + wrapper: createWrapper(durably), + }) + + const d = durably.register({ testJobHandle: testJob }) + + await d.jobs.testJobHandle.trigger({ value: 77 }) + + // Wait a bit - should NOT update automatically + await new Promise((r) => setTimeout(r, 100)) + expect(result.current.runs.length).toBe(0) + + // Manual refresh should work + await result.current.refresh() + + await waitFor(() => { + expect(result.current.runs.length).toBe(1) + }) + }) +}) diff --git a/packages/durably-react/tests/client/create-durably-client.test.tsx b/packages/durably-react/tests/client/create-durably-client.test.tsx new file mode 100644 index 00000000..2b27151c --- /dev/null +++ b/packages/durably-react/tests/client/create-durably-client.test.tsx @@ -0,0 +1,134 @@ +/** + * createDurablyClient Tests + * + * Test the type-safe client factory. + * Note: Hook behavior (SSE subscription, logs, etc.) is tested in the individual hook tests. + * These tests focus on the factory's proxy behavior and job name mapping. + */ + +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDurablyClient } from '../../src/client/create-durably-client' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +// Mock job types for type inference testing +type MockJobs = { + [key: string]: unknown + importCsv: { + trigger: (input: { filename: string }) => Promise<{ + output: { rowCount: number } + }> + } + syncUsers: { + trigger: (input: { orgId: string }) => Promise<{ + output: { syncedCount: number } + }> + } +} + +describe('createDurablyClient', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + let originalFetch: typeof fetch + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + originalFetch = globalThis.fetch + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('creates a client with job accessors via proxy', () => { + const client = createDurablyClient({ api: '/api/durably' }) + + // Verify the proxy creates job clients on access + expect(client.importCsv).toBeDefined() + expect(client.syncUsers).toBeDefined() + expect(client.importCsv.useJob).toBeTypeOf('function') + expect(client.importCsv.useRun).toBeTypeOf('function') + expect(client.importCsv.useLogs).toBeTypeOf('function') + }) + + it('maps property name to jobName in trigger', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'test-run-id' }), + }) + globalThis.fetch = fetchMock + + const client = createDurablyClient({ api: '/api/durably' }) + + const { result } = renderHook(() => client.importCsv.useJob()) + await result.current.trigger({ filename: 'data.csv' }) + + // Verify jobName is derived from property name 'importCsv' + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + body: JSON.stringify({ + jobName: 'importCsv', + input: { filename: 'data.csv' }, + }), + }), + ) + }) + + it('different properties map to different job names', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'run-1' }), + }) + globalThis.fetch = fetchMock + + const client = createDurablyClient({ api: '/api/durably' }) + + // Trigger via importCsv + const { result: importResult } = renderHook(() => client.importCsv.useJob()) + await importResult.current.trigger({ filename: 'test.csv' }) + + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + body: expect.stringContaining('"jobName":"importCsv"'), + }), + ) + + // Trigger via syncUsers - should use different jobName + const { result: syncResult } = renderHook(() => client.syncUsers.useJob()) + await syncResult.current.trigger({ orgId: 'org-123' }) + + expect(fetchMock).toHaveBeenLastCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + body: expect.stringContaining('"jobName":"syncUsers"'), + }), + ) + }) + + it('useRun returns a hook function', () => { + const client = createDurablyClient({ api: '/api/durably' }) + + const { result } = renderHook(() => client.importCsv.useRun(null)) + + // Verify the hook returns expected shape + expect(result.current.status).toBeNull() + }) + + it('useLogs returns a hook function', () => { + const client = createDurablyClient({ api: '/api/durably' }) + + const { result } = renderHook(() => client.importCsv.useLogs(null)) + + // Verify the hook returns expected shape + expect(result.current.logs).toEqual([]) + }) +}) diff --git a/packages/durably-react/tests/client/create-job-hooks.test.tsx b/packages/durably-react/tests/client/create-job-hooks.test.tsx new file mode 100644 index 00000000..5c177344 --- /dev/null +++ b/packages/durably-react/tests/client/create-job-hooks.test.tsx @@ -0,0 +1,128 @@ +/** + * createJobHooks Tests + * + * Test the type-safe job hooks factory. + * Note: Hook behavior (SSE subscription, logs, progress, etc.) is tested in the individual hook tests. + * These tests focus on the factory returning correctly configured hooks. + */ + +import { defineJob } from '@coji/durably' +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { createJobHooks } from '../../src/client/create-job-hooks' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +// Define a mock job for type inference +const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ filename: z.string(), delimiter: z.string().optional() }), + output: z.object({ rowCount: z.number(), errors: z.number() }), + run: async () => ({ rowCount: 100, errors: 0 }), +}) + +describe('createJobHooks', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + let originalFetch: typeof fetch + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + originalFetch = globalThis.fetch + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('creates hooks object with useJob, useRun, useLogs', () => { + const hooks = createJobHooks({ + api: '/api/durably', + jobName: 'import-csv', + }) + + expect(hooks.useJob).toBeTypeOf('function') + expect(hooks.useRun).toBeTypeOf('function') + expect(hooks.useLogs).toBeTypeOf('function') + }) + + it('useJob uses the configured jobName', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'csv-run-id' }), + }) + globalThis.fetch = fetchMock + + const hooks = createJobHooks({ + api: '/api/durably', + jobName: 'import-csv', + }) + + const { result } = renderHook(() => hooks.useJob()) + await result.current.trigger({ filename: 'data.csv' }) + + // Verify the configured jobName is used + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + body: JSON.stringify({ + jobName: 'import-csv', + input: { filename: 'data.csv' }, + }), + }), + ) + }) + + it('useJob uses the configured api endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'run-id' }), + }) + globalThis.fetch = fetchMock + + const hooks = createJobHooks({ + api: '/custom/api/path', + jobName: 'import-csv', + }) + + const { result } = renderHook(() => hooks.useJob()) + await result.current.trigger({ filename: 'test.csv' }) + + // Verify the configured api endpoint is used + expect(fetchMock).toHaveBeenCalledWith( + '/custom/api/path/trigger', + expect.anything(), + ) + }) + + it('useRun returns a hook function', () => { + const hooks = createJobHooks({ + api: '/api/durably', + jobName: 'import-csv', + }) + + const { result } = renderHook(() => hooks.useRun(null)) + + // Verify the hook returns expected shape + expect(result.current.status).toBeNull() + }) + + it('useLogs returns a hook function', () => { + const hooks = createJobHooks({ + api: '/api/durably', + jobName: 'import-csv', + }) + + const { result } = renderHook(() => hooks.useLogs(null)) + + // Verify the hook returns expected shape + expect(result.current.logs).toEqual([]) + }) +}) diff --git a/packages/durably-react/tests/client/mock-event-source.ts b/packages/durably-react/tests/client/mock-event-source.ts new file mode 100644 index 00000000..c87a76cc --- /dev/null +++ b/packages/durably-react/tests/client/mock-event-source.ts @@ -0,0 +1,104 @@ +/** + * Mock EventSource for testing SSE connections + */ + +import type { DurablyEvent } from '@coji/durably' + +export interface MockEventSourceInstance { + url: string + readyState: number + onopen: ((event: Event) => void) | null + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: Event) => void) | null + close: () => void +} + +export interface MockEventSourceController { + instances: MockEventSourceInstance[] + emit: (event: Partial) => void + triggerError: (error: Error) => void + triggerOpen: () => void +} + +export type MockEventSourceConstructor = (new ( + url: string, +) => MockEventSourceInstance) & + MockEventSourceController + +export function createMockEventSource(opts?: { + onClose?: () => void +}): MockEventSourceConstructor { + const instances: MockEventSourceInstance[] = [] + + function MockEventSource( + this: MockEventSourceInstance, + url: string, + ): MockEventSourceInstance { + this.url = url + this.readyState = 0 // CONNECTING + this.onopen = null + this.onmessage = null + this.onerror = null + + this.close = () => { + this.readyState = 2 // CLOSED + opts?.onClose?.() + } + + instances.push(this) + + // Simulate async open + queueMicrotask(() => { + this.readyState = 1 // OPEN + if (this.onopen) { + this.onopen(new Event('open')) + } + }) + + return this + } + + // Static properties for controller + Object.defineProperty(MockEventSource, 'instances', { + get: () => instances, + }) + + Object.defineProperty(MockEventSource, 'emit', { + value: (event: Partial) => { + const latestInstance = instances[instances.length - 1] + if (latestInstance?.onmessage) { + const messageEvent = new MessageEvent('message', { + data: JSON.stringify(event), + }) + latestInstance.onmessage(messageEvent) + } + }, + }) + + Object.defineProperty(MockEventSource, 'triggerError', { + value: (error: Error) => { + const latestInstance = instances[instances.length - 1] + if (latestInstance?.onerror) { + const errorEvent = new Event('error') + Object.defineProperty(errorEvent, 'message', { value: error.message }) + latestInstance.onerror(errorEvent) + } + }, + }) + + Object.defineProperty(MockEventSource, 'triggerOpen', { + value: () => { + const latestInstance = instances[instances.length - 1] + if (latestInstance?.onopen) { + latestInstance.onopen(new Event('open')) + } + }, + }) + + // Add CONNECTING, OPEN, CLOSED constants + Object.defineProperty(MockEventSource, 'CONNECTING', { value: 0 }) + Object.defineProperty(MockEventSource, 'OPEN', { value: 1 }) + Object.defineProperty(MockEventSource, 'CLOSED', { value: 2 }) + + return MockEventSource as unknown as MockEventSourceConstructor +} diff --git a/packages/durably-react/tests/client/use-job-logs.test.tsx b/packages/durably-react/tests/client/use-job-logs.test.tsx new file mode 100644 index 00000000..20d78bb6 --- /dev/null +++ b/packages/durably-react/tests/client/use-job-logs.test.tsx @@ -0,0 +1,209 @@ +/** + * Client mode useJobLogs tests + * + * Test log subscription via SSE + */ + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useJobLogs } from '../../src/client/use-job-logs' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +describe('useJobLogs (client)', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + vi.restoreAllMocks() + }) + + it('collects logs from SSE', async () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'log-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'log-run', + level: 'info', + message: 'Log 1', + data: null, + }) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'log-run', + level: 'warn', + message: 'Log 2', + data: { warning: true }, + }) + }) + + await waitFor(() => { + expect(result.current.logs).toHaveLength(2) + expect(result.current.logs[0].message).toBe('Log 1') + expect(result.current.logs[0].level).toBe('info') + expect(result.current.logs[1].message).toBe('Log 2') + expect(result.current.logs[1].level).toBe('warn') + }) + }) + + it('handles null runId', () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: null }), + ) + + expect(result.current.logs).toHaveLength(0) + expect(mockEventSource.instances).toHaveLength(0) + }) + + it('respects maxLogs limit', async () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'max-logs-run', maxLogs: 2 }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // Emit 3 logs + for (let i = 0; i < 3; i++) { + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'max-logs-run', + level: 'info', + message: `Log ${i}`, + data: null, + }) + }) + } + + await waitFor(() => { + expect(result.current.logs).toHaveLength(2) + // First log should be removed, keep the last 2 + expect(result.current.logs[0].message).toBe('Log 1') + expect(result.current.logs[1].message).toBe('Log 2') + }) + }) + + it('clears logs on clearLogs call', async () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'clear-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'clear-run', + level: 'info', + message: 'Test log', + data: null, + }) + }) + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1) + }) + + act(() => { + result.current.clearLogs() + }) + + expect(result.current.logs).toHaveLength(0) + }) + + it('ignores logs for different runId', async () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'my-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'other-run', + level: 'info', + message: 'Wrong log', + data: null, + }) + }) + + // Wait a bit and check logs are still empty + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.logs).toHaveLength(0) + }) + + it('closes EventSource on unmount', async () => { + const closeSpy = vi.fn() + mockEventSource = createMockEventSource({ onClose: closeSpy }) + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + + const { unmount } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'unmount-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + unmount() + + expect(closeSpy).toHaveBeenCalled() + }) + + it('provides log metadata', async () => { + const { result } = renderHook(() => + useJobLogs({ api: '/api/durably', runId: 'metadata-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'metadata-run', + level: 'error', + message: 'Error occurred', + data: { code: 500 }, + }) + }) + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1) + const log = result.current.logs[0] + expect(log.id).toBeDefined() + expect(log.runId).toBe('metadata-run') + expect(log.level).toBe('error') + expect(log.message).toBe('Error occurred') + expect(log.data).toEqual({ code: 500 }) + expect(log.timestamp).toBeDefined() + }) + }) +}) diff --git a/packages/durably-react/tests/client/use-job-run.test.tsx b/packages/durably-react/tests/client/use-job-run.test.tsx new file mode 100644 index 00000000..d5afc5ec --- /dev/null +++ b/packages/durably-react/tests/client/use-job-run.test.tsx @@ -0,0 +1,355 @@ +/** + * Client mode useJobRun tests + * + * Test SSE subscription for existing runs + */ + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useJobRun } from '../../src/client/use-job-run' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +describe('useJobRun (client)', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + vi.restoreAllMocks() + }) + + it('subscribes to run via SSE', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'existing-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // Check URL is correct + expect(mockEventSource.instances[0].url).toBe( + '/api/durably/subscribe?runId=existing-run', + ) + + act(() => { + mockEventSource.emit({ type: 'run:start', runId: 'existing-run' }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('running') + expect(result.current.isRunning).toBe(true) + }) + }) + + it('does not subscribe when runId is null', () => { + renderHook(() => useJobRun({ api: '/api/durably', runId: null })) + + expect(mockEventSource.instances).toHaveLength(0) + }) + + it('provides output when run completes', async () => { + const { result } = renderHook(() => + useJobRun<{ value: number }>({ + api: '/api/durably', + runId: 'complete-run', + }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'complete-run', + output: { value: 42 }, + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ value: 42 }) + expect(result.current.isCompleted).toBe(true) + }) + }) + + it('provides error when run fails', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'fail-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:fail', + runId: 'fail-run', + error: 'Something went wrong', + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Something went wrong') + expect(result.current.isFailed).toBe(true) + }) + }) + + it('updates status when run is cancelled', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'cancel-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:cancel', + runId: 'cancel-run', + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('cancelled') + expect(result.current.isCancelled).toBe(true) + }) + }) + + it('resets status when run is retried', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'retry-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // First fail the run + act(() => { + mockEventSource.emit({ + type: 'run:fail', + runId: 'retry-run', + error: 'Something went wrong', + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Something went wrong') + }) + + // Then retry it + act(() => { + mockEventSource.emit({ + type: 'run:retry', + runId: 'retry-run', + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('pending') + expect(result.current.error).toBeNull() + expect(result.current.isPending).toBe(true) + }) + }) + + it('tracks progress updates', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'progress-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:progress', + runId: 'progress-run', + progress: { current: 5, total: 10, message: 'Processing' }, + }) + }) + + await waitFor(() => { + expect(result.current.progress).toEqual({ + current: 5, + total: 10, + message: 'Processing', + }) + }) + }) + + it('collects logs', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'log-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'log-run', + level: 'info', + message: 'Step 1 complete', + data: { step: 1 }, + }) + }) + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1) + expect(result.current.logs[0].message).toBe('Step 1 complete') + expect(result.current.logs[0].level).toBe('info') + }) + }) + + it('closes EventSource on unmount', async () => { + const closeSpy = vi.fn() + mockEventSource = createMockEventSource({ onClose: closeSpy }) + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + + const { unmount } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'unmount-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + unmount() + + expect(closeSpy).toHaveBeenCalled() + }) + + it('ignores events for different runId', async () => { + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'my-run' }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // With runId set, status starts as 'pending' until SSE events arrive + expect(result.current.status).toBe('pending') + + act(() => { + mockEventSource.emit({ type: 'run:start', runId: 'other-run' }) + }) + + // Status should remain 'pending' since event is for a different run + await new Promise((r) => setTimeout(r, 50)) + expect(result.current.status).toBe('pending') + }) + + describe('callbacks', () => { + it('fires onStart only once when transitioning from null to pending to running', async () => { + const onStart = vi.fn() + + const { result } = renderHook(() => + useJobRun({ api: '/api/durably', runId: 'callback-run', onStart }), + ) + + // Initially pending (runId exists but no SSE events yet) + await waitFor(() => { + expect(result.current.status).toBe('pending') + }) + + // onStart should have been called once for pending status + expect(onStart).toHaveBeenCalledTimes(1) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // Emit run:start to transition to running + act(() => { + mockEventSource.emit({ type: 'run:start', runId: 'callback-run' }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('running') + }) + + // onStart should NOT have been called again - still just once + expect(onStart).toHaveBeenCalledTimes(1) + }) + + it('fires onComplete when run completes', async () => { + const onComplete = vi.fn() + + const { result } = renderHook(() => + useJobRun({ + api: '/api/durably', + runId: 'complete-callback-run', + onComplete, + }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'complete-callback-run', + output: { done: true }, + }) + }) + + await waitFor(() => { + expect(result.current.isCompleted).toBe(true) + }) + + expect(onComplete).toHaveBeenCalledTimes(1) + }) + + it('fires onFail when run fails', async () => { + const onFail = vi.fn() + + const { result } = renderHook(() => + useJobRun({ + api: '/api/durably', + runId: 'fail-callback-run', + onFail, + }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:fail', + runId: 'fail-callback-run', + error: 'Test error', + }) + }) + + await waitFor(() => { + expect(result.current.isFailed).toBe(true) + }) + + expect(onFail).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/durably-react/tests/client/use-job.test.tsx b/packages/durably-react/tests/client/use-job.test.tsx new file mode 100644 index 00000000..8f2e1049 --- /dev/null +++ b/packages/durably-react/tests/client/use-job.test.tsx @@ -0,0 +1,452 @@ +/** + * Client mode useJob tests + * + * Test trigger via fetch and SSE subscription + */ + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useJob } from '../../src/client/use-job' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +describe('useJob (client)', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + let originalFetch: typeof fetch + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + originalFetch = globalThis.fetch + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('triggers via fetch', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'test-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + const { runId } = await result.current.trigger({ input: 'test' }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ jobName: 'test-job', input: { input: 'test' } }), + }), + ) + expect(runId).toBe('test-run-id') + }) + + it('subscribes via EventSource after trigger', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'sse-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + // Wait for EventSource to be created + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // Emit run:start event + act(() => { + mockEventSource.emit({ type: 'run:start', runId: 'sse-run-id' }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('running') + }) + }) + + it('updates status on run:complete', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'complete-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob<{ input: string }, { result: string }>({ + api: '/api/durably', + jobName: 'test-job', + }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'complete-run-id', + output: { result: 'done' }, + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ result: 'done' }) + expect(result.current.isCompleted).toBe(true) + }) + }) + + it('updates status on run:fail', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'fail-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:fail', + runId: 'fail-run-id', + error: 'Something went wrong', + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('failed') + expect(result.current.error).toBe('Something went wrong') + expect(result.current.isFailed).toBe(true) + }) + }) + + it('handles progress events', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'progress-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:progress', + runId: 'progress-run-id', + progress: { current: 1, total: 3 }, + }) + }) + + await waitFor(() => { + expect(result.current.progress).toEqual({ current: 1, total: 3 }) + }) + }) + + it('handles log events', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'log-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'log:write', + runId: 'log-run-id', + level: 'info', + message: 'Processing', + data: null, + }) + }) + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1) + expect(result.current.logs[0].message).toBe('Processing') + }) + }) + + it('handles connection errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'error-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.triggerError(new Error('Connection failed')) + }) + + await waitFor(() => { + expect(result.current.error).toBe('Connection failed') + }) + }) + + it('reset clears all state', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'reset-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'reset-run-id', + output: { result: 'done' }, + }) + }) + + await waitFor(() => { + expect(result.current.isCompleted).toBe(true) + }) + + act(() => { + result.current.reset() + }) + + expect(result.current.status).toBeNull() + expect(result.current.output).toBeNull() + expect(result.current.currentRunId).toBeNull() + }) + + it('provides currentRunId after trigger', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'current-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + expect(result.current.currentRunId).toBeNull() + + await result.current.trigger({ input: 'test' }) + + await waitFor(() => { + expect(result.current.currentRunId).toBe('current-run-id') + }) + }) + + it('throws on fetch error', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + text: () => Promise.resolve('Job not found'), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'unknown-job' }), + ) + + await expect(result.current.trigger({ input: 'test' })).rejects.toThrow( + 'Job not found', + ) + }) + + it('throws on fetch error with empty text', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve(''), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ api: '/api/durably', jobName: 'test-job' }), + ) + + await expect(result.current.trigger({ input: 'test' })).rejects.toThrow( + 'HTTP 500', + ) + }) + + // Note: triggerAndWait tests are difficult to test with the polling-based implementation + // because the hook needs to re-render to see the updated subscription.status. + // The triggerAndWait function is covered by the browser tests which use real React re-renders. + + describe('initialRunId', () => { + it('sets currentRunId from initialRunId', () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ + api: '/api/durably', + jobName: 'test-job', + initialRunId: 'existing-run-id', + }), + ) + + expect(result.current.currentRunId).toBe('existing-run-id') + }) + + it('subscribes to initialRunId via EventSource immediately', async () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + + renderHook(() => + useJob({ + api: '/api/durably', + jobName: 'test-job', + initialRunId: 'existing-run-id', + }), + ) + + // EventSource should be created for the initial run + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + expect(mockEventSource.instances[0].url).toContain('existing-run-id') + }) + + it('receives events for initialRunId', async () => { + const fetchMock = vi.fn() + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob<{ input: string }, { result: string }>({ + api: '/api/durably', + jobName: 'test-job', + initialRunId: 'existing-run-id', + }), + ) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + // Simulate receiving events for the existing run + act(() => { + mockEventSource.emit({ + type: 'run:progress', + runId: 'existing-run-id', + progress: { current: 5, total: 10, message: 'In progress' }, + }) + }) + + await waitFor(() => { + expect(result.current.progress).toEqual({ + current: 5, + total: 10, + message: 'In progress', + }) + }) + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'existing-run-id', + output: { result: 'reconnected' }, + }) + }) + + await waitFor(() => { + expect(result.current.status).toBe('completed') + expect(result.current.output).toEqual({ result: 'reconnected' }) + expect(result.current.isCompleted).toBe(true) + }) + }) + + it('can trigger new run after initialRunId', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ runId: 'new-run-id' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useJob({ + api: '/api/durably', + jobName: 'test-job', + initialRunId: 'existing-run-id', + }), + ) + + expect(result.current.currentRunId).toBe('existing-run-id') + + // Trigger a new run + await result.current.trigger({ input: 'new' }) + + await waitFor(() => { + expect(result.current.currentRunId).toBe('new-run-id') + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/trigger', + expect.objectContaining({ + method: 'POST', + }), + ) + }) + }) +}) diff --git a/packages/durably-react/tests/client/use-run-actions.test.tsx b/packages/durably-react/tests/client/use-run-actions.test.tsx new file mode 100644 index 00000000..9bd05ef2 --- /dev/null +++ b/packages/durably-react/tests/client/use-run-actions.test.tsx @@ -0,0 +1,417 @@ +/** + * Client mode useRunActions tests + * + * Test retry and cancel actions via fetch + */ + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRunActions } from '../../src/client/use-run-actions' + +describe('useRunActions (client)', () => { + let originalFetch: typeof fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + describe('retry', () => { + it('calls retry endpoint with runId', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + await act(async () => { + await result.current.retry('run-123') + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/retry?runId=run-123', + { method: 'POST' }, + ) + }) + + it('encodes runId in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + await act(async () => { + await result.current.retry('run/with/special&chars') + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/retry?runId=run%2Fwith%2Fspecial%26chars', + { method: 'POST' }, + ) + }) + + it('sets isLoading during request', async () => { + let resolvePromise: () => void + const fetchPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + const fetchMock = vi.fn().mockImplementation(() => + fetchPromise.then(() => ({ + ok: true, + json: () => Promise.resolve({ success: true }), + })), + ) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + expect(result.current.isLoading).toBe(false) + + let retryPromise: Promise + act(() => { + retryPromise = result.current.retry('run-123') + }) + + expect(result.current.isLoading).toBe(true) + + await act(async () => { + resolvePromise!() + await retryPromise + }) + + expect(result.current.isLoading).toBe(false) + }) + + it('sets error on failure and throws', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Not Found', + json: () => Promise.resolve({ error: 'Run not found' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.retry('run-123') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe('Run not found') + expect(result.current.error).toBe('Run not found') + expect(result.current.isLoading).toBe(false) + }) + + it('uses statusText when no error in response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: () => Promise.resolve({}), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.retry('run-123') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe( + 'Failed to retry: Internal Server Error', + ) + expect(result.current.error).toBe( + 'Failed to retry: Internal Server Error', + ) + }) + + it('handles non-JSON error response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: () => Promise.reject(new Error('Invalid JSON')), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.retry('run-123') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe( + 'Failed to retry: Internal Server Error', + ) + expect(result.current.error).toBe( + 'Failed to retry: Internal Server Error', + ) + }) + + it('clears error on new request', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + statusText: 'Error', + json: () => Promise.resolve({ error: 'First error' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + // First call fails + await act(async () => { + try { + await result.current.retry('run-123') + } catch { + // Expected + } + }) + + expect(result.current.error).toBe('First error') + + // Second call succeeds + await act(async () => { + await result.current.retry('run-123') + }) + + expect(result.current.error).toBeNull() + }) + }) + + describe('cancel', () => { + it('calls cancel endpoint with runId', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + await act(async () => { + await result.current.cancel('run-456') + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/cancel?runId=run-456', + { method: 'POST' }, + ) + }) + + it('encodes runId in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + await act(async () => { + await result.current.cancel('run/with/special&chars') + }) + + expect(fetchMock).toHaveBeenCalledWith( + '/api/durably/cancel?runId=run%2Fwith%2Fspecial%26chars', + { method: 'POST' }, + ) + }) + + it('sets isLoading during request', async () => { + let resolvePromise: () => void + const fetchPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + const fetchMock = vi.fn().mockImplementation(() => + fetchPromise.then(() => ({ + ok: true, + json: () => Promise.resolve({ success: true }), + })), + ) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + expect(result.current.isLoading).toBe(false) + + let cancelPromise: Promise + act(() => { + cancelPromise = result.current.cancel('run-456') + }) + + expect(result.current.isLoading).toBe(true) + + await act(async () => { + resolvePromise!() + await cancelPromise + }) + + expect(result.current.isLoading).toBe(false) + }) + + it('sets error on failure and throws', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Bad Request', + json: () => Promise.resolve({ error: 'Run already completed' }), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.cancel('run-456') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe('Run already completed') + expect(result.current.error).toBe('Run already completed') + expect(result.current.isLoading).toBe(false) + }) + + it('uses statusText when no error in response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: () => Promise.resolve({}), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.cancel('run-456') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe( + 'Failed to cancel: Internal Server Error', + ) + expect(result.current.error).toBe( + 'Failed to cancel: Internal Server Error', + ) + }) + + it('handles non-JSON error response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + json: () => Promise.reject(new Error('Invalid JSON')), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let thrownError: Error | undefined + await act(async () => { + try { + await result.current.cancel('run-456') + } catch (err) { + thrownError = err as Error + } + }) + + expect(thrownError?.message).toBe( + 'Failed to cancel: Internal Server Error', + ) + expect(result.current.error).toBe( + 'Failed to cancel: Internal Server Error', + ) + }) + }) + + describe('shared state', () => { + it('shares isLoading between retry and cancel', async () => { + let resolvePromise: () => void + const fetchPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + const fetchMock = vi.fn().mockImplementation(() => + fetchPromise.then(() => ({ + ok: true, + json: () => Promise.resolve({ success: true }), + })), + ) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRunActions({ api: '/api/durably' }), + ) + + let retryPromise: Promise + act(() => { + retryPromise = result.current.retry('run-123') + }) + + expect(result.current.isLoading).toBe(true) + + await act(async () => { + resolvePromise!() + await retryPromise + }) + + expect(result.current.isLoading).toBe(false) + }) + }) +}) diff --git a/packages/durably-react/tests/client/use-runs.test.tsx b/packages/durably-react/tests/client/use-runs.test.tsx new file mode 100644 index 00000000..4ab5b9e9 --- /dev/null +++ b/packages/durably-react/tests/client/use-runs.test.tsx @@ -0,0 +1,447 @@ +/** + * Client mode useRuns tests + * + * Test runs listing via fetch and SSE subscription + */ + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRuns, type ClientRun } from '../../src/client/use-runs' +import { + createMockEventSource, + type MockEventSourceConstructor, +} from './mock-event-source' + +const createMockRun = (overrides: Partial = {}): ClientRun => ({ + id: 'run-1', + jobName: 'test-job', + status: 'pending', + input: { value: 1 }, + output: null, + error: null, + currentStepIndex: 0, + stepCount: 0, + progress: null, + createdAt: '2024-01-01T00:00:00.000Z', + startedAt: null, + completedAt: null, + ...overrides, +}) + +describe('useRuns (client)', () => { + let mockEventSource: MockEventSourceConstructor + let originalEventSource: typeof EventSource + let originalFetch: typeof fetch + + beforeEach(() => { + mockEventSource = createMockEventSource() + originalEventSource = globalThis.EventSource + originalFetch = globalThis.fetch + globalThis.EventSource = mockEventSource as unknown as typeof EventSource + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + it('fetches runs on mount', async () => { + const mockRuns = [ + createMockRun({ id: 'run-1' }), + createMockRun({ id: 'run-2' }), + ] + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRuns), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/durably/runs?'), + ) + expect(result.current.runs).toHaveLength(2) + expect(result.current.runs[0].id).toBe('run-1') + }) + + it('filters by jobName', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably', jobName: 'my-job' })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled() + }) + + const url = fetchMock.mock.calls[0][0] as string + expect(url).toContain('jobName=my-job') + }) + + it('filters by status', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably', status: 'completed' })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled() + }) + + const url = fetchMock.mock.calls[0][0] as string + expect(url).toContain('status=completed') + }) + + it('handles pagination', async () => { + const page1Runs = [ + createMockRun({ id: 'run-1' }), + createMockRun({ id: 'run-2' }), + createMockRun({ id: 'run-3' }), // Extra item indicates hasMore + ] + const page2Runs = [createMockRun({ id: 'run-4' })] + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(page1Runs), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(page2Runs), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRuns({ api: '/api/durably', pageSize: 2 }), + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.runs).toHaveLength(2) + expect(result.current.hasMore).toBe(true) + expect(result.current.page).toBe(0) + + // Go to next page + act(() => { + result.current.nextPage() + }) + + await waitFor(() => { + expect(result.current.page).toBe(1) + }) + + await waitFor(() => { + expect(result.current.runs[0].id).toBe('run-4') + }) + }) + + it('subscribes to SSE on first page', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + expect(mockEventSource.instances[0].url).toContain( + '/api/durably/runs/subscribe', + ) + }) + + it('includes jobName filter in SSE URL', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably', jobName: 'my-job' })) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + expect(mockEventSource.instances[0].url).toContain('jobName=my-job') + }) + + it('refreshes on run:start event', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + const initialCallCount = fetchMock.mock.calls.length + + act(() => { + mockEventSource.emit({ + type: 'run:start', + runId: 'new-run', + jobName: 'test-job', + }) + }) + + await waitFor(() => { + expect(fetchMock.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + }) + + it('refreshes on run:complete event', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + const initialCallCount = fetchMock.mock.calls.length + + act(() => { + mockEventSource.emit({ + type: 'run:complete', + runId: 'run-1', + jobName: 'test-job', + }) + }) + + await waitFor(() => { + expect(fetchMock.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + }) + + it('refreshes on run:fail event', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + const initialCallCount = fetchMock.mock.calls.length + + act(() => { + mockEventSource.emit({ + type: 'run:fail', + runId: 'run-1', + jobName: 'test-job', + }) + }) + + await waitFor(() => { + expect(fetchMock.mock.calls.length).toBeGreaterThan(initialCallCount) + }) + }) + + it('updates progress in place on run:progress event', async () => { + const mockRuns = [ + createMockRun({ id: 'run-1', status: 'running' }), + createMockRun({ id: 'run-2', status: 'running' }), + ] + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockRuns), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(result.current.runs).toHaveLength(2) + }) + + await waitFor(() => { + expect(mockEventSource.instances.length).toBeGreaterThan(0) + }) + + const callCountBeforeProgress = fetchMock.mock.calls.length + + act(() => { + mockEventSource.emit({ + type: 'run:progress', + runId: 'run-1', + jobName: 'test-job', + progress: { current: 5, total: 10, message: 'Processing...' }, + }) + }) + + await waitFor(() => { + expect(result.current.runs[0].progress).toEqual({ + current: 5, + total: 10, + message: 'Processing...', + }) + }) + + // Verify no fetch was triggered for progress update + expect(fetchMock.mock.calls.length).toBe(callCountBeforeProgress) + + // Other runs should not be affected + expect(result.current.runs[1].progress).toBeNull() + }) + + it('does not subscribe to SSE on non-first pages', async () => { + const page1Runs = [ + createMockRun({ id: 'run-1' }), + createMockRun({ id: 'run-2' }), + createMockRun({ id: 'run-3' }), // Extra for hasMore + ] + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(page1Runs), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => + useRuns({ api: '/api/durably', pageSize: 2 }), + ) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + const instancesOnPage0 = mockEventSource.instances.length + + act(() => { + result.current.nextPage() + }) + + await waitFor(() => { + expect(result.current.page).toBe(1) + }) + + // EventSource should be closed, no new instances created + // (actually the old one is closed but counts remain) + expect(mockEventSource.instances[instancesOnPage0 - 1].readyState).toBe(2) // CLOSED + }) + + it('handles fetch errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(result.current.error).toBe( + 'Failed to fetch runs: Internal Server Error', + ) + }) + + expect(result.current.isLoading).toBe(false) + expect(result.current.runs).toEqual([]) + }) + + it('refresh reloads data', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([createMockRun({ id: 'run-1' })]), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + createMockRun({ id: 'run-1' }), + createMockRun({ id: 'run-2' }), + ]), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(result.current.runs).toHaveLength(1) + }) + + await act(async () => { + await result.current.refresh() + }) + + expect(result.current.runs).toHaveLength(2) + }) + + it('goToPage navigates directly', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + act(() => { + result.current.goToPage(5) + }) + + await waitFor(() => { + expect(result.current.page).toBe(5) + }) + }) + + it('prevPage does not go below 0', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }) + globalThis.fetch = fetchMock + + const { result } = renderHook(() => useRuns({ api: '/api/durably' })) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.page).toBe(0) + + act(() => { + result.current.prevPage() + }) + + expect(result.current.page).toBe(0) + }) +}) diff --git a/packages/durably-react/tests/helpers/browser-dialect.ts b/packages/durably-react/tests/helpers/browser-dialect.ts new file mode 100644 index 00000000..0d57c9b5 --- /dev/null +++ b/packages/durably-react/tests/helpers/browser-dialect.ts @@ -0,0 +1,10 @@ +import { SQLocalKysely } from 'sqlocal/kysely' + +let counter = 0 + +export function createBrowserDialect() { + // Use unique DB name for each test (parallel test isolation) + const dbName = `test-${Date.now()}-${counter++}.sqlite3` + const { dialect } = new SQLocalKysely(dbName) + return dialect +} diff --git a/packages/durably-react/tests/helpers/create-test-durably.ts b/packages/durably-react/tests/helpers/create-test-durably.ts new file mode 100644 index 00000000..5540ced2 --- /dev/null +++ b/packages/durably-react/tests/helpers/create-test-durably.ts @@ -0,0 +1,40 @@ +import { createDurably, type Durably } from '@coji/durably' +import { createBrowserDialect } from './browser-dialect' + +export interface TestDurablyOptions { + pollingInterval?: number + autoMigrate?: boolean + /** + * Whether to start the worker. When false, only migrate() is called. + * @default true + */ + autoStart?: boolean +} + +/** + * Create a Durably instance for testing. + * The instance is initialized (migrate + start) unless autoMigrate is false. + */ +export async function createTestDurably( + options?: TestDurablyOptions, +): Promise { + const dialect = createBrowserDialect() + const durably = createDurably({ + dialect, + pollingInterval: options?.pollingInterval ?? 100, + heartbeatInterval: 500, + staleThreshold: 3000, + }) + + if (options?.autoMigrate !== false) { + if (options?.autoStart === false) { + // Only migrate, don't start the worker + await durably.migrate() + } else { + // Default: init() = migrate() + start() + await durably.init() + } + } + + return durably +} diff --git a/packages/durably-react/tests/types.test.ts b/packages/durably-react/tests/types.test.ts new file mode 100644 index 00000000..bb429e71 --- /dev/null +++ b/packages/durably-react/tests/types.test.ts @@ -0,0 +1,136 @@ +/** + * Type inference tests + * + * Verify that types are correctly inferred from job definitions + * + * These tests use vitest's expectTypeOf to verify compile-time type inference + * rather than runtime behavior. + */ + +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' + +// Test job definitions +const typedJob = defineJob({ + name: 'typed-job', + input: z.object({ taskId: z.string() }), + output: z.object({ success: z.boolean() }), + run: async (_ctx, payload) => ({ success: payload.taskId.length > 0 }), +}) + +const voidOutputJob = defineJob({ + name: 'void-output-job', + input: z.object({ value: z.number() }), + run: async () => {}, +}) + +describe('Type inference', () => { + describe('useJob', () => { + it('infers correct return type', () => { + type Result = UseJobResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toEqualTypeOf< + 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null + >() + expectTypeOf().toEqualTypeOf<{ + success: boolean + } | null>() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) + + it('trigger accepts TInput and returns Promise<{ runId: string }>', () => { + type Result = UseJobResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toBeFunction() + expectTypeOf().parameter(0).toEqualTypeOf<{ + taskId: string + }>() + expectTypeOf().returns.toEqualTypeOf< + Promise<{ runId: string }> + >() + }) + + it('triggerAndWait returns Promise with typed output', () => { + type Result = UseJobResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toBeFunction() + expectTypeOf().parameter(0).toEqualTypeOf<{ + taskId: string + }>() + expectTypeOf().returns.toEqualTypeOf< + Promise<{ runId: string; output: { success: boolean } }> + >() + }) + + it('reset is a function with no arguments', () => { + type Result = UseJobResult<{ taskId: string }, { success: boolean }> + + expectTypeOf().toBeFunction() + expectTypeOf().returns.toEqualTypeOf() + }) + }) + + describe('useJobRun', () => { + it('infers output type from generic', () => { + type Result = UseJobRunResult<{ data: number[] }> + + expectTypeOf().toEqualTypeOf<{ + data: number[] + } | null>() + expectTypeOf().toEqualTypeOf< + 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null + >() + expectTypeOf().toEqualTypeOf() + }) + + it('defaults to unknown output type', () => { + type Result = UseJobRunResult + + expectTypeOf().toEqualTypeOf() + }) + }) + + describe('useJobLogs', () => { + it('returns logs array and clearLogs function', () => { + type Result = UseJobLogsResult + + expectTypeOf().toBeArray() + expectTypeOf().toBeFunction() + }) + }) + + describe('Job definition type inference', () => { + it('useJob infers types from job definition', () => { + // This is a compile-time test - if it compiles, types are correct + // In actual usage, the hook would be called inside a React component + + // Verify the job definition has correct types + expectTypeOf(typedJob.name).toEqualTypeOf<'typed-job'>() + + // The input schema should accept the correct type + expectTypeOf(typedJob.input.parse({ taskId: 'test' })).toEqualTypeOf<{ + taskId: string + }>() + + // The output schema should accept the correct type + expectTypeOf(typedJob.output?.parse({ success: true })).toEqualTypeOf< + { success: boolean } | undefined + >() + }) + + it('handles void output jobs', () => { + // Void output job returns void, so no output schema is defined + // We just verify the job compiles correctly without explicit output + expectTypeOf(voidOutputJob.name).toEqualTypeOf<'void-output-job'>() + }) + }) +}) diff --git a/packages/durably-react/tsconfig.json b/packages/durably-react/tsconfig.json new file mode 100644 index 00000000..d60b2f41 --- /dev/null +++ b/packages/durably-react/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/durably-react/tsup.config.ts b/packages/durably-react/tsup.config.ts new file mode 100644 index 00000000..b05419bf --- /dev/null +++ b/packages/durably-react/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + client: 'src/client.ts', + }, + format: ['esm'], + dts: true, + clean: true, + sourcemap: true, + external: ['react', 'react-dom', '@coji/durably'], +}) diff --git a/packages/durably-react/vitest.config.ts b/packages/durably-react/vitest.config.ts new file mode 100644 index 00000000..b446a594 --- /dev/null +++ b/packages/durably-react/vitest.config.ts @@ -0,0 +1,48 @@ +import react from '@vitejs/plugin-react' +import { playwright } from '@vitest/browser-playwright' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [ + react(), + // COOP/COEP headers for OPFS support + { + name: 'configure-response-headers', + configureServer: (server) => { + server.middlewares.use((_req, res, next) => { + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + next() + }) + }, + }, + ], + test: { + include: ['tests/**/*.test.tsx', 'tests/**/*.test.ts'], + retry: 2, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + headless: true, + }, + coverage: { + enabled: true, + provider: 'v8', + include: ['src/**/*.ts', 'src/**/*.tsx'], + exclude: ['src/**/*.d.ts', 'src/index.ts', 'src/client.ts'], + reporter: ['text', 'text-summary'], + }, + }, + optimizeDeps: { + exclude: ['sqlocal'], + include: [ + 'react', + 'react-dom', + '@testing-library/react', + 'zod', + 'kysely', + 'ulidx', + ], + }, +}) diff --git a/packages/durably/README.md b/packages/durably/README.md index ebfa0fe5..d842173c 100644 --- a/packages/durably/README.md +++ b/packages/durably/README.md @@ -4,65 +4,36 @@ Step-oriented resumable batch execution for Node.js and browsers using SQLite. **[Documentation](https://coji.github.io/durably/)** | **[GitHub](https://github.com/coji/durably)** | **[Live Demo](https://durably-demo.vercel.app)** -## Features - -- Resumable batch processing with step-level persistence -- Works in both Node.js and browsers -- Uses SQLite for state management (better-sqlite3/libsql for Node.js, SQLite WASM for browsers) -- Minimal dependencies - just Kysely and Zod as peer dependencies -- Event system for monitoring and extensibility -- Type-safe input/output with Zod schemas +> **Note:** This package is ESM-only. CommonJS is not supported. ## Installation ```bash -# Node.js with better-sqlite3 npm install @coji/durably kysely zod better-sqlite3 - -# Node.js with libsql -npm install @coji/durably kysely zod @libsql/client @libsql/kysely-libsql - -# Browser with SQLocal -npm install @coji/durably kysely zod sqlocal ``` -## Usage +See the [Getting Started Guide](https://coji.github.io/durably/guide/getting-started) for other SQLite backends (libsql, SQLocal for browsers). + +## Quick Start ```ts -import { createDurably } from '@coji/durably' -import SQLite from 'better-sqlite3' -import { SqliteDialect } from 'kysely' +import { createDurably, defineJob } from '@coji/durably' import { z } from 'zod' -const dialect = new SqliteDialect({ - database: new SQLite('local.db'), -}) - -const durably = createDurably({ dialect }) - -const syncUsers = durably.defineJob( - { - name: 'sync-users', - input: z.object({ orgId: z.string() }), - output: z.object({ syncedCount: z.number() }), - }, - async (step, payload) => { - const users = await step.run('fetch-users', async () => { - return api.fetchUsers(payload.orgId) - }) - - await step.run('save-to-db', async () => { - await db.upsertUsers(users) +const myJob = defineJob({ + name: 'my-job', + input: z.object({ id: z.string() }), + run: async (step, payload) => { + await step.run('step-1', async () => { + /* ... */ }) - - return { syncedCount: users.length } }, -) +}) -await durably.migrate() -durably.start() +const durably = createDurably({ dialect }).register({ myJob }) -await syncUsers.trigger({ orgId: 'org_123' }) +await durably.init() // migrate + start +await durably.jobs.myJob.trigger({ id: '123' }) ``` ## Documentation diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index dba8d90f..5d5404a4 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -10,10 +10,10 @@ Durably is a minimal workflow engine that persists step results to SQLite. If a ```bash # Node.js with libsql (recommended) -npm install @coji/durably kysely zod @libsql/client @libsql/kysely-libsql +pnpm add @coji/durably kysely zod @libsql/client @libsql/kysely-libsql # Browser with SQLocal -npm install @coji/durably kysely zod sqlocal +pnpm add @coji/durably kysely zod sqlocal ``` ## Core Concepts @@ -61,18 +61,21 @@ const syncUsersJob = defineJob({ }, }) -// Register the job with durably instance -const syncUsers = durably.register(syncUsersJob) +// Register jobs with durably instance +const { syncUsers } = durably.register({ + syncUsers: syncUsersJob, +}) ``` ### 3. Starting the Worker ```ts -// Run migrations (creates tables if needed) -await durably.migrate() +// Initialize: runs migrations and starts the worker +await durably.init() -// Start the worker (polls for pending jobs) -durably.start() +// Or separately if needed: +// await durably.migrate() // Run migrations only +// durably.start() // Start worker only ``` ### 4. Triggering Jobs @@ -184,28 +187,134 @@ await durably.deleteRun(runId) Subscribe to job execution events: ```ts +// Run lifecycle events +durably.on('run:trigger', (e) => console.log('Triggered:', e.runId)) durably.on('run:start', (e) => console.log('Started:', e.runId)) durably.on('run:complete', (e) => console.log('Done:', e.output)) durably.on('run:fail', (e) => console.error('Failed:', e.error)) +durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId)) +durably.on('run:retry', (e) => console.log('Retried:', e.runId)) durably.on('run:progress', (e) => console.log('Progress:', e.progress.current, '/', e.progress.total), ) +// Step events durably.on('step:start', (e) => console.log('Step:', e.stepName)) durably.on('step:complete', (e) => console.log('Step done:', e.stepName)) -durably.on('step:skip', (e) => - console.log('Step skipped (cached):', e.stepName), -) +durably.on('step:fail', (e) => console.error('Step failed:', e.stepName)) +// Log events durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message)) ``` +## Advanced APIs + +### getJob + +Get a registered job by name: + +```ts +const job = durably.getJob('sync-users') +if (job) { + const run = await job.trigger({ orgId: 'org_123' }) +} +``` + +### subscribe + +Subscribe to events for a specific run as a ReadableStream: + +```ts +const stream = durably.subscribe(runId) +const reader = stream.getReader() + +while (true) { + const { done, value } = await reader.read() + if (done) break + + switch (value.type) { + case 'run:start': + console.log('Started') + break + case 'run:complete': + console.log('Completed:', value.output) + break + case 'run:fail': + console.error('Failed:', value.error) + break + case 'run:progress': + console.log('Progress:', value.progress) + break + case 'log:write': + console.log(`[${value.level}]`, value.message) + break + } +} +``` + +### createDurablyHandler + +Create HTTP handlers for client/server architecture using Web Standard Request/Response: + +```ts +import { createDurablyHandler } from '@coji/durably' + +const handler = createDurablyHandler(durably) + +// Use the unified handle() method with automatic routing +app.all('/api/durably/*', async (req) => { + return await handler.handle(req, '/api/durably') +}) + +// Or use individual endpoints +app.post('/api/durably/trigger', (req) => handler.trigger(req)) +app.get('/api/durably/subscribe', (req) => handler.subscribe(req)) +app.get('/api/durably/runs', (req) => handler.runs(req)) +app.get('/api/durably/run', (req) => handler.run(req)) +app.get('/api/durably/steps', (req) => handler.steps(req)) +app.get('/api/durably/runs/subscribe', (req) => handler.runsSubscribe(req)) +app.post('/api/durably/retry', (req) => handler.retry(req)) +app.post('/api/durably/cancel', (req) => handler.cancel(req)) +app.delete('/api/durably/run', (req) => handler.delete(req)) +``` + +**Handler Interface:** + +```ts +interface DurablyHandler { + // Unified routing handler + handle(request: Request, basePath: string): Promise + + // Individual endpoints + trigger(request: Request): Promise // POST /trigger + subscribe(request: Request): Response // GET /subscribe?runId=xxx (SSE) + runs(request: Request): Promise // GET /runs + run(request: Request): Promise // GET /run?runId=xxx + steps(request: Request): Promise // GET /steps?runId=xxx + runsSubscribe(request: Request): Response // GET /runs/subscribe (SSE) + retry(request: Request): Promise // POST /retry?runId=xxx + cancel(request: Request): Promise // POST /cancel?runId=xxx + delete(request: Request): Promise // DELETE /run?runId=xxx +} + +interface TriggerRequest { + jobName: string + input: Record + idempotencyKey?: string + concurrencyKey?: string +} + +interface TriggerResponse { + runId: string +} +``` + ## Plugins ### Log Persistence ```ts -import { withLogPersistence } from '@coji/durably/plugins' +import { withLogPersistence } from '@coji/durably' durably.use(withLogPersistence()) ``` @@ -227,18 +336,18 @@ const durably = createDurably({ }) // Same API as Node.js -const myJob = durably.register( - defineJob({ +const { myJob } = durably.register({ + myJob: defineJob({ name: 'my-job', input: z.object({}), run: async (step) => { /* ... */ }, }), -) +}) -await durably.migrate() -durably.start() +// Initialize (same as Node.js) +await durably.init() ``` ## Run Lifecycle diff --git a/packages/durably/package.json b/packages/durably/package.json index 87407805..5725000a 100644 --- a/packages/durably/package.json +++ b/packages/durably/package.json @@ -1,6 +1,6 @@ { "name": "@coji/durably", - "version": "0.5.0", + "version": "0.6.0", "description": "Step-oriented resumable batch execution for Node.js and browsers using SQLite", "type": "module", "main": "./dist/index.js", @@ -53,8 +53,8 @@ }, "homepage": "https://github.com/coji/durably#readme", "peerDependencies": { - "kysely": ">=0.27.0", - "zod": ">=4.0.0" + "kysely": "^0.27.0", + "zod": "^4.0.0" }, "dependencies": { "ulidx": "^2.4.1" @@ -80,6 +80,6 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.16", - "zod": "^4.2.1" + "zod": "^4.3.4" } } diff --git a/packages/durably/src/define-job.ts b/packages/durably/src/define-job.ts index e6f4a6bb..2aea0103 100644 --- a/packages/durably/src/define-job.ts +++ b/packages/durably/src/define-job.ts @@ -20,6 +20,26 @@ export interface JobDefinition { readonly run: JobRunFunction } +/** + * Extract input type from a JobDefinition + * @example + * ```ts + * type Input = JobInput // { userId: string } + * ``` + */ +export type JobInput = + T extends JobDefinition ? TInput : never + +/** + * Extract output type from a JobDefinition + * @example + * ```ts + * type Output = JobOutput // { count: number } + * ``` + */ +export type JobOutput = + T extends JobDefinition ? TOutput : never + /** * Configuration for defining a job */ diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index a5f9ca86..1c341ede 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -3,13 +3,20 @@ import { Kysely } from 'kysely' import type { JobDefinition } from './define-job' import { type AnyEventInput, + type DurablyEvent, type ErrorHandler, + type EventEmitter, type EventListener, type EventType, type Unsubscribe, createEventEmitter, } from './events' -import { type JobHandle, createJobHandle, createJobRegistry } from './job' +import { + type JobHandle, + type JobRegistry, + createJobHandle, + createJobRegistry, +} from './job' import { runMigrations } from './migrations' import type { Database } from './schema' import { @@ -18,7 +25,7 @@ import { type Storage, createKyselyStorage, } from './storage' -import { createWorker } from './worker' +import { type Worker, createWorker } from './worker' /** * Options for creating a Durably instance @@ -44,13 +51,51 @@ const DEFAULTS = { */ export interface DurablyPlugin { name: string - install(durably: Durably): void + // biome-ignore lint/suspicious/noExplicitAny: plugin needs to accept any Durably instance + install(durably: Durably): void +} + +/** + * Helper type to transform JobDefinition record to JobHandle record + */ +type TransformToHandles< + TJobs extends Record>, +> = { + [K in keyof TJobs]: TJobs[K] extends JobDefinition< + infer TName, + infer TInput, + infer TOutput + > + ? JobHandle + : never } /** - * Durably instance + * Durably instance with type-safe jobs */ -export interface Durably { +export interface Durably< + TJobs extends Record> = Record< + string, + never + >, +> { + /** + * Registered job handles (type-safe) + */ + readonly jobs: TJobs + + /** + * Initialize Durably: run migrations and start the worker + * This is the recommended way to start Durably. + * Equivalent to calling migrate() then start(). + * @example + * ```ts + * const durably = createDurably({ dialect }).register({ ... }) + * await durably.init() + * ``` + */ + init(): Promise + /** * Run database migrations * This is idempotent and safe to call multiple times @@ -85,13 +130,22 @@ export interface Durably { onError(handler: ErrorHandler): void /** - * Register a job definition and return a job handle - * Same JobDefinition can be registered multiple times (idempotent) - * Different JobDefinitions with the same name will throw an error + * Register job definitions and return a new Durably instance with type-safe jobs + * @example + * ```ts + * const durably = createDurably({ dialect }) + * .register({ + * importCsv: importCsvJob, + * syncUsers: syncUsersJob, + * }) + * await durably.migrate() + * // Usage: durably.jobs.importCsv.trigger({ rows: [...] }) + * ``` */ - register( - jobDef: JobDefinition, - ): JobHandle + // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions + register>>( + jobDefs: TNewJobs, + ): Durably> /** * Start the worker polling loop @@ -135,41 +189,74 @@ export interface Durably { * Register a plugin */ use(plugin: DurablyPlugin): void + + /** + * Get a registered job handle by name + * Returns undefined if job is not registered + */ + getJob( + name: TName, + ): JobHandle, unknown> | undefined + + /** + * Subscribe to events for a specific run + * Returns a ReadableStream that can be used for SSE + */ + subscribe(runId: string): ReadableStream } /** - * Create a Durably instance + * Internal state shared across Durably instances */ -export function createDurably(options: DurablyOptions): Durably { - const config = { - pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval, - heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval, - staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold, - } - - const db = new Kysely({ dialect: options.dialect }) - const storage = createKyselyStorage(db) - const eventEmitter = createEventEmitter() - const jobRegistry = createJobRegistry() - const worker = createWorker(config, storage, eventEmitter, jobRegistry) +interface DurablyState { + db: Kysely + storage: Storage + eventEmitter: EventEmitter + jobRegistry: JobRegistry + worker: Worker + migrating: Promise | null + migrated: boolean +} - // Track migration state for idempotency - let migrating: Promise | null = null - let migrated = false +/** + * Create a Durably instance implementation + */ +function createDurablyInstance< + TJobs extends Record>, +>(state: DurablyState, jobs: TJobs): Durably { + const { db, storage, eventEmitter, jobRegistry, worker } = state - const durably: Durably = { + const durably: Durably = { db, storage, + jobs, on: eventEmitter.on, emit: eventEmitter.emit, onError: eventEmitter.onError, start: worker.start, stop: worker.stop, - register( - jobDef: JobDefinition, - ): JobHandle { - return createJobHandle(jobDef, storage, eventEmitter, jobRegistry) + // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions + register>>( + jobDefs: TNewJobs, + ): Durably> { + const newHandles = {} as TransformToHandles + + for (const key of Object.keys(jobDefs) as (keyof TNewJobs)[]) { + const jobDef = jobDefs[key] + const handle = createJobHandle( + jobDef, + storage, + eventEmitter, + jobRegistry, + ) + newHandles[key] = handle as TransformToHandles[typeof key] + } + + // Create new instance with merged jobs + const mergedJobs = { ...jobs, ...newHandles } as TJobs & + TransformToHandles + return createDurablyInstance(state, mergedJobs) }, getRun: storage.getRun, @@ -179,6 +266,128 @@ export function createDurably(options: DurablyOptions): Durably { plugin.install(durably) }, + getJob( + name: TName, + ): JobHandle, unknown> | undefined { + const registeredJob = jobRegistry.get(name) + if (!registeredJob) { + return undefined + } + return registeredJob.handle as JobHandle< + TName, + Record, + unknown + > + }, + + subscribe(runId: string): ReadableStream { + // Track closed state and cleanup function in outer scope for cancel handler + let closed = false + let cleanup: (() => void) | null = null + + return new ReadableStream({ + start: (controller) => { + const unsubscribeStart = eventEmitter.on('run:start', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }) + + const unsubscribeComplete = eventEmitter.on( + 'run:complete', + (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + closed = true + cleanup?.() + controller.close() + } + }, + ) + + const unsubscribeFail = eventEmitter.on('run:fail', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + // Don't close stream on fail - retry is possible + } + }) + + const unsubscribeCancel = eventEmitter.on('run:cancel', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + // Don't close stream on cancel - retry is possible + } + }) + + const unsubscribeRetry = eventEmitter.on('run:retry', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }) + + const unsubscribeProgress = eventEmitter.on( + 'run:progress', + (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }, + ) + + const unsubscribeStepStart = eventEmitter.on( + 'step:start', + (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }, + ) + + const unsubscribeStepComplete = eventEmitter.on( + 'step:complete', + (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }, + ) + + const unsubscribeStepFail = eventEmitter.on('step:fail', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }) + + const unsubscribeLog = eventEmitter.on('log:write', (event) => { + if (!closed && event.runId === runId) { + controller.enqueue(event) + } + }) + + // Assign cleanup function to outer scope for cancel handler + cleanup = () => { + unsubscribeStart() + unsubscribeComplete() + unsubscribeFail() + unsubscribeCancel() + unsubscribeRetry() + unsubscribeProgress() + unsubscribeStepStart() + unsubscribeStepComplete() + unsubscribeStepFail() + unsubscribeLog() + } + }, + cancel: () => { + // Clean up event listeners when stream is cancelled by consumer + if (!closed) { + closed = true + cleanup?.() + } + }, + }) + }, + async retry(runId: string): Promise { const run = await storage.getRun(runId) if (!run) { @@ -193,11 +402,17 @@ export function createDurably(options: DurablyOptions): Durably { if (run.status === 'running') { throw new Error(`Cannot retry running run: ${runId}`) } - // Only failed runs can be retried await storage.updateRun(runId, { status: 'pending', error: null, }) + + // Emit run:retry event + eventEmitter.emit({ + type: 'run:retry', + runId, + jobName: run.jobName, + }) }, async cancel(runId: string): Promise { @@ -214,10 +429,16 @@ export function createDurably(options: DurablyOptions): Durably { if (run.status === 'cancelled') { throw new Error(`Cannot cancel already cancelled run: ${runId}`) } - // pending or running can be cancelled await storage.updateRun(runId, { status: 'cancelled', }) + + // Emit run:cancel event + eventEmitter.emit({ + type: 'run:cancel', + runId, + jobName: run.jobName, + }) }, async deleteRun(runId: string): Promise { @@ -231,33 +452,65 @@ export function createDurably(options: DurablyOptions): Durably { if (run.status === 'running') { throw new Error(`Cannot delete running run: ${runId}`) } - // completed, failed, or cancelled can be deleted await storage.deleteRun(runId) }, async migrate(): Promise { - // Already migrated - if (migrated) { + if (state.migrated) { return } - // Migration in progress, wait for it - if (migrating) { - return migrating + if (state.migrating) { + return state.migrating } - // Start migration - migrating = runMigrations(db) + state.migrating = runMigrations(db) .then(() => { - migrated = true + state.migrated = true }) .finally(() => { - migrating = null + state.migrating = null }) - return migrating + return state.migrating + }, + + async init(): Promise { + await this.migrate() + this.start() }, } return durably } + +/** + * Create a Durably instance + */ +export function createDurably( + options: DurablyOptions, +): Durably> { + const config = { + pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval, + heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval, + staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold, + } + + const db = new Kysely({ dialect: options.dialect }) + const storage = createKyselyStorage(db) + const eventEmitter = createEventEmitter() + const jobRegistry = createJobRegistry() + const worker = createWorker(config, storage, eventEmitter, jobRegistry) + + const state: DurablyState = { + db, + storage, + eventEmitter, + jobRegistry, + worker, + migrating: null, + migrated: false, + } + + return createDurablyInstance(state, {}) +} diff --git a/packages/durably/src/events.ts b/packages/durably/src/events.ts index eb78b1a9..fb53eea0 100644 --- a/packages/durably/src/events.ts +++ b/packages/durably/src/events.ts @@ -7,6 +7,16 @@ export interface BaseEvent { sequence: number } +/** + * Run trigger event (emitted when a job is triggered, before worker picks it up) + */ +export interface RunTriggerEvent extends BaseEvent { + type: 'run:trigger' + runId: string + jobName: string + payload: unknown +} + /** * Run start event */ @@ -39,6 +49,24 @@ export interface RunFailEvent extends BaseEvent { failedStepName: string } +/** + * Run cancel event + */ +export interface RunCancelEvent extends BaseEvent { + type: 'run:cancel' + runId: string + jobName: string +} + +/** + * Run retry event (emitted when a failed run is retried) + */ +export interface RunRetryEvent extends BaseEvent { + type: 'run:retry' + runId: string + jobName: string +} + /** * Run progress event */ @@ -111,9 +139,12 @@ export interface WorkerErrorEvent extends BaseEvent { * All event types as discriminated union */ export type DurablyEvent = + | RunTriggerEvent | RunStartEvent | RunCompleteEvent | RunFailEvent + | RunCancelEvent + | RunRetryEvent | RunProgressEvent | StepStartEvent | StepCompleteEvent @@ -146,9 +177,12 @@ export type EventInput = Omit< * All possible event inputs as a union (properly distributed) */ export type AnyEventInput = + | EventInput<'run:trigger'> | EventInput<'run:start'> | EventInput<'run:complete'> | EventInput<'run:fail'> + | EventInput<'run:cancel'> + | EventInput<'run:retry'> | EventInput<'run:progress'> | EventInput<'step:start'> | EventInput<'step:complete'> diff --git a/packages/durably/src/index.ts b/packages/durably/src/index.ts index 407f1245..5b951e3f 100644 --- a/packages/durably/src/index.ts +++ b/packages/durably/src/index.ts @@ -8,7 +8,7 @@ export type { Durably, DurablyOptions, DurablyPlugin } from './durably' // Job Definition export { defineJob } from './define-job' -export type { JobDefinition } from './define-job' +export type { JobDefinition, JobInput, JobOutput } from './define-job' // Plugins export { withLogPersistence } from './plugins/log-persistence' @@ -46,3 +46,7 @@ export type { Log, Run, RunFilter, Step } from './storage' // Errors export { CancelledError } from './errors' + +// Server +export { createDurablyHandler } from './server' +export type { DurablyHandler, TriggerRequest, TriggerResponse } from './server' diff --git a/packages/durably/src/job.ts b/packages/durably/src/job.ts index fb617f85..f453b6f6 100644 --- a/packages/durably/src/job.ts +++ b/packages/durably/src/job.ts @@ -54,8 +54,12 @@ export interface TriggerOptions { * Run filter options */ export interface RunFilter { - status?: 'pending' | 'running' | 'completed' | 'failed' + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' jobName?: string + /** Maximum number of runs to return */ + limit?: number + /** Number of runs to skip (for pagination) */ + offset?: number } /** @@ -178,7 +182,7 @@ export function createJobRegistry(): JobRegistry { export function createJobHandle( jobDef: JobDefinition, storage: Storage, - _eventEmitter: EventEmitter, + eventEmitter: EventEmitter, registry: JobRegistry, ): JobHandle { // Check if same JobDefinition is already registered (idempotent) @@ -218,6 +222,14 @@ export function createJobHandle( concurrencyKey: options?.concurrencyKey, }) + // Emit run:trigger event + eventEmitter.emit({ + type: 'run:trigger', + runId: run.id, + jobName: jobDef.name, + payload: parseResult.data, + }) + return run as TypedRun }, @@ -243,20 +255,17 @@ export function createJobHandle( } } - const unsubscribeComplete = _eventEmitter.on( - 'run:complete', - (event) => { - if (event.runId === run.id && !resolved) { - cleanup() - resolve({ - id: run.id, - output: event.output as TOutput, - }) - } - }, - ) + const unsubscribeComplete = eventEmitter.on('run:complete', (event) => { + if (event.runId === run.id && !resolved) { + cleanup() + resolve({ + id: run.id, + output: event.output as TOutput, + }) + } + }) - const unsubscribeFail = _eventEmitter.on('run:fail', (event) => { + const unsubscribeFail = eventEmitter.on('run:fail', (event) => { if (event.runId === run.id && !resolved) { cleanup() reject(new Error(event.error)) @@ -333,6 +342,16 @@ export function createJobHandle( })), ) + // Emit run:trigger events for all created runs + for (let i = 0; i < runs.length; i++) { + eventEmitter.emit({ + type: 'run:trigger', + runId: runs[i].id, + jobName: jobDef.name, + payload: validated[i].payload, + }) + } + return runs as TypedRun[] }, diff --git a/packages/durably/src/server.ts b/packages/durably/src/server.ts new file mode 100644 index 00000000..318bf55b --- /dev/null +++ b/packages/durably/src/server.ts @@ -0,0 +1,640 @@ +import type { Durably } from './durably' +import type { AnyEventInput } from './events' + +/** + * Request body for triggering a job + */ +export interface TriggerRequest { + jobName: string + input: Record + idempotencyKey?: string + concurrencyKey?: string +} + +/** + * Response for trigger endpoint + */ +export interface TriggerResponse { + runId: string +} + +/** + * Request query params for listing runs + */ +export interface RunsRequest { + jobName?: string + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + limit?: number + offset?: number +} + +/** + * Handler interface for HTTP endpoints + */ +export interface DurablyHandler { + /** + * Handle all Durably HTTP requests with automatic routing + * + * Routes: + * - GET {basePath}/subscribe?runId=xxx - SSE stream + * - GET {basePath}/runs - List runs + * - GET {basePath}/run?runId=xxx - Get single run + * - POST {basePath}/trigger - Trigger a job + * - POST {basePath}/retry?runId=xxx - Retry a failed run + * - POST {basePath}/cancel?runId=xxx - Cancel a run + * + * @param request - The incoming HTTP request + * @param basePath - The base path to strip from the URL (e.g., '/api/durably') + * @returns Response or null if route not matched + * + * @example + * ```ts + * // React Router / Remix + * export async function loader({ request }) { + * return durablyHandler.handle(request, '/api/durably') + * } + * export async function action({ request }) { + * return durablyHandler.handle(request, '/api/durably') + * } + * ``` + */ + handle(request: Request, basePath: string): Promise + + /** + * Handle job trigger request + * Expects POST with JSON body: { jobName, input, idempotencyKey?, concurrencyKey? } + * Returns JSON: { runId } + */ + trigger(request: Request): Promise + + /** + * Handle subscription request + * Expects GET with query param: runId + * Returns SSE stream of events + */ + subscribe(request: Request): Response + + /** + * Handle runs list request + * Expects GET with optional query params: jobName, status, limit, offset + * Returns JSON array of runs + */ + runs(request: Request): Promise + + /** + * Handle single run request + * Expects GET with query param: runId + * Returns JSON run object or 404 + */ + run(request: Request): Promise + + /** + * Handle retry request + * Expects POST with query param: runId + * Returns JSON: { success: true } + */ + retry(request: Request): Promise + + /** + * Handle cancel request + * Expects POST with query param: runId + * Returns JSON: { success: true } + */ + cancel(request: Request): Promise + + /** + * Handle delete request + * Expects DELETE with query param: runId + * Returns JSON: { success: true } + */ + delete(request: Request): Promise + + /** + * Handle steps request + * Expects GET with query param: runId + * Returns JSON array of steps + */ + steps(request: Request): Promise + + /** + * Handle runs subscription request + * Expects GET with optional query param: jobName + * Returns SSE stream of run update notifications + */ + runsSubscribe(request: Request): Response +} + +/** + * Options for createDurablyHandler + */ +export interface CreateDurablyHandlerOptions { + /** + * Called before handling each request. + * Use this to initialize Durably (migrate, start worker, etc.) + * + * @example + * ```ts + * const durablyHandler = createDurablyHandler(durably, { + * onRequest: async () => { + * await durably.migrate() + * durably.start() + * } + * }) + * ``` + */ + onRequest?: () => Promise | void +} + +/** + * Create HTTP handlers for Durably + * Uses Web Standard Request/Response for framework-agnostic usage + */ +export function createDurablyHandler( + durably: Durably, + options?: CreateDurablyHandlerOptions, +): DurablyHandler { + const handler: DurablyHandler = { + async handle(request: Request, basePath: string): Promise { + // Run onRequest hook if provided + if (options?.onRequest) { + await options.onRequest() + } + + const url = new URL(request.url) + const path = url.pathname.replace(basePath, '') + const method = request.method + + // GET routes + if (method === 'GET') { + if (path === '/subscribe') return handler.subscribe(request) + if (path === '/runs') return handler.runs(request) + if (path === '/run') return handler.run(request) + if (path === '/steps') return handler.steps(request) + if (path === '/runs/subscribe') return handler.runsSubscribe(request) + } + + // POST routes + if (method === 'POST') { + if (path === '/trigger') return handler.trigger(request) + if (path === '/retry') return handler.retry(request) + if (path === '/cancel') return handler.cancel(request) + } + + // DELETE routes + if (method === 'DELETE') { + if (path === '/run') return handler.delete(request) + } + + return new Response('Not Found', { status: 404 }) + }, + + async trigger(request: Request): Promise { + try { + const body = (await request.json()) as TriggerRequest + + if (!body.jobName) { + return new Response( + JSON.stringify({ error: 'jobName is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const job = durably.getJob(body.jobName) + if (!job) { + return new Response( + JSON.stringify({ error: `Job not found: ${body.jobName}` }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const run = await job.trigger(body.input ?? {}, { + idempotencyKey: body.idempotencyKey, + concurrencyKey: body.concurrencyKey, + }) + + const response: TriggerResponse = { runId: run.id } + return new Response(JSON.stringify(response), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + subscribe(request: Request): Response { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const stream = durably.subscribe(runId) + + // Transform stream to SSE format + const encoder = new TextEncoder() + const sseStream = new ReadableStream({ + async start(controller) { + const reader = stream.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + controller.close() + break + } + + // Format as SSE + const event = value as AnyEventInput + const data = `data: ${JSON.stringify(event)}\n\n` + controller.enqueue(encoder.encode(data)) + } + } catch (error) { + controller.error(error) + } + }, + }) + + return new Response(sseStream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + }, + + async runs(request: Request): Promise { + try { + const url = new URL(request.url) + const jobName = url.searchParams.get('jobName') ?? undefined + const status = url.searchParams.get('status') as RunsRequest['status'] + const limit = url.searchParams.get('limit') + const offset = url.searchParams.get('offset') + + const runs = await durably.getRuns({ + jobName, + status, + limit: limit ? Number.parseInt(limit, 10) : undefined, + offset: offset ? Number.parseInt(offset, 10) : undefined, + }) + + return new Response(JSON.stringify(runs), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + async run(request: Request): Promise { + try { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const run = await durably.getRun(runId) + + if (!run) { + return new Response(JSON.stringify({ error: 'Run not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify(run), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + async retry(request: Request): Promise { + try { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await durably.retry(runId) + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + async cancel(request: Request): Promise { + try { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await durably.cancel(runId) + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + async delete(request: Request): Promise { + try { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + await durably.deleteRun(runId) + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + async steps(request: Request): Promise { + try { + const url = new URL(request.url) + const runId = url.searchParams.get('runId') + + if (!runId) { + return new Response( + JSON.stringify({ error: 'runId query parameter is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const steps = await durably.storage.getSteps(runId) + + return new Response(JSON.stringify(steps), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }, + + runsSubscribe(request: Request): Response { + const url = new URL(request.url) + const jobNameFilter = url.searchParams.get('jobName') + + const encoder = new TextEncoder() + let closed = false + + const sseStream = new ReadableStream({ + start(controller) { + // Subscribe to run lifecycle events + const unsubscribeTrigger = durably.on('run:trigger', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:trigger', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeStart = durably.on('run:start', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:start', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeComplete = durably.on('run:complete', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:complete', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeFail = durably.on('run:fail', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:fail', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeCancel = durably.on('run:cancel', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:cancel', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeRetry = durably.on('run:retry', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:retry', + runId: event.runId, + jobName: event.jobName, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeProgress = durably.on('run:progress', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'run:progress', + runId: event.runId, + jobName: event.jobName, + progress: event.progress, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeStepStart = durably.on('step:start', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'step:start', + runId: event.runId, + jobName: event.jobName, + stepName: event.stepName, + stepIndex: event.stepIndex, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeStepComplete = durably.on( + 'step:complete', + (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'step:complete', + runId: event.runId, + jobName: event.jobName, + stepName: event.stepName, + stepIndex: event.stepIndex, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }, + ) + + const unsubscribeStepFail = durably.on('step:fail', (event) => { + if (closed) return + if (jobNameFilter && event.jobName !== jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'step:fail', + runId: event.runId, + jobName: event.jobName, + stepName: event.stepName, + stepIndex: event.stepIndex, + error: event.error, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + const unsubscribeLogWrite = durably.on('log:write', (event) => { + if (closed) return + // log:write doesn't have jobName, so we can't filter by it + // Send all logs when no filter, or skip if filter is set + if (jobNameFilter) return + + const data = `data: ${JSON.stringify({ + type: 'log:write', + runId: event.runId, + stepName: event.stepName, + level: event.level, + message: event.message, + data: event.data, + })}\n\n` + controller.enqueue(encoder.encode(data)) + }) + + // Store cleanup function for cancel + ;(controller as unknown as { cleanup: () => void }).cleanup = () => { + closed = true + unsubscribeTrigger() + unsubscribeStart() + unsubscribeComplete() + unsubscribeFail() + unsubscribeCancel() + unsubscribeRetry() + unsubscribeProgress() + unsubscribeStepStart() + unsubscribeStepComplete() + unsubscribeStepFail() + unsubscribeLogWrite() + } + }, + cancel(controller) { + const cleanup = (controller as unknown as { cleanup: () => void }) + .cleanup + if (cleanup) cleanup() + }, + }) + + return new Response(sseStream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + }, + } + + return handler +} diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 33f4ee53..95b5ee5d 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -23,6 +23,7 @@ export interface Run { idempotencyKey: string | null concurrencyKey: string | null currentStepIndex: number + stepCount: number progress: { current: number; total?: number; message?: string } | null output: unknown | null error: string | null @@ -133,7 +134,9 @@ export interface Storage { /** * Convert database row to Run object */ -function rowToRun(row: Database['durably_runs']): Run { +function rowToRun( + row: Database['durably_runs'] & { step_count?: number | bigint | null }, +): Run { return { id: row.id, jobName: row.job_name, @@ -142,6 +145,7 @@ function rowToRun(row: Database['durably_runs']): Run { idempotencyKey: row.idempotency_key, concurrencyKey: row.concurrency_key, currentStepIndex: row.current_step_index, + stepCount: Number(row.step_count ?? 0), progress: row.progress ? JSON.parse(row.progress) : null, output: row.output ? JSON.parse(row.output) : null, error: row.error, @@ -316,24 +320,36 @@ export function createKyselyStorage(db: Kysely): Storage { async getRun(runId: string): Promise { const row = await db .selectFrom('durably_runs') - .selectAll() - .where('id', '=', runId) + .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') + .selectAll('durably_runs') + .select((eb) => + eb.fn.count('durably_steps.id').as('step_count'), + ) + .where('durably_runs.id', '=', runId) + .groupBy('durably_runs.id') .executeTakeFirst() return row ? rowToRun(row) : null }, async getRuns(filter?: RunFilter): Promise { - let query = db.selectFrom('durably_runs').selectAll() + let query = db + .selectFrom('durably_runs') + .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') + .selectAll('durably_runs') + .select((eb) => + eb.fn.count('durably_steps.id').as('step_count'), + ) + .groupBy('durably_runs.id') if (filter?.status) { - query = query.where('status', '=', filter.status) + query = query.where('durably_runs.status', '=', filter.status) } if (filter?.jobName) { - query = query.where('job_name', '=', filter.jobName) + query = query.where('durably_runs.job_name', '=', filter.jobName) } - query = query.orderBy('created_at', 'desc') + query = query.orderBy('durably_runs.created_at', 'desc') if (filter?.limit !== undefined) { query = query.limit(filter.limit) @@ -355,16 +371,25 @@ export function createKyselyStorage(db: Kysely): Storage { ): Promise { let query = db .selectFrom('durably_runs') - .selectAll() - .where('status', '=', 'pending') - .orderBy('created_at', 'asc') + .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') + .selectAll('durably_runs') + .select((eb) => + eb.fn.count('durably_steps.id').as('step_count'), + ) + .where('durably_runs.status', '=', 'pending') + .groupBy('durably_runs.id') + .orderBy('durably_runs.created_at', 'asc') .limit(1) if (excludeConcurrencyKeys.length > 0) { query = query.where((eb) => eb.or([ - eb('concurrency_key', 'is', null), - eb('concurrency_key', 'not in', excludeConcurrencyKeys), + eb('durably_runs.concurrency_key', 'is', null), + eb( + 'durably_runs.concurrency_key', + 'not in', + excludeConcurrencyKeys, + ), ]), ) } diff --git a/packages/durably/tests/node/core-extensions.test.ts b/packages/durably/tests/node/core-extensions.test.ts new file mode 100644 index 00000000..ffec88fa --- /dev/null +++ b/packages/durably/tests/node/core-extensions.test.ts @@ -0,0 +1,246 @@ +/** + * Core Extensions Tests + * + * Test getJob, subscribe, and createDurablyHandler + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { + createDurably, + createDurablyHandler, + defineJob, + type Durably, +} from '../../src' +import type { DurablyEvent } from '../../src/events' +import { createNodeDialect } from '../helpers/node-dialect' + +describe('Core Extensions', () => { + let durably: Durably + + beforeEach(async () => { + const dialect = createNodeDialect() + durably = createDurably({ dialect, pollingInterval: 50 }) + await durably.migrate() + }) + + afterEach(async () => { + await durably.stop() + }) + + describe('getJob', () => { + const testJobDef = defineJob({ + name: 'test-job-getjob', + input: z.object({ value: z.number() }), + output: z.object({ result: z.number() }), + run: async (_ctx, payload) => ({ result: payload.value * 2 }), + }) + + it('returns registered job by name', () => { + durably.register({ testJob: testJobDef }) + + const job = durably.getJob('test-job-getjob') + + expect(job).toBeDefined() + expect(job?.name).toBe('test-job-getjob') + }) + + it('returns undefined for unknown job', () => { + expect(durably.getJob('unknown-job')).toBeUndefined() + }) + + it('can trigger job via getJob handle', async () => { + durably.register({ testJob: testJobDef }) + + const job = durably.getJob('test-job-getjob') + const run = await job!.trigger({ value: 5 }) + + expect(run.id).toBeDefined() + expect(run.status).toBe('pending') + }) + }) + + describe('subscribe', () => { + const testJobDef = defineJob({ + name: 'test-job-subscribe', + input: z.object({ input: z.string() }), + output: z.object({ result: z.string() }), + run: async (ctx, payload) => { + await ctx.run('step1', () => 'done') + return { result: `processed: ${payload.input}` } + }, + }) + + it('returns ReadableStream of events', async () => { + durably.register({ testJob: testJobDef }) + durably.start() + + const job = durably.getJob('test-job-subscribe')! + const run = await job.trigger({ input: 'test' }) + + const stream = durably.subscribe(run.id) + const reader = stream.getReader() + + const events: DurablyEvent[] = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(events.some((e) => e.type === 'run:complete')).toBe(true) + }) + + it('emits run:start event', async () => { + durably.register({ testJob: testJobDef }) + durably.start() + + const job = durably.getJob('test-job-subscribe')! + const run = await job.trigger({ input: 'test' }) + + const stream = durably.subscribe(run.id) + const reader = stream.getReader() + + const events: DurablyEvent[] = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(events.some((e) => e.type === 'run:start')).toBe(true) + }) + + it('emits step events', async () => { + durably.register({ testJob: testJobDef }) + durably.start() + + const job = durably.getJob('test-job-subscribe')! + const run = await job.trigger({ input: 'test' }) + + const stream = durably.subscribe(run.id) + const reader = stream.getReader() + + const events: DurablyEvent[] = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + + expect(events.some((e) => e.type === 'step:start')).toBe(true) + expect(events.some((e) => e.type === 'step:complete')).toBe(true) + }) + + it('cleans up event listeners when stream is cancelled', async () => { + const longRunningJob = defineJob({ + name: 'long-running-subscribe', + input: z.object({ input: z.string() }), + run: async (ctx) => { + await ctx.run('wait', async () => { + await new Promise((resolve) => setTimeout(resolve, 10000)) + }) + }, + }) + + durably.register({ longRunningJob }) + durably.start() + + const job = durably.getJob('long-running-subscribe')! + const run = await job.trigger({ input: 'test' }) + + const stream = durably.subscribe(run.id) + const reader = stream.getReader() + + // Read one event (run:start) + const { value } = await reader.read() + expect(value?.type).toBe('run:start') + + // Cancel the stream before the job completes + await reader.cancel() + + // The stream should be cancelled and no errors should occur + // If event listeners are not cleaned up, this would cause memory leaks + // Wait a bit to ensure no errors are thrown after cancellation + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Cancel the run to clean up + await durably.cancel(run.id) + }) + }) + + describe('createDurablyHandler', () => { + const testJobDef = defineJob({ + name: 'test-job-handler', + input: z.object({ value: z.number() }), + output: z.object({ result: z.number() }), + run: async (_ctx, payload) => ({ result: payload.value * 2 }), + }) + + beforeEach(() => { + durably.register({ testJob: testJobDef }) + }) + + it('trigger returns runId', async () => { + const handler = createDurablyHandler(durably) + + const request = new Request('http://localhost/api', { + method: 'POST', + body: JSON.stringify({ + jobName: 'test-job-handler', + input: { value: 5 }, + }), + }) + + const response = await handler.trigger(request) + const body = (await response.json()) as { runId: string } + + expect(response.status).toBe(200) + expect(body.runId).toBeDefined() + }) + + it('trigger returns 404 for unknown job', async () => { + const handler = createDurablyHandler(durably) + + const request = new Request('http://localhost/api', { + method: 'POST', + body: JSON.stringify({ jobName: 'unknown-job', input: {} }), + }) + + const response = await handler.trigger(request) + + expect(response.status).toBe(404) + }) + + it('trigger returns 400 for missing jobName', async () => { + const handler = createDurablyHandler(durably) + + const request = new Request('http://localhost/api', { + method: 'POST', + body: JSON.stringify({ input: {} }), + }) + + const response = await handler.trigger(request) + + expect(response.status).toBe(400) + }) + + it('subscribe returns SSE stream', () => { + const handler = createDurablyHandler(durably) + + const request = new Request('http://localhost/api?runId=test-run-id') + const response = handler.subscribe(request) + + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + }) + + it('subscribe returns 400 for missing runId', () => { + const handler = createDurablyHandler(durably) + + const request = new Request('http://localhost/api') + const response = handler.subscribe(request) + + expect(response.status).toBe(400) + }) + }) +}) diff --git a/packages/durably/tests/node/server.test.ts b/packages/durably/tests/node/server.test.ts new file mode 100644 index 00000000..081527e6 --- /dev/null +++ b/packages/durably/tests/node/server.test.ts @@ -0,0 +1,4 @@ +import { createNodeDialect } from '../helpers/node-dialect' +import { createServerTests } from '../shared/server.shared' + +createServerTests(createNodeDialect) diff --git a/packages/durably/tests/react/strict-mode.test.tsx b/packages/durably/tests/react/strict-mode.test.tsx index f3da5240..28c174f9 100644 --- a/packages/durably/tests/react/strict-mode.test.tsx +++ b/packages/durably/tests/react/strict-mode.test.tsx @@ -204,8 +204,8 @@ describe('React StrictMode', () => { await instance.migrate() if (cleanedUp.current) return - const job = instance.register( - defineJob({ + const d = instance.register({ + job: defineJob({ name: 'strict-mode-test', input: z.object({ value: z.string() }), output: z.object({ processed: z.string() }), @@ -214,18 +214,18 @@ describe('React StrictMode', () => { return { processed: payload.value.toUpperCase() } }, }), - ) + }) - const run = await job.trigger({ value: 'hello' }) + const run = await d.jobs.job.trigger({ value: 'hello' }) if (cleanedUp.current) return - instance.start() + d.start() // Wait for completion const checkCompletion = async () => { if (cleanedUp.current) return try { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) if (updated?.status === 'completed') { setResult((updated.output as { processed: string }).processed) } else if (!cleanedUp.current) { @@ -289,16 +289,16 @@ describe('React StrictMode', () => { instance.migrate().then(() => { if (cleanedUp.current) return - const job = instance.register( - defineJob({ + const d = instance.register({ + job: defineJob({ name: 'event-test', input: z.object({}), run: async () => {}, }), - ) - job.trigger({}).then(() => { + }) + d.jobs.job.trigger({}).then(() => { if (!cleanedUp.current) { - instance.start() + d.start() } }) }) diff --git a/packages/durably/tests/shared/concurrency.shared.ts b/packages/durably/tests/shared/concurrency.shared.ts index e618c113..cd4daeec 100644 --- a/packages/durably/tests/shared/concurrency.shared.ts +++ b/packages/durably/tests/shared/concurrency.shared.ts @@ -34,17 +34,17 @@ export function createConcurrencyTests(createDialect: () => Dialect) { executionOrder.push(`end-${payload.id}`) }, }) - const job = durably.register(concurrencyTestDef) + const d = durably.register({ job: concurrencyTestDef }) // Trigger two runs with the same concurrency key - await job.trigger({ id: '1' }, { concurrencyKey: 'user-123' }) - await job.trigger({ id: '2' }, { concurrencyKey: 'user-123' }) + await d.jobs.job.trigger({ id: '1' }, { concurrencyKey: 'user-123' }) + await d.jobs.job.trigger({ id: '2' }, { concurrencyKey: 'user-123' }) - durably.start() + d.start() await vi.waitFor( async () => { - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() const allCompleted = runs.every((r) => r.status === 'completed') expect(allCompleted).toBe(true) }, @@ -68,17 +68,17 @@ export function createConcurrencyTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(differentKeysTestDef) + const d = durably.register({ job: differentKeysTestDef }) // Trigger two runs with different concurrency keys - await job.trigger({ id: 'a' }, { concurrencyKey: 'user-A' }) - await job.trigger({ id: 'b' }, { concurrencyKey: 'user-B' }) + await d.jobs.job.trigger({ id: 'a' }, { concurrencyKey: 'user-A' }) + await d.jobs.job.trigger({ id: 'b' }, { concurrencyKey: 'user-B' }) - durably.start() + d.start() await vi.waitFor( async () => { - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() const allCompleted = runs.every((r) => r.status === 'completed') expect(allCompleted).toBe(true) }, @@ -103,18 +103,18 @@ export function createConcurrencyTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(noKeyTestDef) + const d = durably.register({ job: noKeyTestDef }) // Mix of runs with and without concurrency keys - await job.trigger({ id: '1' }) // no key - await job.trigger({ id: '2' }, { concurrencyKey: 'key-x' }) - await job.trigger({ id: '3' }) // no key + await d.jobs.job.trigger({ id: '1' }) // no key + await d.jobs.job.trigger({ id: '2' }, { concurrencyKey: 'key-x' }) + await d.jobs.job.trigger({ id: '3' }) // no key - durably.start() + d.start() await vi.waitFor( async () => { - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() const allCompleted = runs.every((r) => r.status === 'completed') expect(allCompleted).toBe(true) }, @@ -140,18 +140,18 @@ export function createConcurrencyTests(createDialect: () => Dialect) { concurrentRuns-- }, }) - const job = durably.register(nullKeyTestDef) + const d = durably.register({ job: nullKeyTestDef }) // Multiple runs with no concurrency key - await job.trigger({ id: 1 }) - await job.trigger({ id: 2 }) - await job.trigger({ id: 3 }) + await d.jobs.job.trigger({ id: 1 }) + await d.jobs.job.trigger({ id: 2 }) + await d.jobs.job.trigger({ id: 3 }) - durably.start() + d.start() await vi.waitFor( async () => { - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() const allCompleted = runs.every((r) => r.status === 'completed') expect(allCompleted).toBe(true) }, diff --git a/packages/durably/tests/shared/job.shared.ts b/packages/durably/tests/shared/job.shared.ts index 53304d97..da0c91b4 100644 --- a/packages/durably/tests/shared/job.shared.ts +++ b/packages/durably/tests/shared/job.shared.ts @@ -16,7 +16,7 @@ export function createJobTests(createDialect: () => Dialect) { await durably.db.destroy() }) - it('returns a JobHandle', () => { + it('returns a Durably instance with typed jobs', () => { const testJobDef = defineJob({ name: 'test-job', input: z.object({ value: z.number() }), @@ -25,13 +25,13 @@ export function createJobTests(createDialect: () => Dialect) { return { result: 42 } }, }) - const job = durably.register(testJobDef) + const d = durably.register({ testJob: testJobDef }) - expect(job).toBeDefined() - expect(job.name).toBe('test-job') - expect(job.trigger).toBeTypeOf('function') - expect(job.getRun).toBeTypeOf('function') - expect(job.getRuns).toBeTypeOf('function') + expect(d.jobs.testJob).toBeDefined() + expect(d.jobs.testJob.name).toBe('test-job') + expect(d.jobs.testJob.trigger).toBeTypeOf('function') + expect(d.jobs.testJob.getRun).toBeTypeOf('function') + expect(d.jobs.testJob.getRuns).toBeTypeOf('function') }) it('returns same JobHandle for same JobDefinition (idempotent)', () => { @@ -42,10 +42,10 @@ export function createJobTests(createDialect: () => Dialect) { run: async () => ({}), }) - const handle1 = durably.register(jobDef) - const handle2 = durably.register(jobDef) + const d1 = durably.register({ job: jobDef }) + const d2 = d1.register({ job2: jobDef }) - expect(handle1).toBe(handle2) + expect(d1.jobs.job).toBe(d2.jobs.job2) }) it('throws if different JobDefinition has same name', () => { @@ -63,13 +63,44 @@ export function createJobTests(createDialect: () => Dialect) { run: async () => ({}), }) - durably.register(jobDef1) + const d = durably.register({ job1: jobDef1 }) expect(() => { - durably.register(jobDef2) + d.register({ job2: jobDef2 }) }).toThrow(/already registered|different/i) }) + it('registers multiple jobs in a single call', async () => { + const importCsvDef = defineJob({ + name: 'import-csv', + input: z.object({ rows: z.array(z.string()) }), + run: async () => {}, + }) + + const syncUsersDef = defineJob({ + name: 'sync-users', + input: z.object({ userId: z.string() }), + run: async () => {}, + }) + + const d = durably.register({ + importCsv: importCsvDef, + syncUsers: syncUsersDef, + }) + + // Verify both handles are returned correctly + expect(d.jobs.importCsv.name).toBe('import-csv') + expect(d.jobs.syncUsers.name).toBe('sync-users') + + // Verify both can be triggered independently + const csvRun = await d.jobs.importCsv.trigger({ rows: ['a', 'b'] }) + const userRun = await d.jobs.syncUsers.trigger({ userId: 'user-1' }) + + expect(csvRun.id).toBeDefined() + expect(userRun.id).toBeDefined() + expect(csvRun.id).not.toBe(userRun.id) + }) + it('validates input with Zod schema on trigger', async () => { const validatedJobDef = defineJob({ name: 'validated-job', @@ -77,15 +108,15 @@ export function createJobTests(createDialect: () => Dialect) { output: z.object({}), run: async () => ({}), }) - const job = durably.register(validatedJobDef) + const d = durably.register({ job: validatedJobDef }) // Invalid input should throw await expect( - job.trigger({ count: 0 } as { count: number }), + d.jobs.job.trigger({ count: 0 } as { count: number }), ).rejects.toThrow() await expect( - job.trigger({ count: -1 } as { count: number }), + d.jobs.job.trigger({ count: -1 } as { count: number }), ).rejects.toThrow() }) @@ -96,10 +127,10 @@ export function createJobTests(createDialect: () => Dialect) { output: z.object({}), run: async () => ({}), }) - const job = durably.register(validInputJobDef) + const d = durably.register({ job: validInputJobDef }) // Valid input should work - const run = await job.trigger({ count: 1 }) + const run = await d.jobs.job.trigger({ count: 1 }) expect(run).toBeDefined() expect(run.id).toBeDefined() expect(run.status).toBe('pending') @@ -122,9 +153,9 @@ export function createJobTests(createDialect: () => Dialect) { return { success: true } }, }) - const job = durably.register(typedInputJobDef) + const d = durably.register({ job: typedInputJobDef }) - const run = await job.trigger({ + const run = await d.jobs.job.trigger({ name: 'test', count: 42, }) @@ -140,9 +171,9 @@ export function createJobTests(createDialect: () => Dialect) { // No return value }, }) - const job = durably.register(noOutputJobDef) + const d = durably.register({ job: noOutputJobDef }) - const run = await job.trigger({ value: 'test' }) + const run = await d.jobs.job.trigger({ value: 'test' }) expect(run.status).toBe('pending') }) }) @@ -165,9 +196,9 @@ export function createJobTests(createDialect: () => Dialect) { input: z.object({ value: z.number() }), run: async () => {}, }) - const job = durably.register(batchJobDef) + const d = durably.register({ job: batchJobDef }) - const runs = await job.batchTrigger([ + const runs = await d.jobs.job.batchTrigger([ { value: 1 }, { value: 2 }, { value: 3 }, @@ -179,7 +210,7 @@ export function createJobTests(createDialect: () => Dialect) { expect(runs[2].status).toBe('pending') // Verify all runs exist in DB - const allRuns = await job.getRuns() + const allRuns = await d.jobs.job.getRuns() expect(allRuns).toHaveLength(3) }) @@ -189,15 +220,15 @@ export function createJobTests(createDialect: () => Dialect) { input: z.object({ value: z.number().min(1) }), run: async () => {}, }) - const job = durably.register(batchValidateJobDef) + const d = durably.register({ job: batchValidateJobDef }) // Second input is invalid (0 < min 1) await expect( - job.batchTrigger([{ value: 5 }, { value: 0 }, { value: 3 }]), + d.jobs.job.batchTrigger([{ value: 5 }, { value: 0 }, { value: 3 }]), ).rejects.toThrow() // No runs should have been created - const allRuns = await job.getRuns() + const allRuns = await d.jobs.job.getRuns() expect(allRuns).toHaveLength(0) }) @@ -207,9 +238,9 @@ export function createJobTests(createDialect: () => Dialect) { input: z.object({ id: z.string() }), run: async () => {}, }) - const job = durably.register(batchOptionsJobDef) + const d = durably.register({ job: batchOptionsJobDef }) - const runs = await job.batchTrigger([ + const runs = await d.jobs.job.batchTrigger([ { input: { id: 'a' }, options: { idempotencyKey: 'key-a' } }, { input: { id: 'b' }, options: { concurrencyKey: 'group-1' } }, { input: { id: 'c' } }, @@ -226,9 +257,9 @@ export function createJobTests(createDialect: () => Dialect) { input: z.object({}), run: async () => {}, }) - const job = durably.register(batchEmptyJobDef) + const d = durably.register({ job: batchEmptyJobDef }) - const runs = await job.batchTrigger([]) + const runs = await d.jobs.job.batchTrigger([]) expect(runs).toEqual([]) }) }) diff --git a/packages/durably/tests/shared/log.shared.ts b/packages/durably/tests/shared/log.shared.ts index 37d0bea2..c99bf4c5 100644 --- a/packages/durably/tests/shared/log.shared.ts +++ b/packages/durably/tests/shared/log.shared.ts @@ -37,10 +37,10 @@ export function createLogTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }) - const job = durably.register(logInfoTestDef) + const d = durably.register({ job: logInfoTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -64,10 +64,10 @@ export function createLogTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }) - const job = durably.register(logWarnTestDef) + const d = durably.register({ job: logWarnTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -91,10 +91,10 @@ export function createLogTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }) - const job = durably.register(logErrorTestDef) + const d = durably.register({ job: logErrorTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -118,10 +118,10 @@ export function createLogTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }) - const job = durably.register(logDataTestDef) + const d = durably.register({ job: logDataTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -144,10 +144,10 @@ export function createLogTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }) - const job = durably.register(logRunIdTestDef) + const d = durably.register({ job: logRunIdTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -173,10 +173,10 @@ export function createLogTests(createDialect: () => Dialect) { step.log.info('After step') // stepName should be null }, }) - const job = durably.register(logStepNameTestDef) + const d = durably.register({ job: logStepNameTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { diff --git a/packages/durably/tests/shared/plugin.shared.ts b/packages/durably/tests/shared/plugin.shared.ts index 2336554b..e5c131d5 100644 --- a/packages/durably/tests/shared/plugin.shared.ts +++ b/packages/durably/tests/shared/plugin.shared.ts @@ -40,18 +40,18 @@ export function createPluginTests(createDialect: () => Dialect) { durably.use(plugin) - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'plugin-test', input: z.object({}), run: async (step) => { await step.run('step', () => {}) }, }), - ) + }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -81,18 +81,18 @@ export function createPluginTests(createDialect: () => Dialect) { durably.use(plugin1) durably.use(plugin2) - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'multi-plugin-test', input: z.object({}), run: async (step) => { await step.run('step', () => {}) }, }), - ) + }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -109,8 +109,8 @@ export function createPluginTests(createDialect: () => Dialect) { const { withLogPersistence } = await import('../../src') durably.use(withLogPersistence()) - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'log-persist-test', input: z.object({}), run: async (step) => { @@ -118,21 +118,21 @@ export function createPluginTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) // Check logs were persisted - const logs = await durably.storage.getLogs(run.id) + const logs = await d.storage.getLogs(run.id) expect(logs.length).toBeGreaterThanOrEqual(1) expect(logs[0].message).toBe('Test log message') expect(logs[0].level).toBe('info') @@ -140,8 +140,8 @@ export function createPluginTests(createDialect: () => Dialect) { }) it('logs table is empty without plugin', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'no-log-persist-test', input: z.object({}), run: async (step) => { @@ -149,21 +149,21 @@ export function createPluginTests(createDialect: () => Dialect) { await step.run('step', () => {}) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) // Logs should not be persisted - const logs = await durably.storage.getLogs(run.id) + const logs = await d.storage.getLogs(run.id) expect(logs).toHaveLength(0) }) }) diff --git a/packages/durably/tests/shared/recovery.shared.ts b/packages/durably/tests/shared/recovery.shared.ts index 6700b1c8..7a598629 100644 --- a/packages/durably/tests/shared/recovery.shared.ts +++ b/packages/durably/tests/shared/recovery.shared.ts @@ -24,8 +24,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { describe('Heartbeat', () => { it('updates heartbeat_at periodically for running runs', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'heartbeat-test', input: z.object({}), run: async (step) => { @@ -35,17 +35,17 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) const initialHeartbeat = run.heartbeatAt - durably.start() + d.start() // Wait a bit then check heartbeat was updated await new Promise((r) => setTimeout(r, 200)) - const midRun = await job.getRun(run.id) + const midRun = await d.jobs.job.getRun(run.id) expect(midRun?.status).toBe('running') expect(new Date(midRun!.heartbeatAt).getTime()).toBeGreaterThan( new Date(initialHeartbeat).getTime(), @@ -54,7 +54,7 @@ export function createRecoveryTests(createDialect: () => Dialect) { // Wait for completion await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, @@ -73,8 +73,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { const timestamps: string[] = [] - const job = customDurably.register( - defineJob({ + const d = customDurably.register({ + job: defineJob({ name: 'custom-heartbeat-test', input: z.object({}), run: async (step) => { @@ -88,20 +88,20 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - customDurably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 2000 }, ) - await customDurably.stop() + await d.stop() await customDurably.db.destroy() // Should have recorded multiple timestamps @@ -111,29 +111,29 @@ export function createRecoveryTests(createDialect: () => Dialect) { describe('Stale Run Recovery', () => { it('recovers stale running runs to pending', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'stale-recovery-test', input: z.object({}), run: async () => {}, }), - ) + }) // Create a run and manually set it to running with old heartbeat - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) const oldTime = new Date(Date.now() - 1000).toISOString() // 1 second ago - await durably.storage.updateRun(run.id, { + await d.storage.updateRun(run.id, { status: 'running', heartbeatAt: oldTime, }) // Start worker - should recover the stale run - durably.start() + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) // Should either be pending (recovered) or completed (re-executed) expect(['pending', 'completed']).toContain(updated?.status) }, @@ -145,8 +145,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { let step1Calls = 0 let step2Calls = 0 - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'resume-skip-test', input: z.object({}), run: async (step) => { @@ -160,13 +160,13 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) // Create run and simulate partial execution - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) // Manually complete step1 - await durably.storage.createStep({ + await d.storage.createStep({ runId: run.id, name: 'step1', index: 0, @@ -175,17 +175,17 @@ export function createRecoveryTests(createDialect: () => Dialect) { startedAt: new Date().toISOString(), }) - await durably.storage.updateRun(run.id, { + await d.storage.updateRun(run.id, { status: 'running', currentStepIndex: 1, heartbeatAt: new Date(Date.now() - 1000).toISOString(), }) - durably.start() + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, @@ -199,8 +199,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { describe('retry() API', () => { it('resets failed run to pending', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'retry-test', input: z.object({ shouldFail: z.boolean() }), run: async (_step, payload) => { @@ -209,72 +209,68 @@ export function createRecoveryTests(createDialect: () => Dialect) { } }, }), - ) + }) - const run = await job.trigger({ shouldFail: true }) - durably.start() + const run = await d.jobs.job.trigger({ shouldFail: true }) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') }, { timeout: 1000 }, ) // Retry the failed run - await durably.retry(run.id) + await d.retry(run.id) - const retried = await job.getRun(run.id) + const retried = await d.jobs.job.getRun(run.id) expect(retried?.status).toBe('pending') expect(retried?.error).toBeNull() }) it('throws when retrying completed run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'retry-completed-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) - await expect(durably.retry(run.id)).rejects.toThrow( - /completed|cannot retry/i, - ) + await expect(d.retry(run.id)).rejects.toThrow(/completed|cannot retry/i) }) it('throws when retrying pending run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'retry-pending-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) // Don't start worker - run stays pending - await expect(durably.retry(run.id)).rejects.toThrow( - /pending|cannot retry/i, - ) + await expect(d.retry(run.id)).rejects.toThrow(/pending|cannot retry/i) }) it('throws when retrying running run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'retry-running-test', input: z.object({}), run: async (step) => { @@ -283,48 +279,46 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() // Wait until running await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('running') }, { timeout: 500 }, ) - await expect(durably.retry(run.id)).rejects.toThrow( - /running|cannot retry/i, - ) + await expect(d.retry(run.id)).rejects.toThrow(/running|cannot retry/i) }) }) describe('cancel() API', () => { it('cancels pending run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-pending-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) // Don't start worker - run stays pending - await durably.cancel(run.id) + await d.cancel(run.id) - const cancelled = await job.getRun(run.id) + const cancelled = await d.jobs.job.getRun(run.id) expect(cancelled?.status).toBe('cancelled') }) it('cancels running run immediately', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-running-test', input: z.object({}), run: async (step) => { @@ -334,92 +328,90 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() // Wait until running await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('running') }, { timeout: 500 }, ) // Cancel while running - marks as cancelled immediately - await durably.cancel(run.id) + await d.cancel(run.id) - const cancelled = await job.getRun(run.id) + const cancelled = await d.jobs.job.getRun(run.id) expect(cancelled?.status).toBe('cancelled') }) it('throws when cancelling completed run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-completed-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) - await expect(durably.cancel(run.id)).rejects.toThrow( + await expect(d.cancel(run.id)).rejects.toThrow( /completed|cannot cancel/i, ) }) it('throws when cancelling failed run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-failed-test', input: z.object({}), run: async () => { throw new Error('fail') }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') }, { timeout: 1000 }, ) - await expect(durably.cancel(run.id)).rejects.toThrow( - /failed|cannot cancel/i, - ) + await expect(d.cancel(run.id)).rejects.toThrow(/failed|cannot cancel/i) }) it('throws when cancelling already cancelled run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-cancelled-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) - await durably.cancel(run.id) + const run = await d.jobs.job.trigger({}) + await d.cancel(run.id) - await expect(durably.cancel(run.id)).rejects.toThrow( + await expect(d.cancel(run.id)).rejects.toThrow( /cancelled|cannot cancel/i, ) }) @@ -435,8 +427,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { let step2Executed = false let step3Executed = false - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-mid-execution-test', input: z.object({}), run: async (step) => { @@ -456,28 +448,28 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() // Wait until running await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('running') }, { timeout: 500 }, ) // Cancel while step1 is executing - await durably.cancel(run.id) + await d.cancel(run.id) // Wait for worker to finish processing await new Promise((r) => setTimeout(r, 200)) // Run should stay cancelled (not overwritten to completed) - const finalRun = await job.getRun(run.id) + const finalRun = await d.jobs.job.getRun(run.id) expect(finalRun?.status).toBe('cancelled') // step1 was executed (was in progress when cancelled) @@ -488,8 +480,8 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) it('does not overwrite cancelled status with completed', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'cancel-no-overwrite-test', input: z.object({}), run: async (step) => { @@ -499,36 +491,36 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() // Wait until running await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('running') }, { timeout: 500 }, ) // Cancel while step is executing - await durably.cancel(run.id) + await d.cancel(run.id) // Wait for step to complete naturally await new Promise((r) => setTimeout(r, 300)) // Status should remain cancelled even though job function returned normally - const finalRun = await job.getRun(run.id) + const finalRun = await d.jobs.job.getRun(run.id) expect(finalRun?.status).toBe('cancelled') }) }) describe('deleteRun() API', () => { it('deletes completed run with its steps and logs', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'delete-completed-test', input: z.object({}), run: async (step) => { @@ -536,101 +528,101 @@ export function createRecoveryTests(createDialect: () => Dialect) { await step.run('step1', () => 'done') }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) // Verify steps and logs exist - const steps = await durably.storage.getSteps(run.id) + const steps = await d.storage.getSteps(run.id) expect(steps.length).toBeGreaterThan(0) // Delete the run - await durably.deleteRun(run.id) + await d.deleteRun(run.id) // Run should be gone - const deleted = await job.getRun(run.id) + const deleted = await d.jobs.job.getRun(run.id) expect(deleted).toBeNull() // Steps should also be deleted - const deletedSteps = await durably.storage.getSteps(run.id) + const deletedSteps = await d.storage.getSteps(run.id) expect(deletedSteps.length).toBe(0) }) it('deletes failed run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'delete-failed-test', input: z.object({}), run: async () => { throw new Error('fail') }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') }, { timeout: 1000 }, ) - await durably.deleteRun(run.id) + await d.deleteRun(run.id) - const deleted = await job.getRun(run.id) + const deleted = await d.jobs.job.getRun(run.id) expect(deleted).toBeNull() }) it('deletes cancelled run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'delete-cancelled-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) - await durably.cancel(run.id) + const run = await d.jobs.job.trigger({}) + await d.cancel(run.id) - await durably.deleteRun(run.id) + await d.deleteRun(run.id) - const deleted = await job.getRun(run.id) + const deleted = await d.jobs.job.getRun(run.id) expect(deleted).toBeNull() }) it('throws when deleting pending run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'delete-pending-test', input: z.object({}), run: async () => {}, }), - ) + }) - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) // Don't start worker - run stays pending - await expect(durably.deleteRun(run.id)).rejects.toThrow( + await expect(d.deleteRun(run.id)).rejects.toThrow( /pending|cannot delete/i, ) }) it('throws when deleting running run', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'delete-running-test', input: z.object({}), run: async (step) => { @@ -639,20 +631,20 @@ export function createRecoveryTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('running') }, { timeout: 500 }, ) - await expect(durably.deleteRun(run.id)).rejects.toThrow( + await expect(d.deleteRun(run.id)).rejects.toThrow( /running|cannot delete/i, ) }) diff --git a/packages/durably/tests/shared/run-api.shared.ts b/packages/durably/tests/shared/run-api.shared.ts index a9287855..88bec685 100644 --- a/packages/durably/tests/shared/run-api.shared.ts +++ b/packages/durably/tests/shared/run-api.shared.ts @@ -22,16 +22,16 @@ export function createRunApiTests(createDialect: () => Dialect) { describe('durably.getRun()', () => { it('returns a run by ID', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'get-run-test', input: z.object({ value: z.number() }), run: async () => {}, }), - ) + }) - const run = await job.trigger({ value: 42 }) - const fetched = await durably.getRun(run.id) + const run = await d.jobs.job.trigger({ value: 42 }) + const fetched = await d.getRun(run.id) expect(fetched).not.toBeNull() expect(fetched?.id).toBe(run.id) @@ -46,128 +46,128 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('returns run with unknown output type', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'unknown-output-test', input: z.object({}), output: z.object({ result: z.string() }), run: async () => ({ result: 'hello' }), }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) // durably.getRun returns unknown output type - const fetched = await durably.getRun(run.id) + const fetched = await d.getRun(run.id) expect(fetched?.output).toEqual({ result: 'hello' }) }) }) describe('durably.getRuns()', () => { it('returns all runs', async () => { - const job1 = durably.register( - defineJob({ + const d1 = durably.register({ + job1: defineJob({ name: 'job1', input: z.object({}), run: async () => {}, }), - ) - const job2 = durably.register( - defineJob({ + }) + const d2 = d1.register({ + job2: defineJob({ name: 'job2', input: z.object({}), run: async () => {}, }), - ) + }) - await job1.trigger({}) - await job2.trigger({}) - await job1.trigger({}) + await d2.jobs.job1.trigger({}) + await d2.jobs.job2.trigger({}) + await d2.jobs.job1.trigger({}) - const runs = await durably.getRuns() + const runs = await d2.getRuns() expect(runs).toHaveLength(3) }) it('filters by status', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'status-filter-test', input: z.object({}), run: async () => {}, }), - ) + }) - await job.trigger({}) - await job.trigger({}) + await d.jobs.job.trigger({}) + await d.jobs.job.trigger({}) - durably.start() + d.start() await vi.waitFor( async () => { - const completed = await durably.getRuns({ status: 'completed' }) + const completed = await d.getRuns({ status: 'completed' }) expect(completed.length).toBeGreaterThanOrEqual(1) }, { timeout: 1000 }, ) - const pending = await durably.getRuns({ status: 'pending' }) - const completed = await durably.getRuns({ status: 'completed' }) + const pending = await d.getRuns({ status: 'pending' }) + const completed = await d.getRuns({ status: 'completed' }) expect(pending.length + completed.length).toBe(2) }) it('filters by jobName', async () => { - const job1 = durably.register( - defineJob({ + const d1 = durably.register({ + job1: defineJob({ name: 'filter-job-a', input: z.object({}), run: async () => {}, }), - ) - const job2 = durably.register( - defineJob({ + }) + const d2 = d1.register({ + job2: defineJob({ name: 'filter-job-b', input: z.object({}), run: async () => {}, }), - ) + }) - await job1.trigger({}) - await job1.trigger({}) - await job2.trigger({}) + await d2.jobs.job1.trigger({}) + await d2.jobs.job1.trigger({}) + await d2.jobs.job2.trigger({}) - const runsA = await durably.getRuns({ jobName: 'filter-job-a' }) - const runsB = await durably.getRuns({ jobName: 'filter-job-b' }) + const runsA = await d2.getRuns({ jobName: 'filter-job-a' }) + const runsB = await d2.getRuns({ jobName: 'filter-job-b' }) expect(runsA).toHaveLength(2) expect(runsB).toHaveLength(1) }) it('returns runs sorted by created_at descending', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'sort-test', input: z.object({ order: z.number() }), run: async () => {}, }), - ) + }) - await job.trigger({ order: 1 }) + await d.jobs.job.trigger({ order: 1 }) await new Promise((r) => setTimeout(r, 10)) - await job.trigger({ order: 2 }) + await d.jobs.job.trigger({ order: 2 }) await new Promise((r) => setTimeout(r, 10)) - await job.trigger({ order: 3 }) + await d.jobs.job.trigger({ order: 3 }) - const runs = await durably.getRuns() + const runs = await d.getRuns() // Most recent first expect((runs[0].payload as { order: number }).order).toBe(3) @@ -176,21 +176,21 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('supports limit option', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'limit-test', input: z.object({ order: z.number() }), run: async () => {}, }), - ) + }) // Add slight delays to ensure distinct created_at timestamps for (let i = 1; i <= 5; i++) { - await job.trigger({ order: i }) + await d.jobs.job.trigger({ order: i }) if (i < 5) await new Promise((r) => setTimeout(r, 5)) } - const limited = await durably.getRuns({ + const limited = await d.getRuns({ jobName: 'limit-test', limit: 3, }) @@ -203,21 +203,21 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('supports offset option', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'offset-test', input: z.object({ order: z.number() }), run: async () => {}, }), - ) + }) // Add slight delays to ensure distinct created_at timestamps for (let i = 1; i <= 5; i++) { - await job.trigger({ order: i }) + await d.jobs.job.trigger({ order: i }) if (i < 5) await new Promise((r) => setTimeout(r, 5)) } - const offset = await durably.getRuns({ + const offset = await d.getRuns({ jobName: 'offset-test', offset: 2, }) @@ -230,22 +230,22 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('supports limit and offset together for pagination', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'pagination-test', input: z.object({ order: z.number() }), run: async () => {}, }), - ) + }) // Add slight delays to ensure distinct created_at timestamps for (let i = 1; i <= 10; i++) { - await job.trigger({ order: i }) + await d.jobs.job.trigger({ order: i }) if (i < 10) await new Promise((r) => setTimeout(r, 5)) } // Page 1: first 3 items - const page1 = await durably.getRuns({ + const page1 = await d.getRuns({ jobName: 'pagination-test', limit: 3, offset: 0, @@ -256,7 +256,7 @@ export function createRunApiTests(createDialect: () => Dialect) { expect((page1[2].payload as { order: number }).order).toBe(8) // Page 2: next 3 items - const page2 = await durably.getRuns({ + const page2 = await d.getRuns({ jobName: 'pagination-test', limit: 3, offset: 3, @@ -267,7 +267,7 @@ export function createRunApiTests(createDialect: () => Dialect) { expect((page2[2].payload as { order: number }).order).toBe(5) // Page 4: last page with only 1 item - const page4 = await durably.getRuns({ + const page4 = await d.getRuns({ jobName: 'pagination-test', limit: 3, offset: 9, @@ -277,21 +277,21 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('combines pagination with other filters', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'combined-filter-pagination-test', input: z.object({ order: z.number() }), run: async () => {}, }), - ) + }) // Add slight delays to ensure distinct created_at timestamps for (let i = 1; i <= 6; i++) { - await job.trigger({ order: i }) + await d.jobs.job.trigger({ order: i }) if (i < 6) await new Promise((r) => setTimeout(r, 5)) } - const filtered = await durably.getRuns({ + const filtered = await d.getRuns({ jobName: 'combined-filter-pagination-test', limit: 2, offset: 1, @@ -304,18 +304,18 @@ export function createRunApiTests(createDialect: () => Dialect) { }) it('returns empty array when offset exceeds total', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'offset-exceeds-test', input: z.object({}), run: async () => {}, }), - ) + }) - await job.trigger({}) - await job.trigger({}) + await d.jobs.job.trigger({}) + await d.jobs.job.trigger({}) - const result = await durably.getRuns({ + const result = await d.getRuns({ jobName: 'offset-exceeds-test', offset: 10, }) @@ -325,8 +325,8 @@ export function createRunApiTests(createDialect: () => Dialect) { describe('triggerAndWait()', () => { it('triggers and waits for successful completion', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'trigger-and-wait-success', input: z.object({ value: z.number() }), output: z.object({ result: z.number() }), @@ -337,23 +337,23 @@ export function createRunApiTests(createDialect: () => Dialect) { return { result: payload.value * 2 } }, }), - ) + }) - durably.start() + d.start() - const { id, output } = await job.triggerAndWait({ value: 21 }) + const { id, output } = await d.jobs.job.triggerAndWait({ value: 21 }) expect(id).toBeDefined() expect(output).toEqual({ result: 42 }) // Verify run is completed - const run = await job.getRun(id) + const run = await d.jobs.job.getRun(id) expect(run?.status).toBe('completed') }) it('rejects when job fails', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'trigger-and-wait-fail', input: z.object({}), output: z.object({}), @@ -364,18 +364,18 @@ export function createRunApiTests(createDialect: () => Dialect) { return {} }, }), - ) + }) - durably.start() + d.start() - await expect(job.triggerAndWait({})).rejects.toThrow( + await expect(d.jobs.job.triggerAndWait({})).rejects.toThrow( 'Intentional failure', ) }) it('works with options', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'trigger-and-wait-options', input: z.object({}), output: z.object({ done: z.boolean() }), @@ -383,24 +383,24 @@ export function createRunApiTests(createDialect: () => Dialect) { return { done: true } }, }), - ) + }) - durably.start() + d.start() - const { output } = await job.triggerAndWait( + const { output } = await d.jobs.job.triggerAndWait( {}, { idempotencyKey: 'test-key' }, ) expect(output).toEqual({ done: true }) // Verify idempotency key was used - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() expect(runs[0].idempotencyKey).toBe('test-key') }) it('times out if job does not complete within timeout', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'trigger-and-wait-timeout', input: z.object({}), output: z.object({}), @@ -412,21 +412,21 @@ export function createRunApiTests(createDialect: () => Dialect) { return {} }, }), - ) + }) // Don't start the worker - job will never complete // Or start with a delay that exceeds timeout - await expect(job.triggerAndWait({}, { timeout: 100 })).rejects.toThrow( - 'timeout', - ) + await expect( + d.jobs.job.triggerAndWait({}, { timeout: 100 }), + ).rejects.toThrow('timeout') }) }) describe('step.progress()', () => { it('saves progress with current value', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'progress-test', input: z.object({}), run: async (step) => { @@ -436,26 +436,26 @@ export function createRunApiTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.progress).not.toBeNull() }, { timeout: 1000 }, ) - const midRun = await job.getRun(run.id) + const midRun = await d.jobs.job.getRun(run.id) expect(midRun?.progress?.current).toBe(50) }) it('saves progress with all fields', async () => { - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'full-progress-test', input: z.object({}), run: async (step) => { @@ -465,20 +465,20 @@ export function createRunApiTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.progress).not.toBeNull() }, { timeout: 1000 }, ) - const midRun = await job.getRun(run.id) + const midRun = await d.jobs.job.getRun(run.id) expect(midRun?.progress).toEqual({ current: 25, total: 100, @@ -489,8 +489,8 @@ export function createRunApiTests(createDialect: () => Dialect) { it('progress is available via getRun()', async () => { let progressSet = false - const job = durably.register( - defineJob({ + const d = durably.register({ + job: defineJob({ name: 'get-progress-test', input: z.object({}), run: async (step) => { @@ -501,10 +501,10 @@ export function createRunApiTests(createDialect: () => Dialect) { }) }, }), - ) + }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -516,7 +516,7 @@ export function createRunApiTests(createDialect: () => Dialect) { // Give time for async progress update await new Promise((r) => setTimeout(r, 50)) - const fetched = await durably.getRun(run.id) + const fetched = await d.getRun(run.id) expect(fetched?.progress?.current).toBe(75) expect(fetched?.progress?.total).toBe(100) }) diff --git a/packages/durably/tests/shared/server.shared.ts b/packages/durably/tests/shared/server.shared.ts new file mode 100644 index 00000000..73735891 --- /dev/null +++ b/packages/durably/tests/shared/server.shared.ts @@ -0,0 +1,895 @@ +import type { Dialect } from 'kysely' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { + createDurably, + createDurablyHandler, + defineJob, + type Durably, + type DurablyHandler, +} from '../../src' + +export function createServerTests(createDialect: () => Dialect) { + describe('createDurablyHandler', () => { + let durably: Durably + let handler: DurablyHandler + + beforeEach(async () => { + durably = createDurably({ + dialect: createDialect(), + pollingInterval: 50, + }) + await durably.migrate() + handler = createDurablyHandler(durably) + }) + + afterEach(async () => { + await durably.stop() + await durably.db.destroy() + }) + + describe('handle() routing', () => { + it('routes GET /subscribe to subscribe handler', async () => { + const d = durably.register({ + job: defineJob({ + name: 'subscribe-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/subscribe?runId=${run.id}`, + { method: 'GET' }, + ) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + }) + + it('routes GET /runs to runs handler', async () => { + const request = new Request('http://localhost/api/durably/runs', { + method: 'GET', + }) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/json') + }) + + it('routes GET /run to run handler', async () => { + const d = durably.register({ + job: defineJob({ + name: 'run-route-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/run?runId=${run.id}`, + { method: 'GET' }, + ) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + }) + + it('routes POST /trigger to trigger handler', async () => { + durably.register({ + job: defineJob({ + name: 'trigger-route-test', + input: z.object({}), + run: async () => {}, + }), + }) + + const request = new Request('http://localhost/api/durably/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jobName: 'trigger-route-test', input: {} }), + }) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + }) + + it('routes POST /retry to retry handler', async () => { + const d = durably.register({ + job: defineJob({ + name: 'retry-route-test', + input: z.object({}), + run: async () => { + throw new Error('fail') + }, + }), + }) + + const run = await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + async () => { + const updated = await d.getRun(run.id) + expect(updated?.status).toBe('failed') + }, + { timeout: 1000 }, + ) + + const request = new Request( + `http://localhost/api/durably/retry?runId=${run.id}`, + { method: 'POST' }, + ) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + }) + + it('routes POST /cancel to cancel handler', async () => { + const d = durably.register({ + job: defineJob({ + name: 'cancel-route-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/cancel?runId=${run.id}`, + { method: 'POST' }, + ) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + }) + + it('returns 404 for unknown routes', async () => { + const request = new Request('http://localhost/api/durably/unknown', { + method: 'GET', + }) + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(404) + }) + + it('calls onRequest hook before handling', async () => { + const onRequest = vi.fn() + const handlerWithHook = createDurablyHandler(durably, { onRequest }) + + const request = new Request('http://localhost/api/durably/runs', { + method: 'GET', + }) + await handlerWithHook.handle(request, '/api/durably') + + expect(onRequest).toHaveBeenCalled() + }) + }) + + describe('trigger()', () => { + it('triggers a job and returns runId', async () => { + durably.register({ + job: defineJob({ + name: 'trigger-test', + input: z.object({ value: z.number() }), + run: async () => {}, + }), + }) + + const request = new Request('http://localhost/api/durably/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jobName: 'trigger-test', + input: { value: 42 }, + }), + }) + + const response = await handler.trigger(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.runId).toBeDefined() + }) + + it('returns 400 when jobName is missing', async () => { + const request = new Request('http://localhost/api/durably/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: {} }), + }) + + const response = await handler.trigger(request) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('jobName is required') + }) + + it('returns 404 when job is not found', async () => { + const request = new Request('http://localhost/api/durably/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jobName: 'non-existent', input: {} }), + }) + + const response = await handler.trigger(request) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error).toBe('Job not found: non-existent') + }) + + it('supports idempotencyKey and concurrencyKey', async () => { + durably.register({ + job: defineJob({ + name: 'trigger-options-test', + input: z.object({}), + run: async () => {}, + }), + }) + + const request = new Request('http://localhost/api/durably/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jobName: 'trigger-options-test', + input: {}, + idempotencyKey: 'idem-key', + concurrencyKey: 'conc-key', + }), + }) + + const response = await handler.trigger(request) + const body = await response.json() + + expect(response.status).toBe(200) + + const run = await durably.getRun(body.runId) + expect(run?.idempotencyKey).toBe('idem-key') + expect(run?.concurrencyKey).toBe('conc-key') + }) + }) + + describe('runs()', () => { + it('returns all runs', async () => { + const d = durably.register({ + job: defineJob({ + name: 'runs-test', + input: z.object({}), + run: async () => {}, + }), + }) + await d.jobs.job.trigger({}) + await d.jobs.job.trigger({}) + + const request = new Request('http://localhost/api/durably/runs', { + method: 'GET', + }) + + const response = await handler.runs(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toHaveLength(2) + }) + + it('filters by jobName', async () => { + const d1 = durably.register({ + job1: defineJob({ + name: 'filter-job-1', + input: z.object({}), + run: async () => {}, + }), + }) + const d2 = d1.register({ + job2: defineJob({ + name: 'filter-job-2', + input: z.object({}), + run: async () => {}, + }), + }) + await d2.jobs.job1.trigger({}) + await d2.jobs.job2.trigger({}) + + const request = new Request( + 'http://localhost/api/durably/runs?jobName=filter-job-1', + { method: 'GET' }, + ) + + const response = await handler.runs(request) + const body = await response.json() + + expect(body).toHaveLength(1) + expect(body[0].jobName).toBe('filter-job-1') + }) + + it('filters by status', async () => { + const d = durably.register({ + job: defineJob({ + name: 'status-filter-test', + input: z.object({}), + run: async () => {}, + }), + }) + await d.jobs.job.trigger({}) + await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + async () => { + const runs = await d.getRuns({ status: 'completed' }) + expect(runs.length).toBeGreaterThanOrEqual(1) + }, + { timeout: 1000 }, + ) + + const request = new Request( + 'http://localhost/api/durably/runs?status=completed', + { method: 'GET' }, + ) + + const response = await handler.runs(request) + const body = await response.json() + + for (const run of body) { + expect(run.status).toBe('completed') + } + }) + + it('supports limit and offset', async () => { + const d = durably.register({ + job: defineJob({ + name: 'pagination-test', + input: z.object({ order: z.number() }), + run: async () => {}, + }), + }) + for (let i = 1; i <= 5; i++) { + await d.jobs.job.trigger({ order: i }) + if (i < 5) await new Promise((r) => setTimeout(r, 5)) + } + + const request = new Request( + 'http://localhost/api/durably/runs?limit=2&offset=1', + { method: 'GET' }, + ) + + const response = await handler.runs(request) + const body = await response.json() + + expect(body).toHaveLength(2) + }) + }) + + describe('run()', () => { + it('returns a single run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'single-run-test', + input: z.object({ value: z.number() }), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({ value: 42 }) + + const request = new Request( + `http://localhost/api/durably/run?runId=${run.id}`, + { method: 'GET' }, + ) + + const response = await handler.run(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.id).toBe(run.id) + expect(body.payload).toEqual({ value: 42 }) + }) + + it('returns 400 when runId is missing', async () => { + const request = new Request('http://localhost/api/durably/run', { + method: 'GET', + }) + + const response = await handler.run(request) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('runId query parameter is required') + }) + + it('returns 404 when run is not found', async () => { + const request = new Request( + 'http://localhost/api/durably/run?runId=non-existent', + { method: 'GET' }, + ) + + const response = await handler.run(request) + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error).toBe('Run not found') + }) + }) + + describe('retry()', () => { + it('retries a failed run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'retry-test', + input: z.object({}), + run: async () => { + throw new Error('fail') + }, + }), + }) + const run = await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + async () => { + const updated = await d.getRun(run.id) + expect(updated?.status).toBe('failed') + }, + { timeout: 1000 }, + ) + + const request = new Request( + `http://localhost/api/durably/retry?runId=${run.id}`, + { method: 'POST' }, + ) + + const response = await handler.retry(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.success).toBe(true) + + const updated = await d.getRun(run.id) + expect(updated?.status).toBe('pending') + }) + + it('returns 400 when runId is missing', async () => { + const request = new Request('http://localhost/api/durably/retry', { + method: 'POST', + }) + + const response = await handler.retry(request) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('runId query parameter is required') + }) + + it('returns 500 when retrying non-failed run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'retry-pending-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/retry?runId=${run.id}`, + { method: 'POST' }, + ) + + const response = await handler.retry(request) + expect(response.status).toBe(500) + }) + }) + + describe('cancel()', () => { + it('cancels a pending run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'cancel-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/cancel?runId=${run.id}`, + { method: 'POST' }, + ) + + const response = await handler.cancel(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.success).toBe(true) + + const updated = await d.getRun(run.id) + expect(updated?.status).toBe('cancelled') + }) + + it('returns 400 when runId is missing', async () => { + const request = new Request('http://localhost/api/durably/cancel', { + method: 'POST', + }) + + const response = await handler.cancel(request) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toBe('runId query parameter is required') + }) + + it('returns 500 when cancelling completed run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'cancel-completed-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + d.start() + + await vi.waitFor( + async () => { + const updated = await d.getRun(run.id) + expect(updated?.status).toBe('completed') + }, + { timeout: 1000 }, + ) + + const request = new Request( + `http://localhost/api/durably/cancel?runId=${run.id}`, + { method: 'POST' }, + ) + + const response = await handler.cancel(request) + expect(response.status).toBe(500) + }) + }) + + describe('subscribe()', () => { + it('returns SSE stream', async () => { + const d = durably.register({ + job: defineJob({ + name: 'sse-test', + input: z.object({}), + run: async () => {}, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/subscribe?runId=${run.id}`, + { method: 'GET' }, + ) + + const response = handler.subscribe(request) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + expect(response.headers.get('Cache-Control')).toBe('no-cache') + }) + + it('streams events for a run', async () => { + const d = durably.register({ + job: defineJob({ + name: 'sse-stream-test', + input: z.object({}), + run: async (step) => { + await step.run('step1', async () => 'result') + }, + }), + }) + const run = await d.jobs.job.trigger({}) + + const request = new Request( + `http://localhost/api/durably/subscribe?runId=${run.id}`, + { method: 'GET' }, + ) + + const response = handler.subscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + // Start worker to process the job + d.start() + + const events: string[] = [] + const readEvents = async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + } + } + + await Promise.race([ + readEvents(), + new Promise((r) => setTimeout(r, 1000)), + ]) + + // Should have received some events + expect(events.length).toBeGreaterThan(0) + const allEvents = events.join('') + expect(allEvents).toContain('data:') + }) + + it('returns 400 when runId is missing', () => { + const request = new Request('http://localhost/api/durably/subscribe', { + method: 'GET', + }) + + const response = handler.subscribe(request) + expect(response.status).toBe(400) + }) + }) + + describe('runsSubscribe()', () => { + it('returns SSE stream for run updates', () => { + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + }) + + it('routes to runsSubscribe via handle()', async () => { + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = await handler.handle(request, '/api/durably') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/event-stream') + }) + + it('streams run:trigger immediately when job is triggered', async () => { + const d = durably.register({ + job: defineJob({ + name: 'runs-subscribe-trigger-test', + input: z.object({}), + run: async () => {}, + }), + }) + + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + const events: string[] = [] + const readPromise = (async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + // Stop after receiving the trigger event + if (events.some((e) => e.includes('run:trigger'))) break + } + })() + + // Trigger the job (don't start worker yet) + await d.jobs.job.trigger({}) + + await Promise.race([ + readPromise, + new Promise((r) => setTimeout(r, 500)), + ]) + + // Should have received run:trigger event immediately + const allEvents = events.join('') + expect(allEvents).toContain('run:trigger') + }) + + it('streams run:cancel when job is cancelled', async () => { + const d = durably.register({ + job: defineJob({ + name: 'runs-subscribe-cancel-test', + input: z.object({}), + run: async () => {}, + }), + }) + + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + const events: string[] = [] + const readPromise = (async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + // Stop after receiving the cancel event + if (events.some((e) => e.includes('run:cancel'))) break + } + })() + + // Trigger and then cancel the job + const run = await d.jobs.job.trigger({}) + await d.cancel(run.id) + + await Promise.race([ + readPromise, + new Promise((r) => setTimeout(r, 500)), + ]) + + // Should have received run:cancel event + const allEvents = events.join('') + expect(allEvents).toContain('run:cancel') + }) + + it('streams run:retry when job is retried', async () => { + const d = durably.register({ + job: defineJob({ + name: 'runs-subscribe-retry-test', + input: z.object({}), + run: async () => { + throw new Error('test error') + }, + }), + }) + + // First, trigger and let it fail + const run = await d.jobs.job.trigger({}) + d.start() + + // Wait for the job to fail + await new Promise((r) => setTimeout(r, 200)) + + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + const events: string[] = [] + const readPromise = (async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + // Stop after receiving the retry event + if (events.some((e) => e.includes('run:retry'))) break + } + })() + + // Retry the failed job + await d.retry(run.id) + + await Promise.race([ + readPromise, + new Promise((r) => setTimeout(r, 500)), + ]) + + // Should have received run:retry event + const allEvents = events.join('') + expect(allEvents).toContain('run:retry') + }) + + it('streams run lifecycle events', async () => { + const d = durably.register({ + job: defineJob({ + name: 'runs-subscribe-test', + input: z.object({}), + run: async (step) => { + step.progress(50) + await step.run('work', async () => {}) + }, + }), + }) + + const request = new Request( + 'http://localhost/api/durably/runs/subscribe', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + // Trigger a job to generate events + await d.jobs.job.trigger({}) + d.start() + + const events: string[] = [] + const readEvents = async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + // Stop after receiving some events + if (events.length >= 3) break + } + } + + await Promise.race([ + readEvents(), + new Promise((r) => setTimeout(r, 1000)), + ]) + + // Should have received run:trigger, run:start and run:complete events + expect(events.length).toBeGreaterThan(0) + const allEvents = events.join('') + expect(allEvents).toContain('data:') + expect(allEvents).toContain('run:') + }) + + it('filters by jobName', async () => { + const d1 = durably.register({ + job1: defineJob({ + name: 'filter-subscribe-1', + input: z.object({}), + run: async () => {}, + }), + }) + const d2 = d1.register({ + job2: defineJob({ + name: 'filter-subscribe-2', + input: z.object({}), + run: async () => {}, + }), + }) + + // Subscribe only to job1 + const request = new Request( + 'http://localhost/api/durably/runs/subscribe?jobName=filter-subscribe-1', + { method: 'GET' }, + ) + + const response = handler.runsSubscribe(request) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + + // Trigger both jobs + await d2.jobs.job1.trigger({}) + await d2.jobs.job2.trigger({}) + d2.start() + + const events: string[] = [] + const readEvents = async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(decoder.decode(value)) + if (events.length >= 2) break + } + } + + await Promise.race([ + readEvents(), + new Promise((r) => setTimeout(r, 1000)), + ]) + + // All events should be for filter-subscribe-1 only + const allEvents = events.join('') + if (allEvents.includes('jobName')) { + expect(allEvents).toContain('filter-subscribe-1') + expect(allEvents).not.toContain('filter-subscribe-2') + } + }) + }) + }) +} diff --git a/packages/durably/tests/shared/step.shared.ts b/packages/durably/tests/shared/step.shared.ts index eb4b6fc4..fe2a4c9b 100644 --- a/packages/durably/tests/shared/step.shared.ts +++ b/packages/durably/tests/shared/step.shared.ts @@ -36,14 +36,14 @@ export function createStepTests(createDialect: () => Dialect) { return { result: value } }, }) - const job = durably.register(stepReturnTestDef) + const d = durably.register({ job: stepReturnTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') expect(updated?.output).toEqual({ result: 42 }) }, @@ -60,14 +60,14 @@ export function createStepTests(createDialect: () => Dialect) { await step.run('step2', () => 'result2') }, }) - const job = durably.register(stepRecordTestDef) + const d = durably.register({ job: stepRecordTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const steps = await durably.storage.getSteps(run.id) + const steps = await d.storage.getSteps(run.id) expect(steps).toHaveLength(2) expect(steps[0].name).toBe('step1') expect(steps[0].status).toBe('completed') @@ -90,14 +90,14 @@ export function createStepTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(stepFailTestDef) + const d = durably.register({ job: stepFailTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') expect(updated?.error).toContain('Step failed!') }, @@ -105,7 +105,7 @@ export function createStepTests(createDialect: () => Dialect) { ) // Check step was recorded as failed - const steps = await durably.storage.getSteps(run.id) + const steps = await d.storage.getSteps(run.id) expect(steps).toHaveLength(1) expect(steps[0].status).toBe('failed') expect(steps[0].error).toContain('Step failed!') @@ -133,15 +133,15 @@ export function createStepTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(stepResumeTestDef) + const d = durably.register({ job: stepResumeTestDef }) // First run - will fail at step2 - const run1 = await job.trigger({ shouldFail: true }) - durably.start() + const run1 = await d.jobs.job.trigger({ shouldFail: true }) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run1.id) + const updated = await d.jobs.job.getRun(run1.id) expect(updated?.status).toBe('failed') }, { timeout: 1000 }, @@ -151,12 +151,12 @@ export function createStepTests(createDialect: () => Dialect) { expect(step2Calls).toBe(1) // Reset run to pending for retry (simulate retry behavior) - await durably.storage.updateRun(run1.id, { status: 'pending' }) + await d.storage.updateRun(run1.id, { status: 'pending' }) // Second run - step1 should be skipped await vi.waitFor( async () => { - const updated = await job.getRun(run1.id) + const updated = await d.jobs.job.getRun(run1.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, @@ -194,15 +194,15 @@ export function createStepTests(createDialect: () => Dialect) { return { step1Result: result } }, }) - const job = durably.register(stepOutputResumeTestDef) + const d = durably.register({ job: stepOutputResumeTestDef }) // First attempt - step1 succeeds, step2 fails - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') }, { timeout: 1000 }, @@ -212,13 +212,13 @@ export function createStepTests(createDialect: () => Dialect) { expect(step2CallCount).toBe(1) // Retry - step1 should be skipped and return stored value - await durably.storage.updateRun(run.id, { + await d.storage.updateRun(run.id, { status: 'pending', }) await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') // The step1Result should be from first call, not recomputed expect(updated?.output?.step1Result).toBe('computed-call-1') @@ -243,10 +243,10 @@ export function createStepTests(createDialect: () => Dialect) { await step.run('myStep', () => 'hello') }, }) - const job = durably.register(stepEventsTestDef) + const d = durably.register({ job: stepEventsTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { @@ -271,14 +271,14 @@ export function createStepTests(createDialect: () => Dialect) { return { value } }, }) - const job = durably.register(asyncStepTestDef) + const d = durably.register({ job: asyncStepTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') expect(updated?.output).toEqual({ value: 'async-result' }) }, @@ -297,20 +297,20 @@ export function createStepTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(stepTimingTestDef) + const d = durably.register({ job: stepTimingTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, ) - const steps = await durably.storage.getSteps(run.id) + const steps = await d.storage.getSteps(run.id) expect(steps).toHaveLength(1) const step = steps[0] @@ -341,14 +341,14 @@ export function createStepTests(createDialect: () => Dialect) { step.progress(3, 3, 'Complete') }, }) - const job = durably.register(progressTestDef) + const d = durably.register({ job: progressTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, diff --git a/packages/durably/tests/shared/storage.shared.ts b/packages/durably/tests/shared/storage.shared.ts index 812038da..fcf47c89 100644 --- a/packages/durably/tests/shared/storage.shared.ts +++ b/packages/durably/tests/shared/storage.shared.ts @@ -59,6 +59,98 @@ export function createStorageTests(createDialect: () => Dialect) { expect(run!.jobName).toBe('test-job') }) + it('returns stepCount as 0 for new run', async () => { + const created = await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + }) + + const run = await durably.storage.getRun(created.id) + + expect(run).not.toBeNull() + expect(run!.stepCount).toBe(0) + }) + + it('returns stepCount reflecting completed steps', async () => { + const created = await durably.storage.createRun({ + jobName: 'test-job', + payload: {}, + }) + + // Add 3 steps + await durably.storage.createStep({ + runId: created.id, + name: 'step-1', + index: 0, + status: 'completed', + startedAt: new Date().toISOString(), + }) + await durably.storage.createStep({ + runId: created.id, + name: 'step-2', + index: 1, + status: 'completed', + startedAt: new Date().toISOString(), + }) + await durably.storage.createStep({ + runId: created.id, + name: 'step-3', + index: 2, + status: 'completed', + startedAt: new Date().toISOString(), + }) + + const run = await durably.storage.getRun(created.id) + + expect(run).not.toBeNull() + expect(run!.stepCount).toBe(3) + }) + + it('returns stepCount in getRuns', async () => { + const run1 = await durably.storage.createRun({ + jobName: 'job-a', + payload: {}, + }) + const run2 = await durably.storage.createRun({ + jobName: 'job-b', + payload: {}, + }) + + // Add 2 steps to run1 + await durably.storage.createStep({ + runId: run1.id, + name: 'step-1', + index: 0, + status: 'completed', + startedAt: new Date().toISOString(), + }) + await durably.storage.createStep({ + runId: run1.id, + name: 'step-2', + index: 1, + status: 'completed', + startedAt: new Date().toISOString(), + }) + + // Add 1 step to run2 + await durably.storage.createStep({ + runId: run2.id, + name: 'step-1', + index: 0, + status: 'completed', + startedAt: new Date().toISOString(), + }) + + const runs = await durably.storage.getRuns() + + // runs are ordered by created_at desc + const foundRun1 = runs.find((r) => r.id === run1.id) + const foundRun2 = runs.find((r) => r.id === run2.id) + + expect(foundRun1!.stepCount).toBe(2) + expect(foundRun2!.stepCount).toBe(1) + }) + it('returns null for non-existent run', async () => { const run = await durably.storage.getRun('non-existent-id') expect(run).toBeNull() diff --git a/packages/durably/tests/shared/worker.shared.ts b/packages/durably/tests/shared/worker.shared.ts index 58b58ad5..97d76013 100644 --- a/packages/durably/tests/shared/worker.shared.ts +++ b/packages/durably/tests/shared/worker.shared.ts @@ -28,15 +28,15 @@ export function createWorkerTests(createDialect: () => Dialect) { output: z.object({ done: z.boolean() }), run: async () => ({ done: true }), }) - const job = durably.register(pollingTestDef) + const d = durably.register({ job: pollingTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() // Wait for polling to pick up the job await vi.waitFor( async () => { - const run = (await job.getRuns())[0] + const run = (await d.jobs.job.getRuns())[0] expect(run.status).toBe('completed') }, { timeout: 1000 }, @@ -55,17 +55,17 @@ export function createWorkerTests(createDialect: () => Dialect) { }) }, }) - const job = durably.register(stopTestDef) + const d = durably.register({ job: stopTestDef }) - await job.trigger({}) - durably.start() + await d.jobs.job.trigger({}) + d.start() // Wait a bit then stop await new Promise((r) => setTimeout(r, 50)) - await durably.stop() + await d.stop() expect(stepExecuted).toBe(true) - const run = (await job.getRuns())[0] + const run = (await d.jobs.job.getRuns())[0] expect(run.status).toBe('completed') }) @@ -92,16 +92,16 @@ export function createWorkerTests(createDialect: () => Dialect) { output: z.object({ value: z.number() }), run: async () => ({ value: 42 }), }) - const job = durably.register(stateTestDef) + const d = durably.register({ job: stateTestDef }) - const run = await job.trigger({}) + const run = await d.jobs.job.trigger({}) expect(run.status).toBe('pending') - durably.start() + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') }, { timeout: 1000 }, @@ -118,14 +118,14 @@ export function createWorkerTests(createDialect: () => Dialect) { throw new Error('Job failed intentionally') }, }) - const job = durably.register(failTestDef) + const d = durably.register({ job: failTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('failed') expect(updated?.error).toContain('Job failed intentionally') }, @@ -145,10 +145,10 @@ export function createWorkerTests(createDialect: () => Dialect) { receivedPayload = payload }, }) - const job = durably.register(payloadTestDef) + const d = durably.register({ job: payloadTestDef }) - await job.trigger({ value: 'hello' }) - durably.start() + await d.jobs.job.trigger({ value: 'hello' }) + d.start() await vi.waitFor( async () => { @@ -165,14 +165,14 @@ export function createWorkerTests(createDialect: () => Dialect) { output: z.object({ result: z.number() }), run: async () => ({ result: 123 }), }) - const job = durably.register(outputTestDef) + const d = durably.register({ job: outputTestDef }) - const run = await job.trigger({}) - durably.start() + const run = await d.jobs.job.trigger({}) + d.start() await vi.waitFor( async () => { - const updated = await job.getRun(run.id) + const updated = await d.jobs.job.getRun(run.id) expect(updated?.status).toBe('completed') expect(updated?.output).toEqual({ result: 123 }) }, @@ -191,17 +191,17 @@ export function createWorkerTests(createDialect: () => Dialect) { await new Promise((r) => setTimeout(r, 20)) }, }) - const job = durably.register(sequentialTestDef) + const d = durably.register({ job: sequentialTestDef }) - await job.trigger({ n: 1 }) - await job.trigger({ n: 2 }) - await job.trigger({ n: 3 }) + await d.jobs.job.trigger({ n: 1 }) + await d.jobs.job.trigger({ n: 2 }) + await d.jobs.job.trigger({ n: 3 }) - durably.start() + d.start() await vi.waitFor( async () => { - const runs = await job.getRuns() + const runs = await d.jobs.job.getRuns() const allCompleted = runs.every((r) => r.status === 'completed') expect(allCompleted).toBe(true) }, diff --git a/packages/durably/vitest.config.ts b/packages/durably/vitest.config.ts index 280d4043..b757e62f 100644 --- a/packages/durably/vitest.config.ts +++ b/packages/durably/vitest.config.ts @@ -3,5 +3,12 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['tests/node/**/*.test.ts'], + coverage: { + enabled: true, + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts', 'src/index.ts'], + reporter: ['text', 'text-summary'], + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e019bb4..e86daddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,132 +8,259 @@ importers: .: devDependencies: + '@biomejs/biome': + specifier: ^2.3.10 + version: 2.3.10 '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@vitest/coverage-v8': + specifier: 4.0.16 + version: 4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16))(vitest@4.0.16) cc-hooks-ts: - specifier: 2.0.70 - version: 2.0.70(typescript@5.9.3)(zod@4.2.1) + specifier: 2.0.76 + version: 2.0.76(typescript@5.9.3)(zod@4.3.4) tsx: specifier: ^4.21.0 version: 4.21.0 + turbo: + specifier: 2.7.2 + version: 2.7.2 typescript: specifier: ^5.9.3 version: 5.9.3 - examples/browser: + examples/browser-react-router-spa: dependencies: '@coji/durably': specifier: workspace:* version: link:../../packages/durably + '@coji/durably-react': + specifier: workspace:* + version: link:../../packages/durably-react + '@react-router/node': + specifier: 7.11.0 + version: 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + isbot: + specifier: ^5.1.32 + version: 5.1.32 kysely: specifier: ^0.28.9 version: 0.28.9 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + react-router: + specifier: 7.11.0 + version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) sqlocal: specifier: ^0.16.0 - version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) + version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.4 + version: 4.3.4 devDependencies: '@biomejs/biome': specifier: ^2.3.10 version: 2.3.10 + '@react-router/dev': + specifier: 7.11.0 + version: 7.11.0(@react-router/serve@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) prettier: specifier: ^3.7.4 version: 3.7.4 prettier-plugin-organize-imports: specifier: ^4.3.0 version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 typescript: specifier: ^5.9.3 version: 5.9.3 vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-tsconfig-paths: + specifier: ^6.0.3 + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) - examples/node: + examples/browser-vite-react: dependencies: '@coji/durably': specifier: workspace:* version: link:../../packages/durably - '@libsql/client': - specifier: ^0.15.15 - version: 0.15.15 - '@libsql/kysely-libsql': - specifier: ^0.4.1 - version: 0.4.1(kysely@0.28.9) + '@coji/durably-react': + specifier: workspace:* + version: link:../../packages/durably-react kysely: specifier: ^0.28.9 version: 0.28.9 + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + sqlocal: + specifier: ^0.16.0 + version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.4 + version: 4.3.4 devDependencies: '@biomejs/biome': specifier: ^2.3.10 version: 2.3.10 - '@types/node': - specifier: ^25.0.3 - version: 25.0.3 + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) prettier: specifier: ^3.7.4 version: 3.7.4 prettier-plugin-organize-imports: specifier: ^4.3.0 version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.21.0 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 typescript: specifier: ^5.9.3 version: 5.9.3 + vite: + specifier: ^7.3.0 + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) - examples/react: + examples/fullstack-react-router: dependencies: '@coji/durably': specifier: workspace:* version: link:../../packages/durably - kysely: - specifier: ^0.28.9 - version: 0.28.9 + '@coji/durably-react': + specifier: workspace:* + version: link:../../packages/durably-react + '@libsql/kysely-libsql': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.28.9) + '@react-router/node': + specifier: 7.11.0 + version: 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@react-router/serve': + specifier: 7.11.0 + version: 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + isbot: + specifier: ^5.1.31 + version: 5.1.32 react: specifier: ^19.2.3 version: 19.2.3 react-dom: specifier: ^19.2.3 version: 19.2.3(react@19.2.3) - sqlocal: - specifier: ^0.16.0 - version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) + react-router: + specifier: 7.11.0 + version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.4 + version: 4.3.4 devDependencies: '@biomejs/biome': specifier: ^2.3.10 version: 2.3.10 + '@react-router/dev': + specifier: 7.11.0 + version: 7.11.0(@react-router/serve@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@tailwindcss/vite': + specifier: ^4.1.13 + version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.7) - '@vitejs/plugin-react': - specifier: ^5.1.2 - version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) prettier: specifier: ^3.7.4 version: 3.7.4 prettier-plugin-organize-imports: specifier: ^4.3.0 version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) + tailwindcss: + specifier: ^4.1.13 + version: 4.1.18 typescript: - specifier: ^5.9.3 + specifier: ^5.9.2 version: 5.9.3 vite: - specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + specifier: ^7.1.7 + version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-tsconfig-paths: + specifier: ^6.0.3 + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + + examples/server-node: + dependencies: + '@coji/durably': + specifier: workspace:* + version: link:../../packages/durably + '@libsql/client': + specifier: ^0.15.15 + version: 0.15.15 + '@libsql/kysely-libsql': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.28.9) + kysely: + specifier: ^0.28.9 + version: 0.28.9 + zod: + specifier: ^4.3.4 + version: 4.3.4 + devDependencies: + '@biomejs/biome': + specifier: ^2.3.10 + version: 2.3.10 + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + prettier: + specifier: ^3.7.4 + version: 3.7.4 + prettier-plugin-organize-imports: + specifier: ^4.3.0 + version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 packages/durably: dependencies: @@ -161,16 +288,76 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/browser': specifier: ^4.0.16 - version: 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16) + version: 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) '@vitest/browser-playwright': specifier: 4.0.16 - version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16) + version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) jsdom: specifier: ^27.3.0 - version: 27.3.0 + version: 27.4.0 + kysely: + specifier: ^0.28.9 + version: 0.28.9 + playwright: + specifier: ^1.57.0 + version: 1.57.0 + prettier: + specifier: ^3.7.4 + version: 3.7.4 + prettier-plugin-organize-imports: + specifier: ^4.3.0 + version: 4.3.0(prettier@3.7.4)(typescript@5.9.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + sqlocal: + specifier: ^0.16.0 + version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) + zod: + specifier: ^4.3.4 + version: 4.3.4 + + packages/durably-react: + devDependencies: + '@biomejs/biome': + specifier: ^2.3.10 + version: 2.3.10 + '@coji/durably': + specifier: workspace:* + version: link:../durably + '@testing-library/react': + specifier: ^16.3.1 + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/browser': + specifier: ^4.0.16 + version: 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) + '@vitest/browser-playwright': + specifier: 4.0.16 + version: 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) kysely: specifier: ^0.28.9 version: 0.28.9 @@ -191,33 +378,33 @@ importers: version: 19.2.3(react@19.2.3) sqlocal: specifier: ^0.16.0 - version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) + version: 0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.0.16 - version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jsdom@27.3.0)(tsx@4.21.0) + version: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.4 + version: 4.3.4 website: devDependencies: vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.46.1)(@types/node@25.0.3)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(lightningcss@1.30.2)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) packages: - '@acemir/cssom@0.9.29': - resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} + '@acemir/cssom@0.9.30': + resolution: {integrity: sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==} - '@algolia/abtesting@1.12.1': - resolution: {integrity: sha512-Y+7e2uPe376OH5O73OB1+vR40ZhbV2kzGh/AR/dPCWguoBOp1IK0o+uZQLX+7i32RMMBEKl3pj6KVEav100Kvg==} + '@algolia/abtesting@1.12.2': + resolution: {integrity: sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ==} engines: {node: '>= 14.0.0'} '@algolia/autocomplete-core@1.17.7': @@ -240,63 +427,63 @@ packages: '@algolia/client-search': '>= 4.9.1 < 6' algoliasearch: '>= 4.9.1 < 6' - '@algolia/client-abtesting@5.46.1': - resolution: {integrity: sha512-5SWfl0UGuKxMBYlU2Y9BnlIKKEyhFU5jHE9F9jAd8nbhxZNLk0y7fXE+AZeFtyK1lkVw6O4B/e6c3XIVVCkmqw==} + '@algolia/client-abtesting@5.46.2': + resolution: {integrity: sha512-oRSUHbylGIuxrlzdPA8FPJuwrLLRavOhAmFGgdAvMcX47XsyM+IOGa9tc7/K5SPvBqn4nhppOCEz7BrzOPWc4A==} engines: {node: '>= 14.0.0'} - '@algolia/client-analytics@5.46.1': - resolution: {integrity: sha512-496K6B1l/0Jvyp3MbW/YIgmm1a6nkTrKXBM7DoEy9YAOJ8GywGpa2UYjNCW1UrOTt+em1ECzDjRx7PIzTR9YvA==} + '@algolia/client-analytics@5.46.2': + resolution: {integrity: sha512-EPBN2Oruw0maWOF4OgGPfioTvd+gmiNwx0HmD9IgmlS+l75DatcBkKOPNJN+0z3wBQWUO5oq602ATxIfmTQ8bA==} engines: {node: '>= 14.0.0'} - '@algolia/client-common@5.46.1': - resolution: {integrity: sha512-3u6AuZ1Kiss6V5JPuZfVIUYfPi8im06QBCgKqLg82GUBJ3SwhiTdSZFIEgz2mzFuitFdW1PQi3c/65zE/3FgIw==} + '@algolia/client-common@5.46.2': + resolution: {integrity: sha512-Hj8gswSJNKZ0oyd0wWissqyasm+wTz1oIsv5ZmLarzOZAp3vFEda8bpDQ8PUhO+DfkbiLyVnAxsPe4cGzWtqkg==} engines: {node: '>= 14.0.0'} - '@algolia/client-insights@5.46.1': - resolution: {integrity: sha512-LwuWjdO35HHl1rxtdn48t920Xl26Dl0SMxjxjFeAK/OwK/pIVfYjOZl/f3Pnm7Kixze+6HjpByVxEaqhTuAFaw==} + '@algolia/client-insights@5.46.2': + resolution: {integrity: sha512-6dBZko2jt8FmQcHCbmNLB0kCV079Mx/DJcySTL3wirgDBUH7xhY1pOuUTLMiGkqM5D8moVZTvTdRKZUJRkrwBA==} engines: {node: '>= 14.0.0'} - '@algolia/client-personalization@5.46.1': - resolution: {integrity: sha512-6LvJAlfEsn9SVq63MYAFX2iUxztUK2Q7BVZtI1vN87lDiJ/tSVFKgKS/jBVO03A39ePxJQiFv6EKv7lmoGlWtQ==} + '@algolia/client-personalization@5.46.2': + resolution: {integrity: sha512-1waE2Uqh/PHNeDXGn/PM/WrmYOBiUGSVxAWqiJIj73jqPqvfzZgzdakHscIVaDl6Cp+j5dwjsZ5LCgaUr6DtmA==} engines: {node: '>= 14.0.0'} - '@algolia/client-query-suggestions@5.46.1': - resolution: {integrity: sha512-9GLUCyGGo7YOXHcNqbzca82XYHJTbuiI6iT0FTGc0BrnV2N4OcrznUuVKic/duiLSun5gcy/G2Bciw5Sav9f9w==} + '@algolia/client-query-suggestions@5.46.2': + resolution: {integrity: sha512-EgOzTZkyDcNL6DV0V/24+oBJ+hKo0wNgyrOX/mePBM9bc9huHxIY2352sXmoZ648JXXY2x//V1kropF/Spx83w==} engines: {node: '>= 14.0.0'} - '@algolia/client-search@5.46.1': - resolution: {integrity: sha512-NL76o/BoEgU4ObY5oBEC3o6KSPpuXsnSta00tAxTm1iKUWOGR34DQEKhUt8xMHhMKleUNPM/rLPFiIVtfsGU8w==} + '@algolia/client-search@5.46.2': + resolution: {integrity: sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA==} engines: {node: '>= 14.0.0'} - '@algolia/ingestion@1.46.1': - resolution: {integrity: sha512-52Nc8WKC1FFXsdlXlTMl1Re/pTAbd2DiJiNdYmgHiikZcfF96G+Opx4qKiLUG1q7zp9e+ahNwXF6ED0XChMywg==} + '@algolia/ingestion@1.46.2': + resolution: {integrity: sha512-1Uw2OslTWiOFDtt83y0bGiErJYy5MizadV0nHnOoHFWMoDqWW0kQoMFI65pXqRSkVvit5zjXSLik2xMiyQJDWQ==} engines: {node: '>= 14.0.0'} - '@algolia/monitoring@1.46.1': - resolution: {integrity: sha512-1x2/2Y/eqz6l3QcEZ8u/zMhSCpjlhePyizJd3sXrmg031HjayYT5+IxikjpqkdF7TU/deCTd/TFUcxLJ2ZHXiQ==} + '@algolia/monitoring@1.46.2': + resolution: {integrity: sha512-xk9f+DPtNcddWN6E7n1hyNNsATBCHIqAvVGG2EAGHJc4AFYL18uM/kMTiOKXE/LKDPyy1JhIerrh9oYb7RBrgw==} engines: {node: '>= 14.0.0'} - '@algolia/recommend@5.46.1': - resolution: {integrity: sha512-SSd3KlQuplxV3aRs5+Z09XilFesgpPjtCG7BGRxLTVje5hn9BLmhjO4W3gKw01INUt44Z1r0Fwx5uqnhAouunA==} + '@algolia/recommend@5.46.2': + resolution: {integrity: sha512-NApbTPj9LxGzNw4dYnZmj2BoXiAc8NmbbH6qBNzQgXklGklt/xldTvu+FACN6ltFsTzoNU6j2mWNlHQTKGC5+Q==} engines: {node: '>= 14.0.0'} - '@algolia/requester-browser-xhr@5.46.1': - resolution: {integrity: sha512-3GfCwudeW6/3caKSdmOP6RXZEL4F3GiemCaXEStkTt2Re8f7NcGYAAZnGlHsCzvhlNEuDzPYdYxh4UweY8l/2w==} + '@algolia/requester-browser-xhr@5.46.2': + resolution: {integrity: sha512-ekotpCwpSp033DIIrsTpYlGUCF6momkgupRV/FA3m62SreTSZUKjgK6VTNyG7TtYfq9YFm/pnh65bATP/ZWJEg==} engines: {node: '>= 14.0.0'} - '@algolia/requester-fetch@5.46.1': - resolution: {integrity: sha512-JUAxYfmnLYTVtAOFxVvXJ4GDHIhMuaP7JGyZXa/nCk3P8RrN5FCNTdRyftSnxyzwSIAd8qH3CjdBS9WwxxqcHQ==} + '@algolia/requester-fetch@5.46.2': + resolution: {integrity: sha512-gKE+ZFi/6y7saTr34wS0SqYFDcjHW4Wminv8PDZEi0/mE99+hSrbKgJWxo2ztb5eqGirQTgIh1AMVacGGWM1iw==} engines: {node: '>= 14.0.0'} - '@algolia/requester-node-http@5.46.1': - resolution: {integrity: sha512-VwbhV1xvTGiek3d2pOS6vNBC4dtbNadyRT+i1niZpGhOJWz1XnfhxNboVbXPGAyMJYz7kDrolbDvEzIDT93uUA==} + '@algolia/requester-node-http@5.46.2': + resolution: {integrity: sha512-ciPihkletp7ttweJ8Zt+GukSVLp2ANJHU+9ttiSxsJZThXc4Y2yJ8HGVWesW5jN1zrsZsezN71KrMx/iZsOYpg==} engines: {node: '>= 14.0.0'} - '@anthropic-ai/claude-agent-sdk@0.1.70': - resolution: {integrity: sha512-4jpFPDX8asys6skO1r3Pzh0Fe9nbND2ASYTWuyFB5iN9bWEL6WScTFyGokjql3M2TkEp9ZGuB2YYpTCdaqT9Sw==} + '@anthropic-ai/claude-agent-sdk@0.1.76': + resolution: {integrity: sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.24.1 + zod: ^3.24.1 || ^4.0.0 '@asamuzakjp/css-color@4.1.1': resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} @@ -323,14 +510,28 @@ packages: resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.28.5': + resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} @@ -341,10 +542,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.27.1': resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} + '@babel/helper-replace-supers@7.27.1': + resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -366,6 +581,24 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.27.1': + resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -378,6 +611,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.28.5': + resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} @@ -394,6 +639,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.3.10': resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} engines: {node: '>=14.21.3'} @@ -796,8 +1045,17 @@ packages: cpu: [x64] os: [win32] - '@iconify-json/simple-icons@1.2.63': - resolution: {integrity: sha512-xZl2UWCwE58VlqZ+pDPmaUhE2tq8MVSTJRr4/9nzzHlDdjJ0Ud1VxNXPrwTSgESKY29iCQw3S0r2nJTSNNngHw==} + '@exodus/bytes@1.8.0': + resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@exodus/crypto': ^1.0.0-rc.4 + peerDependenciesMeta: + '@exodus/crypto': + optional: true + + '@iconify-json/simple-icons@1.2.64': + resolution: {integrity: sha512-SMmm//tjZBvHnT0EAzZLnBTL6bukSkncM0pwkOXjr0FsAeCqjQtqoxBR0Mp+PazIJjXJKHm1Ju0YgnCIPOodJg==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -1015,12 +1273,70 @@ packages: cpu: [x64] os: [win32] + '@mjackson/node-fetch-server@0.2.0': + resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@react-router/dev@7.11.0': + resolution: {integrity: sha512-g1ou5Zw3r4mCU0L+EXH4vRtAiyt8qz1JOvL1k+PW4rZ4+71h5nBy/fLgD7cg5BnzQZmjRO1PzCgpF5BIrlKYxQ==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@react-router/serve': ^7.11.0 + '@vitejs/plugin-rsc': ~0.5.7 + react-router: ^7.11.0 + react-server-dom-webpack: ^19.2.3 + typescript: ^5.1.0 + vite: ^5.1.0 || ^6.0.0 || ^7.0.0 + wrangler: ^3.28.2 || ^4.0.0 + peerDependenciesMeta: + '@react-router/serve': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-webpack: + optional: true + typescript: + optional: true + wrangler: + optional: true + + '@react-router/express@7.11.0': + resolution: {integrity: sha512-o5DeO9tqUrZcUWAgmPGgK4I/S6iFpqnj/e20xMGA04trk+90b9KAx9eqmRMgHERubVKANTM9gTDPduobQjeH1A==} + engines: {node: '>=20.0.0'} + peerDependencies: + express: ^4.17.1 || ^5 + react-router: 7.11.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/node@7.11.0': + resolution: {integrity: sha512-11ha8EW+F7wTMmPz2pdi11LJxz2irtuksiCpunpZjtpPmYU37S+GGihG8vFeTa2xFPNunEaHNlfzKyzeYm570Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + react-router: 7.11.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@react-router/serve@7.11.0': + resolution: {integrity: sha512-U5Ht9PmUYF4Ti1ssaWlddLY4ZCbXBtHDGFU/u1h3VsHqleSdHsFuGAFrr/ZEuqTuEWp1CLqn2npEDAmlV9IUKQ==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + react-router: 7.11.0 + + '@remix-run/node-fetch-server@0.9.0': + resolution: {integrity: sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==} + '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -1165,36 +1481,126 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - '@testing-library/react@16.3.1': - resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.1': + resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} @@ -1273,6 +1679,15 @@ packages: peerDependencies: vitest: 4.0.16 + '@vitest/coverage-v8@4.0.16': + resolution: {integrity: sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==} + peerDependencies: + '@vitest/browser': 4.0.16 + vitest: 4.0.16 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.16': resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} @@ -1390,6 +1805,10 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -1399,8 +1818,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - algoliasearch@5.46.1: - resolution: {integrity: sha512-39ol8Ulqb3MntofkXHlrcXKyU8BU0PXvQrXPBIX6eXj/EO4VT7651mhGVORI2oF8ydya9nFzT3fYDoqme/KL6w==} + algoliasearch@5.46.2: + resolution: {integrity: sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q==} engines: {node: '>= 14.0.0'} ansi-regex@5.0.1: @@ -1414,50 +1833,85 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.10: + resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + + babel-dead-code-elimination@1.0.11: + resolution: {integrity: sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ==} + baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - caniuse-lite@1.0.30001761: - resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} - cc-hooks-ts@2.0.70: - resolution: {integrity: sha512-VIUAEXw/huaTev21xlKjCpWso51vHRIuodGc6T0ihVyA7BXwmQHlfxBnhfsvYtTbo9xVsijSPIgwS7/flJOjAQ==} + cc-hooks-ts@2.0.76: + resolution: {integrity: sha512-5b4UyCiqCxUM4UfvWYASTP5DqdNsHuAq4Zyks5uNqgGs4DQo2BAhFTQG8g5vj4SqLXYaFjszBDtsGhi3lkAkCw==} engines: {node: ^20.12.0 || ^22.0.0 || >=24.0.0} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} character-entities-html4@2.1.0: @@ -1480,16 +1934,46 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -1498,8 +1982,8 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - cssstyle@5.3.5: - resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} + cssstyle@5.3.6: + resolution: {integrity: sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==} engines: {node: '>=20'} csstype@3.2.3: @@ -1513,6 +1997,14 @@ packages: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1525,26 +2017,61 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1553,9 +2080,21 @@ packages: resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1570,16 +2109,34 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1593,16 +2150,28 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - focus-trap@7.7.0: - resolution: {integrity: sha512-DJJDHpEgoSbP8ZE1MNeU2IzCpfFyFdNZZRilqmfH2XiQsPK6PtD8AfJqWzEBudUQB2yHwZc5iq54rjTaGQ+ljw==} + focus-trap@7.7.1: + resolution: {integrity: sha512-Pkp8m55GjxBLnhBoT6OXdMvfRr4TjMAKLvFM566zlIryq5plbhaTmLAJWTGR0EkRwLjEte1lCOG9MxF1ipJrOg==} formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1613,6 +2182,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gc-hook@0.3.1: resolution: {integrity: sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==} @@ -1620,9 +2192,43 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -1632,13 +2238,20 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1647,10 +2260,17 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1658,6 +2278,30 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + isbot@5.1.32: + resolution: {integrity: sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1668,8 +2312,11 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - jsdom@27.3.0: - resolution: {integrity: sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -1677,8 +2324,8 @@ packages: canvas: optional: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} hasBin: true @@ -1704,6 +2351,76 @@ packages: cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1715,6 +2432,9 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -1729,15 +2449,37 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.1: + resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -1753,6 +2495,23 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1762,10 +2521,17 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1777,6 +2543,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -1793,15 +2567,45 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1826,6 +2630,9 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -1862,8 +2669,8 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - preact@10.28.0: - resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} + preact@10.28.1: + resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==} prettier-plugin-organize-imports@4.3.0: resolution: {integrity: sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==} @@ -1890,6 +2697,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-target@3.0.2: resolution: {integrity: sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==} @@ -1897,6 +2708,18 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: @@ -1905,10 +2728,24 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-router@7.11.0: + resolution: {integrity: sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -1945,6 +2782,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1962,9 +2805,44 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1976,6 +2854,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -2013,6 +2898,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2028,11 +2917,22 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tabbable@6.3.0: - resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} @@ -2066,6 +2966,10 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -2088,6 +2992,16 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -2112,16 +3026,54 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true + turbo-darwin-64@2.7.2: + resolution: {integrity: sha512-dxY3X6ezcT5vm3coK6VGixbrhplbQMwgNsCsvZamS/+/6JiebqW9DKt4NwpgYXhDY2HdH00I7FWs3wkVuan4rA==} + cpu: [x64] + os: [darwin] - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + turbo-darwin-arm64@2.7.2: + resolution: {integrity: sha512-1bXmuwPLqNFt3mzrtYcVx1sdJ8UYb124Bf48nIgcpMCGZy3kDhgxNv1503kmuK/37OGOZbsWSQFU4I08feIuSg==} + cpu: [arm64] + os: [darwin] - ulidx@2.4.1: - resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} + turbo-linux-64@2.7.2: + resolution: {integrity: sha512-kP+TiiMaiPugbRlv57VGLfcjFNsFbo8H64wMBCPV2270Or2TpDCBULMzZrvEsvWFjT3pBFvToYbdp8/Kw0jAQg==} + cpu: [x64] + os: [linux] + + turbo-linux-arm64@2.7.2: + resolution: {integrity: sha512-VDJwQ0+8zjAfbyY6boNaWfP6RIez4ypKHxwkuB6SrWbOSk+vxTyW5/hEjytTwK8w/TsbKVcMDyvpora8tEsRFw==} + cpu: [arm64] + os: [linux] + + turbo-windows-64@2.7.2: + resolution: {integrity: sha512-rPjqQXVnI6A6oxgzNEE8DNb6Vdj2Wwyhfv3oDc+YM3U9P7CAcBIlKv/868mKl4vsBtz4ouWpTQNXG8vljgJO+w==} + cpu: [x64] + os: [win32] + + turbo-windows-arm64@2.7.2: + resolution: {integrity: sha512-tcnHvBhO515OheIFWdxA+qUvZzNqqcHbLVFc1+n+TJ1rrp8prYicQtbtmsiKgMvr/54jb9jOabU62URAobnB7g==} + cpu: [arm64] + os: [win32] + + turbo@2.7.2: + resolution: {integrity: sha512-5JIA5aYBAJSAhrhbyag1ZuMSgUZnHtI+Sq3H8D3an4fL8PeF+L1yYvbEJg47akP1PFfATMf5ehkqFnxfkmuwZQ==} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + ulidx@2.4.1: + resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} engines: {node: '>=16'} undici-types@7.16.0: @@ -2142,12 +3094,20 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -2156,12 +3116,29 @@ packages: typescript: optional: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@6.0.3: + resolution: {integrity: sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2299,10 +3276,6 @@ packages: resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} engines: {node: '>=20'} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -2338,131 +3311,131 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.4: + resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: - '@acemir/cssom@0.9.29': {} + '@acemir/cssom@0.9.30': {} - '@algolia/abtesting@1.12.1': + '@algolia/abtesting@1.12.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)(search-insights@2.17.3)': + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)(search-insights@2.17.3)': dependencies: - '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)(search-insights@2.17.3) - '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1) + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2) transitivePeerDependencies: - '@algolia/client-search' - algoliasearch - search-insights - '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)(search-insights@2.17.3)': + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)(search-insights@2.17.3)': dependencies: - '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2) search-insights: 2.17.3 transitivePeerDependencies: - '@algolia/client-search' - algoliasearch - '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)': + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)': dependencies: - '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1) - '@algolia/client-search': 5.46.1 - algoliasearch: 5.46.1 + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2) + '@algolia/client-search': 5.46.2 + algoliasearch: 5.46.2 - '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)': + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)': dependencies: - '@algolia/client-search': 5.46.1 - algoliasearch: 5.46.1 + '@algolia/client-search': 5.46.2 + algoliasearch: 5.46.2 - '@algolia/client-abtesting@5.46.1': + '@algolia/client-abtesting@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/client-analytics@5.46.1': + '@algolia/client-analytics@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/client-common@5.46.1': {} + '@algolia/client-common@5.46.2': {} - '@algolia/client-insights@5.46.1': + '@algolia/client-insights@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/client-personalization@5.46.1': + '@algolia/client-personalization@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/client-query-suggestions@5.46.1': + '@algolia/client-query-suggestions@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/client-search@5.46.1': + '@algolia/client-search@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/ingestion@1.46.1': + '@algolia/ingestion@1.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/monitoring@1.46.1': + '@algolia/monitoring@1.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/recommend@5.46.1': + '@algolia/recommend@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + '@algolia/client-common': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 - '@algolia/requester-browser-xhr@5.46.1': + '@algolia/requester-browser-xhr@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 + '@algolia/client-common': 5.46.2 - '@algolia/requester-fetch@5.46.1': + '@algolia/requester-fetch@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 + '@algolia/client-common': 5.46.2 - '@algolia/requester-node-http@5.46.1': + '@algolia/requester-node-http@5.46.2': dependencies: - '@algolia/client-common': 5.46.1 + '@algolia/client-common': 5.46.2 - '@anthropic-ai/claude-agent-sdk@0.1.70(zod@4.2.1)': + '@anthropic-ai/claude-agent-sdk@0.1.76(zod@4.3.4)': dependencies: - zod: 4.2.1 + zod: 4.3.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -2525,7 +3498,11 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + jsesc: 3.0.2 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -2535,8 +3512,28 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.5 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -2553,8 +3550,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.5 + '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2570,6 +3587,24 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -2580,6 +3615,28 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': @@ -2605,6 +3662,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.3.10': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.10 @@ -2664,10 +3723,10 @@ snapshots: '@docsearch/css@3.8.2': {} - '@docsearch/js@3.8.2(@algolia/client-search@5.46.1)(search-insights@2.17.3)': + '@docsearch/js@3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3)': dependencies: - '@docsearch/react': 3.8.2(@algolia/client-search@5.46.1)(search-insights@2.17.3) - preact: 10.28.0 + '@docsearch/react': 3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3) + preact: 10.28.1 transitivePeerDependencies: - '@algolia/client-search' - '@types/react' @@ -2675,12 +3734,12 @@ snapshots: - react-dom - search-insights - '@docsearch/react@3.8.2(@algolia/client-search@5.46.1)(search-insights@2.17.3)': + '@docsearch/react@3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3)': dependencies: - '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1)(search-insights@2.17.3) - '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.46.1)(algoliasearch@5.46.1) + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.46.2)(algoliasearch@5.46.2) '@docsearch/css': 3.8.2 - algoliasearch: 5.46.1 + algoliasearch: 5.46.2 optionalDependencies: search-insights: 2.17.3 transitivePeerDependencies: @@ -2833,7 +3892,9 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@iconify-json/simple-icons@1.2.63': + '@exodus/bytes@1.8.0': {} + + '@iconify-json/simple-icons@1.2.64': dependencies: '@iconify/types': 2.0.0 @@ -3035,10 +4096,94 @@ snapshots: '@libsql/win32-x64-msvc@0.5.22': optional: true + '@mjackson/node-fetch-server@0.2.0': {} + '@neon-rs/load@0.0.4': {} '@polka/url@1.0.0-next.29': {} + '@react-router/dev@7.11.0(@react-router/serve@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3))(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.28.5 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@react-router/node': 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@remix-run/node-fetch-server': 0.9.0 + arg: 5.0.2 + babel-dead-code-elimination: 1.0.11 + chokidar: 4.0.3 + dedent: 1.7.1 + es-module-lexer: 1.7.0 + exit-hook: 2.2.1 + isbot: 5.1.32 + jsesc: 3.0.2 + lodash: 4.17.21 + p-map: 7.0.4 + pathe: 1.1.2 + picocolors: 1.1.1 + pkg-types: 2.3.0 + prettier: 3.7.4 + react-refresh: 0.14.2 + react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + semver: 7.7.3 + tinyglobby: 0.2.15 + valibot: 1.2.0(typescript@5.9.3) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + optionalDependencies: + '@react-router/serve': 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + '@react-router/express@7.11.0(express@4.22.1)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@react-router/node': 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + express: 4.22.1 + react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/node@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@mjackson/node-fetch-server': 0.2.0 + react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + typescript: 5.9.3 + + '@react-router/serve@7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)': + dependencies: + '@mjackson/node-fetch-server': 0.2.0 + '@react-router/express': 7.11.0(express@4.22.1)(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + '@react-router/node': 7.11.0(react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3) + compression: 1.8.1 + express: 4.22.1 + get-port: 5.1.1 + morgan: 1.10.1 + react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + source-map-support: 0.5.21 + transitivePeerDependencies: + - supports-color + - typescript + + '@remix-run/node-fetch-server@0.9.0': {} + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/rollup-android-arm-eabi@4.54.0': @@ -3151,6 +4296,74 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -3245,7 +4458,7 @@ snapshots: '@ungap/with-resolvers@0.1.0': {} - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))': + '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -3253,38 +4466,38 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.0.3))(vue@3.5.26(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2))(vue@3.5.26(typescript@5.9.3))': dependencies: - vite: 5.4.21(@types/node@25.0.3) + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2) vue: 3.5.26(typescript@5.9.3) - '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16)': + '@vitest/browser-playwright@4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16)': dependencies: - '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16) - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) + '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) playwright: 1.57.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jsdom@27.3.0)(tsx@4.21.0) + vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16)': + '@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16)': dependencies: - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/utils': 4.0.16 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jsdom@27.3.0)(tsx@4.21.0) + vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -3292,22 +4505,41 @@ snapshots: - utf-8-validate - vite + '@vitest/coverage-v8@4.0.16(@vitest/browser@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16))(vitest@4.0.16)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.16 + ast-v8-to-istanbul: 0.3.10 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magicast: 0.5.1 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0) + optionalDependencies: + '@vitest/browser': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.0.16': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 '@vitest/spy': 4.0.16 '@vitest/utils': 4.0.16 - chai: 6.2.1 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))': + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) '@vitest/pretty-format@4.0.16': dependencies: @@ -3412,13 +4644,13 @@ snapshots: transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(focus-trap@7.7.0)(typescript@5.9.3)': + '@vueuse/integrations@12.8.2(focus-trap@7.7.1)(typescript@5.9.3)': dependencies: '@vueuse/core': 12.8.2(typescript@5.9.3) '@vueuse/shared': 12.8.2(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3) optionalDependencies: - focus-trap: 7.7.0 + focus-trap: 7.7.1 transitivePeerDependencies: - typescript @@ -3430,26 +4662,31 @@ snapshots: transitivePeerDependencies: - typescript + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn@8.15.0: {} agent-base@7.1.4: {} - algoliasearch@5.46.1: - dependencies: - '@algolia/abtesting': 1.12.1 - '@algolia/client-abtesting': 5.46.1 - '@algolia/client-analytics': 5.46.1 - '@algolia/client-common': 5.46.1 - '@algolia/client-insights': 5.46.1 - '@algolia/client-personalization': 5.46.1 - '@algolia/client-query-suggestions': 5.46.1 - '@algolia/client-search': 5.46.1 - '@algolia/ingestion': 1.46.1 - '@algolia/monitoring': 1.46.1 - '@algolia/recommend': 5.46.1 - '@algolia/requester-browser-xhr': 5.46.1 - '@algolia/requester-fetch': 5.46.1 - '@algolia/requester-node-http': 5.46.1 + algoliasearch@5.46.2: + dependencies: + '@algolia/abtesting': 1.12.2 + '@algolia/client-abtesting': 5.46.2 + '@algolia/client-analytics': 5.46.2 + '@algolia/client-common': 5.46.2 + '@algolia/client-insights': 5.46.2 + '@algolia/client-personalization': 5.46.2 + '@algolia/client-query-suggestions': 5.46.2 + '@algolia/client-search': 5.46.2 + '@algolia/ingestion': 1.46.2 + '@algolia/monitoring': 1.46.2 + '@algolia/recommend': 5.46.2 + '@algolia/requester-browser-xhr': 5.46.2 + '@algolia/requester-fetch': 5.46.2 + '@algolia/requester-node-http': 5.46.2 ansi-regex@5.0.1: {} @@ -3457,40 +4694,94 @@ snapshots: any-promise@1.3.0: {} + arg@5.0.2: {} + aria-query@5.3.0: dependencies: dequal: 2.0.3 + array-flatten@1.1.1: {} + assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.10: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + babel-dead-code-elimination@1.0.11: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + baseline-browser-mapping@2.9.11: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 birpc@2.9.0: {} + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-from@1.1.2: {} + bundle-require@5.1.0(esbuild@0.27.2): dependencies: esbuild: 0.27.2 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + cac@6.7.14: {} - caniuse-lite@1.0.30001761: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001762: {} - cc-hooks-ts@2.0.70(typescript@5.9.3)(zod@4.2.1): + cc-hooks-ts@2.0.76(typescript@5.9.3)(zod@4.3.4): dependencies: - '@anthropic-ai/claude-agent-sdk': 0.1.70(zod@4.2.1) + '@anthropic-ai/claude-agent-sdk': 0.1.76(zod@4.3.4) valibot: 1.2.0(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -3498,7 +4789,7 @@ snapshots: ccount@2.0.1: {} - chai@6.2.1: {} + chai@6.2.2: {} character-entities-html4@2.1.0: {} @@ -3524,12 +4815,42 @@ snapshots: commander@4.1.1: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + confbox@0.1.8: {} + confbox@0.2.2: {} + consola@3.4.2: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -3539,11 +4860,12 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 - cssstyle@5.3.5: + cssstyle@5.3.6: dependencies: '@asamuzakjp/css-color': 4.1.1 '@csstools/css-syntax-patches-for-csstree': 1.0.22 css-tree: 3.1.0 + lru-cache: 11.2.4 csstype@3.2.3: {} @@ -3554,32 +4876,67 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 decimal.js@10.6.0: {} + dedent@1.7.1: {} + + depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.267: {} emoji-regex-xs@1.0.0: {} + encodeurl@2.0.0: {} + + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + entities@6.0.1: {} entities@7.0.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -3637,14 +4994,58 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + estree-walker@2.0.2: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + exit-hook@2.2.1: {} + expect-type@1.3.0: {} + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -3654,34 +5055,86 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.54.0 - focus-trap@7.7.0: + focus-trap@7.7.1: dependencies: - tabbable: 6.3.0 + tabbable: 6.4.0 formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@0.5.2: {} + fsevents@2.3.2: optional: true fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gc-hook@0.3.1: {} gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -3702,12 +5155,24 @@ snapshots: hookable@5.5.3: {} - html-encoding-sniffer@4.0.0: + html-encoding-sniffer@6.0.0: dependencies: - whatwg-encoding: 3.1.1 + '@exodus/bytes': 1.8.0 + transitivePeerDependencies: + - '@exodus/crypto' + + html-escaper@2.0.2: {} html-void-elements@3.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -3722,28 +5187,60 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.6.3: + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + is-potential-custom-element-name@1.0.1: {} is-what@5.5.0: {} + isbot@5.1.32: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jiti@2.6.1: {} + joycon@3.1.1: {} js-base64@3.7.8: {} js-tokens@4.0.0: {} - jsdom@27.3.0: + js-tokens@9.0.1: {} + + jsdom@27.4.0: dependencies: - '@acemir/cssom': 0.9.29 + '@acemir/cssom': 0.9.30 '@asamuzakjp/dom-selector': 6.7.6 - cssstyle: 5.3.5 + '@exodus/bytes': 1.8.0 + cssstyle: 5.3.6 data-urls: 6.0.0 decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 + html-encoding-sniffer: 6.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -3753,17 +5250,17 @@ snapshots: tough-cookie: 6.0.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.0 - whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 ws: 8.18.3 xml-name-validator: 5.0.0 transitivePeerDependencies: + - '@exodus/crypto' - bufferutil - supports-color - utf-8-validate - jsesc@3.1.0: {} + jsesc@3.0.2: {} json5@2.2.3: {} @@ -3799,12 +5296,63 @@ snapshots: '@libsql/linux-x64-musl': 0.5.22 '@libsql/win32-x64-msvc': 0.5.22 + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} load-tsconfig@0.2.5: {} + lodash@4.17.21: {} + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -3817,8 +5365,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.1: + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + mark.js@8.11.1: {} + math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -3833,6 +5393,12 @@ snapshots: mdn-data@2.12.2: {} + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -3850,6 +5416,16 @@ snapshots: micromark-util-types@2.0.2: {} + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + minisearch@7.2.0: {} mitt@3.0.1: {} @@ -3861,8 +5437,20 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + mrmime@2.0.1: {} + ms@2.0.0: {} + ms@2.1.3: {} mz@2.7.0: @@ -3873,6 +5461,10 @@ snapshots: nanoid@3.3.11: {} + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -3885,18 +5477,38 @@ snapshots: object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + oniguruma-to-es@3.1.1: dependencies: emoji-regex-xs: 1.0.0 regex: 6.1.0 regex-recursion: 6.0.2 + p-map@7.0.4: {} + parse5@8.0.0: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + pathe@1.1.2: {} + pathe@2.0.3: {} perfect-debounce@1.0.0: {} @@ -3917,6 +5529,12 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + playwright-core@1.57.0: {} playwright@1.57.0: @@ -3927,10 +5545,11 @@ snapshots: pngjs@7.0.0: {} - postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.6.1 postcss: 8.5.6 tsx: 4.21.0 @@ -3940,7 +5559,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - preact@10.28.0: {} + preact@10.28.1: {} prettier-plugin-organize-imports@4.3.0(prettier@3.7.4)(typescript@5.9.3): dependencies: @@ -3959,10 +5578,28 @@ snapshots: property-information@7.1.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-target@3.0.2: {} punycode@2.3.1: {} + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 @@ -3970,8 +5607,18 @@ snapshots: react-is@17.0.2: {} + react-refresh@0.14.2: {} + react-refresh@0.18.0: {} + react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} readdirp@4.1.2: {} @@ -4022,6 +5669,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -4034,6 +5685,39 @@ snapshots: semver@6.3.1: {} + semver@7.7.3: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -4045,6 +5729,34 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} sirv@3.0.2: @@ -4055,20 +5767,27 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + source-map@0.7.6: {} space-separated-tokens@2.0.2: {} speakingurl@14.0.1: {} - sqlocal@0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)): + sqlocal@0.16.0(kysely@0.28.9)(react@19.2.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)): dependencies: '@sqlite.org/sqlite-wasm': 3.50.4-build1 coincident: 1.2.3 optionalDependencies: kysely: 0.28.9 react: 19.2.3 - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vue: 3.5.26(typescript@5.9.3) transitivePeerDependencies: - bufferutil @@ -4076,6 +5795,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stringify-entities@4.0.4: @@ -4097,9 +5818,17 @@ snapshots: dependencies: copy-anything: 4.0.5 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + symbol-tree@3.2.4: {} - tabbable@6.3.0: {} + tabbable@6.4.0: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} thenify-all@1.6.0: dependencies: @@ -4128,6 +5857,8 @@ snapshots: dependencies: tldts-core: 7.0.19 + toidentifier@1.0.1: {} + totalist@3.0.1: {} tough-cookie@6.0.0: @@ -4144,7 +5875,11 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 @@ -4155,7 +5890,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) resolve-from: 5.0.0 rollup: 4.54.0 source-map: 0.7.6 @@ -4179,6 +5914,38 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turbo-darwin-64@2.7.2: + optional: true + + turbo-darwin-arm64@2.7.2: + optional: true + + turbo-linux-64@2.7.2: + optional: true + + turbo-linux-arm64@2.7.2: + optional: true + + turbo-windows-64@2.7.2: + optional: true + + turbo-windows-arm64@2.7.2: + optional: true + + turbo@2.7.2: + optionalDependencies: + turbo-darwin-64: 2.7.2 + turbo-darwin-arm64: 2.7.2 + turbo-linux-64: 2.7.2 + turbo-linux-arm64: 2.7.2 + turbo-windows-64: 2.7.2 + turbo-windows-arm64: 2.7.2 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typescript@5.9.3: {} ufo@1.6.1: {} @@ -4212,16 +5979,22 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 + utils-merge@1.0.1: {} + valibot@1.2.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4232,7 +6005,39 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@5.4.21(@types/node@25.0.3): + vite-node@3.2.4(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + - typescript + + vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -4240,8 +6045,9 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 fsevents: 2.3.3 + lightningcss: 1.30.2 - vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0): + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -4252,27 +6058,29 @@ snapshots: optionalDependencies: '@types/node': 25.0.3 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 tsx: 4.21.0 - vitepress@1.6.4(@algolia/client-search@5.46.1)(@types/node@25.0.3)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@25.0.3)(lightningcss@1.30.2)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 - '@docsearch/js': 3.8.2(@algolia/client-search@5.46.1)(search-insights@2.17.3) - '@iconify-json/simple-icons': 1.2.63 + '@docsearch/js': 3.8.2(@algolia/client-search@5.46.2)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.64 '@shikijs/core': 2.5.0 '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.0.3))(vue@3.5.26(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.0.3)(lightningcss@1.30.2))(vue@3.5.26(typescript@5.9.3)) '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.26 '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/integrations': 12.8.2(focus-trap@7.7.0)(typescript@5.9.3) - focus-trap: 7.7.0 + '@vueuse/integrations': 12.8.2(focus-trap@7.7.1)(typescript@5.9.3) + focus-trap: 7.7.1 mark.js: 8.11.1 minisearch: 7.2.0 shiki: 2.5.0 - vite: 5.4.21(@types/node@25.0.3) + vite: 5.4.21(@types/node@25.0.3)(lightningcss@1.30.2) vue: 3.5.26(typescript@5.9.3) optionalDependencies: postcss: 8.5.6 @@ -4303,10 +6111,10 @@ snapshots: - typescript - universal-cookie - vitest@4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jsdom@27.3.0)(tsx@4.21.0): + vitest@4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -4323,12 +6131,12 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@25.0.3)(tsx@4.21.0) + vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.0.3 - '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0))(vitest@4.0.16) - jsdom: 27.3.0 + '@vitest/browser-playwright': 4.0.16(playwright@1.57.0)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vitest@4.0.16) + jsdom: 27.4.0 transitivePeerDependencies: - jiti - less @@ -4360,10 +6168,6 @@ snapshots: webidl-conversions@8.0.0: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-mimetype@4.0.0: {} whatwg-url@15.1.0: @@ -4384,6 +6188,6 @@ snapshots: yallist@3.1.1: {} - zod@4.2.1: {} + zod@4.3.4: {} zwitch@2.0.4: {} diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..adcdeaaf --- /dev/null +++ b/turbo.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", "build/**", ".vitepress/dist/**"] + }, + "dev": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["^build"] + }, + "test:node": { + "dependsOn": ["^build"] + }, + "test:browser": { + "dependsOn": ["^build"] + }, + "test:react": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + }, + "lint": {}, + "format": {} + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index d2266c8b..bbd99a6b 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -11,7 +11,7 @@ export default defineConfig({ logo: '/logo.svg', nav: [ - { text: 'Guide', link: '/guide/getting-started' }, + { text: 'Guide', link: '/guide/' }, { text: 'API', link: '/api/' }, { text: 'Demo', link: 'https://durably-demo.vercel.app' }, { text: 'llms.txt', link: '/durably/llms.txt' }, @@ -24,35 +24,127 @@ export default defineConfig({ items: [ { text: 'What is Durably?', link: '/guide/' }, { text: 'Getting Started', link: '/guide/getting-started' }, + { text: 'Core Concepts', link: '/guide/concepts' }, ], }, { - text: 'Core Concepts', + text: 'Use Cases', items: [ - { text: 'Jobs and Steps', link: '/guide/jobs-and-steps' }, - { text: 'Resumability', link: '/guide/resumability' }, - { text: 'Events', link: '/guide/events' }, + { text: 'CSV Import (Full-Stack)', link: '/guide/csv-import' }, + { text: 'Offline App (Browser)', link: '/guide/offline-app' }, + { text: 'Background Sync (Server)', link: '/guide/background-sync' }, ], }, + ], + '/api/': [ { - text: 'Platforms', + text: 'Getting Started', items: [ - { text: 'Node.js', link: '/guide/nodejs' }, - { text: 'Browser', link: '/guide/browser' }, - { text: 'React', link: '/guide/react' }, - { text: 'Deployment', link: '/guide/deployment' }, + { text: 'Quick Reference', link: '/api/' }, + ], + }, + { + text: 'Job Definition', + items: [ + { + text: 'defineJob', + link: '/api/define-job', + collapsed: false, + items: [ + { text: 'trigger', link: '/api/define-job#trigger' }, + { text: 'triggerAndWait', link: '/api/define-job#triggerandwait' }, + { text: 'batchTrigger', link: '/api/define-job#batchtrigger' }, + ], + }, + { + text: 'Step Context', + link: '/api/step', + collapsed: false, + items: [ + { text: 'step.run', link: '/api/step#run' }, + { text: 'step.progress', link: '/api/step#progress' }, + { text: 'step.log', link: '/api/step#log' }, + ], + }, + ], + }, + { + text: 'Instance & Lifecycle', + items: [ + { + text: 'createDurably', + link: '/api/create-durably', + collapsed: false, + items: [ + { text: 'init / migrate / start', link: '/api/create-durably#init' }, + { text: 'register', link: '/api/create-durably#register' }, + { text: 'on (events)', link: '/api/create-durably#on' }, + { text: 'stop', link: '/api/create-durably#stop' }, + { text: 'retry / cancel', link: '/api/create-durably#retry' }, + { text: 'getRun / getRuns', link: '/api/create-durably#getrun' }, + ], + }, + { + text: 'Events', + link: '/api/events', + collapsed: false, + items: [ + { text: 'Run Events', link: '/api/events#run-events' }, + { text: 'Step Events', link: '/api/events#step-events' }, + { text: 'Log Events', link: '/api/events#log-events' }, + { text: 'Worker Events', link: '/api/events#worker-events' }, + ], + }, + ], + }, + { + text: 'Server Integration', + items: [ + { + text: 'HTTP Handler', + link: '/api/http-handler', + collapsed: false, + items: [ + { text: 'createDurablyHandler', link: '/api/http-handler#createdurablyhandler' }, + { text: 'Framework Integration', link: '/api/http-handler#framework-integration' }, + { text: 'Endpoints', link: '/api/http-handler#endpoints' }, + { text: 'SSE Events', link: '/api/http-handler#sse-event-stream' }, + { text: 'Security', link: '/api/http-handler#security-considerations' }, + ], + }, ], }, - ], - '/api/': [ { - text: 'API Reference', + text: 'React Hooks', items: [ - { text: 'Overview', link: '/api/' }, - { text: 'createDurably', link: '/api/create-durably' }, - { text: 'defineJob', link: '/api/define-job' }, - { text: 'Step', link: '/api/step' }, - { text: 'Events', link: '/api/events' }, + { text: 'Overview', link: '/api/durably-react/' }, + { + text: 'Browser Hooks', + link: '/api/durably-react/browser', + collapsed: false, + items: [ + { text: 'DurablyProvider', link: '/api/durably-react/browser#durablyprovider' }, + { text: 'useDurably', link: '/api/durably-react/browser#usedurably' }, + { text: 'useJob', link: '/api/durably-react/browser#usejob' }, + { text: 'useJobRun', link: '/api/durably-react/browser#usejobrun' }, + { text: 'useJobLogs', link: '/api/durably-react/browser#usejoblogs' }, + { text: 'useRuns', link: '/api/durably-react/browser#useruns' }, + ], + }, + { + text: 'Server Hooks', + link: '/api/durably-react/client', + collapsed: false, + items: [ + { text: 'createDurablyClient', link: '/api/durably-react/client#createdurablyclient' }, + { text: 'useJob', link: '/api/durably-react/client#usejob' }, + { text: 'useJobRun', link: '/api/durably-react/client#usejobrun' }, + { text: 'useJobLogs', link: '/api/durably-react/client#usejoblogs' }, + { text: 'useRuns', link: '/api/durably-react/client#useruns' }, + { text: 'useRunActions', link: '/api/durably-react/client#userunactions' }, + ], + }, + { text: 'Type Definitions', link: '/api/durably-react/types' }, ], }, ], diff --git a/website/api/create-durably.md b/website/api/create-durably.md index fc3effc9..306b2729 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -30,13 +30,21 @@ interface DurablyOptions { Returns a `Durably` instance with the following methods: +### `init()` + +```ts +await durably.init(): Promise +``` + +Initialize Durably: runs database migrations and starts the worker. This is the recommended way to start Durably. Equivalent to calling `migrate()` then `start()`. + ### `migrate()` ```ts await durably.migrate(): Promise ``` -Runs database migrations to create the required tables. +Runs database migrations to create the required tables. Use `init()` instead for most cases. ### `start()` @@ -44,7 +52,7 @@ Runs database migrations to create the required tables. durably.start(): void ``` -Starts the worker that processes pending jobs. +Starts the worker that processes pending jobs. Typically called after `migrate()`, or use `init()` for both. ### `stop()` @@ -57,12 +65,24 @@ Stops the worker gracefully, waiting for the current job to complete. ### `register()` ```ts -durably.register( - jobDef: JobDefinition -): JobHandle +durably.register>( + jobs: TJobs +): { [K in keyof TJobs]: JobHandle } +``` + +Registers one or more job definitions and returns an object of job handles. Also populates `durably.jobs` with the same handles for type-safe access. + +```ts +const { syncUsers, processImage } = durably.register({ + syncUsers: syncUsersJob, + processImage: processImageJob, +}) + +// Or access via durably.jobs +await durably.jobs.syncUsers.trigger({ orgId: '123' }) ``` -Registers a job definition and returns a job handle. See [defineJob](/api/define-job) for details. +See [defineJob](/api/define-job) for details. ### `on()` @@ -81,7 +101,62 @@ Subscribes to an event. Returns an unsubscribe function. See [Events](/api/event await durably.retry(runId: string): Promise ``` -Retries a failed run by resetting its status to pending. +Retries a failed or cancelled run by resetting its status to pending. + +### `cancel()` + +```ts +await durably.cancel(runId: string): Promise +``` + +Cancels a pending or running run. + +### `deleteRun()` + +```ts +await durably.deleteRun(runId: string): Promise +``` + +Deletes a run and its associated steps and logs. + +### `getRun()` + +```ts +await durably.getRun(runId: string): Promise +``` + +Gets a single run by ID. + +### `getRuns()` + +```ts +await durably.getRuns(filter?: RunFilter): Promise + +interface RunFilter { + jobName?: string + status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + limit?: number + offset?: number +} +``` + +Gets runs with optional filtering and pagination. + +### `getJob()` + +```ts +durably.getJob(name: string): JobHandle | undefined +``` + +Gets a registered job by name. + +### `subscribe()` + +```ts +durably.subscribe(runId: string): ReadableStream +``` + +Subscribes to events for a specific run as a ReadableStream. The stream automatically closes when the run completes or fails. ## Example @@ -100,24 +175,34 @@ const durably = createDurably({ staleThreshold: 30000, }) -await durably.migrate() -durably.start() +// Initialize (migrate + start) +await durably.init() // Define and register jobs import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const myJobDef = defineJob({ + name: 'my-job', + input: z.object({ id: z.string() }), + run: async (step, payload) => { + // ... + }, +}) + +const { myJob } = durably.register({ myJob: myJobDef }) -const myJob = durably.register( - defineJob({ - name: 'my-job', - input: z.object({ id: z.string() }), - run: async (step, payload) => { - // ... - }, - }), -) +// Or trigger via durably.jobs +await durably.jobs.myJob.trigger({ id: '123' }) // Clean shutdown process.on('SIGTERM', async () => { await durably.stop() }) ``` + +## See Also + +- [HTTP Handler](/api/http-handler) — Expose Durably via HTTP/SSE for React clients +- [defineJob](/api/define-job) — Define jobs with typed schemas +- [Events](/api/events) — Subscribe to run and step events diff --git a/website/api/define-job.md b/website/api/define-job.md index d679000f..b43f6bc1 100644 --- a/website/api/define-job.md +++ b/website/api/define-job.md @@ -46,10 +46,18 @@ Returns a `JobDefinition` object that can be registered with `durably.register() ## Registering Jobs -Use `durably.register()` to register a job definition and get a job handle: +Use `durably.register()` to register job definitions and get job handles: ```ts -const job = durably.register(jobDef) +const { job } = durably.register({ + job: jobDef, +}) + +// Multiple jobs at once +const { syncUsers, importCsv } = durably.register({ + syncUsers: syncUsersJob, + importCsv: importCsvJob, +}) ``` The job handle provides the following methods: @@ -71,6 +79,7 @@ Triggers a new job run. interface TriggerOptions { idempotencyKey?: string concurrencyKey?: string + timeout?: number // For triggerAndWait only } ``` @@ -78,6 +87,70 @@ interface TriggerOptions { |--------|-------------| | `idempotencyKey` | Prevents duplicate runs with the same key | | `concurrencyKey` | Groups jobs for concurrency control | +| `timeout` | Timeout in ms for `triggerAndWait()` | + +### `triggerAndWait()` + +```ts +await job.triggerAndWait( + input: TInput, + options?: TriggerOptions +): Promise<{ id: string; output: TOutput }> +``` + +Triggers a run and waits for completion. Throws if the run fails. + +```ts +const { id, output } = await job.triggerAndWait({ orgId: 'org_123' }) +console.log('Completed:', output) + +// With timeout +const { output } = await job.triggerAndWait( + { orgId: 'org_123' }, + { timeout: 30000 } // 30 seconds +) +``` + +### `batchTrigger()` + +```ts +await job.batchTrigger( + inputs: (TInput | { input: TInput; options?: TriggerOptions })[] +): Promise[]> +``` + +Triggers multiple runs. All inputs are validated before any runs are created. + +```ts +// Simple batch +const runs = await job.batchTrigger([ + { orgId: 'org_1' }, + { orgId: 'org_2' }, + { orgId: 'org_3' }, +]) + +// With per-item options +const runs = await job.batchTrigger([ + { input: { orgId: 'org_1' }, options: { idempotencyKey: 'key-1' } }, + { input: { orgId: 'org_2' }, options: { idempotencyKey: 'key-2' } }, +]) +``` + +### `getRun()` + +```ts +await job.getRun(id: string): Promise | null> +``` + +Gets a run by ID (only returns runs for this job). + +### `getRuns()` + +```ts +await job.getRuns(filter?: { status?, limit?, offset? }): Promise[]> +``` + +Gets runs for this job with optional filtering. ## Example @@ -120,7 +193,9 @@ const syncUsersJob = defineJob({ }) // Register with durably instance -const syncUsers = durably.register(syncUsersJob) +const { syncUsers } = durably.register({ + syncUsers: syncUsersJob, +}) // Trigger the job await syncUsers.trigger({ orgId: 'org_123' }) @@ -147,7 +222,9 @@ const exampleJob = defineJob({ }, }) -const job = durably.register(exampleJob) +const { job } = durably.register({ + job: exampleJob, +}) // trigger() is typed await job.trigger({ id: 'abc' }) // OK @@ -161,8 +238,8 @@ Registering the same `JobDefinition` instance multiple times returns the same jo ```ts const jobDef = defineJob({ name: 'my-job', ... }) -const handle1 = durably.register(jobDef) -const handle2 = durably.register(jobDef) +const { job: handle1 } = durably.register({ job: jobDef }) +const { job: handle2 } = durably.register({ job: jobDef }) console.log(handle1 === handle2) // true ``` diff --git a/website/api/durably-react/browser.md b/website/api/durably-react/browser.md new file mode 100644 index 00000000..e3ae3a26 --- /dev/null +++ b/website/api/durably-react/browser.md @@ -0,0 +1,289 @@ +# Browser Hooks + +Run Durably entirely in the browser using SQLite WASM with OPFS persistence. Jobs execute client-side with data stored in the browser's Origin Private File System. + +```tsx +import { DurablyProvider, useDurably, useJob, useJobRun, useJobLogs, useRuns } from '@coji/durably-react' +``` + +## DurablyProvider + +Wraps your app and initializes Durably with a browser SQLite database. + +```tsx +import { DurablyProvider } from '@coji/durably-react' +import { createDurably } from '@coji/durably' +import { SQLocalKysely } from 'sqlocal/kysely' + +const sqlocal = new SQLocalKysely('app.sqlite3') + +const durably = createDurably({ + dialect: sqlocal.dialect, + pollingInterval: 100, +}).register({ + myJob: myJobDef, +}) + +await durably.migrate() + +function App() { + return ( + Loading...

}> + +
+ ) +} +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `durably` | `Durably \| Promise` | required | Durably instance or Promise | +| `autoStart` | `boolean` | `true` | Auto-start worker on mount | +| `onReady` | `(durably: Durably) => void` | - | Callback when ready | +| `fallback` | `ReactNode` | - | Loading fallback (wraps in Suspense) | + +--- + +## useDurably + +Access the Durably instance directly. + +```tsx +import { useDurably } from '@coji/durably-react' + +function Component() { + const { durably, isReady, error } = useDurably() + + if (!isReady) return
Loading...
+ if (error) return
Error: {error.message}
+ + // Use durably instance directly + const runs = await durably.storage.getRuns() +} +``` + +### Return Type + +| Property | Type | Description | +|----------|------|-------------| +| `durably` | `Durably \| null` | The Durably instance | +| `isReady` | `boolean` | Whether Durably is initialized | +| `error` | `Error \| null` | Initialization error | + +--- + +## useJob + +Trigger and monitor a job. Pass a `JobDefinition` to get type-safe input/output. + +```tsx +import { defineJob } from '@coji/durably' +import { useJob } from '@coji/durably-react' +import { z } from 'zod' + +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { + const data = await step.run('process', () => process(payload.value)) + return { result: data.length } + }, +}) + +function Component() { + const { + isReady, + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + currentRunId, + reset, + } = useJob(myJob) + + const handleClick = async () => { + const { runId } = await trigger({ value: 'test' }) + console.log('Started:', runId) + } + + return ( +
+ +

Status: {status}

+ {progress &&

Progress: {progress.current}/{progress.total}

} + {isCompleted &&

Result: {output?.result}

} + {isFailed &&

Error: {error}

} + +
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `initialRunId` | `string` | Resume subscription to an existing run | + +### Return Type + +```ts +interface UseJobResult { + isReady: boolean + trigger: (input: TInput) => Promise<{ runId: string }> + triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }> + status: RunStatus | null + output: TOutput | null + error: string | null + logs: LogEntry[] + progress: Progress | null + isRunning: boolean + isPending: boolean + isCompleted: boolean + isFailed: boolean + currentRunId: string | null + reset: () => void +} +``` + +--- + +## useJobRun + +Subscribe to an existing run by ID. + +```tsx +import { useJobRun } from '@coji/durably-react' + +function RunMonitor({ runId }: { runId: string | null }) { + const { + isReady, + status, + output, + error, + progress, + logs, + isRunning, + isCompleted, + isFailed, + } = useJobRun<{ result: number }>({ runId }) + + if (!runId) return
No run selected
+ + return ( +
+

Status: {status}

+ {isCompleted &&

Output: {JSON.stringify(output)}

} +
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `runId` | `string \| null` | The run ID to subscribe to | + +--- + +## useJobLogs + +Subscribe to logs from a run. + +```tsx +import { useJobLogs } from '@coji/durably-react' + +function LogViewer({ runId }: { runId: string | null }) { + const { isReady, logs, clearLogs } = useJobLogs({ + runId, + maxLogs: 100, + }) + + return ( +
+ +
    + {logs.map((log) => ( +
  • + [{log.level}] {log.message} + {log.data &&
    {JSON.stringify(log.data)}
    } +
  • + ))} +
+
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `runId` | `string \| null` | The run ID to subscribe to | +| `maxLogs` | `number` | Maximum number of logs to keep | + +--- + +## useRuns + +List runs with optional filtering 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 + +```tsx +import { useRuns } from '@coji/durably-react' + +function RunList() { + const { runs, isLoading, refresh } = useRuns({ + jobName: 'my-job', + status: 'completed', + limit: 10, + }) + + return ( +
+ +
    + {runs.map(run => ( +
  • + {run.jobName}: {run.status} + {run.progress && ` (${run.progress.current}/${run.progress.total})`} +
  • + ))} +
+
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `jobName` | `string` | Filter by job name | +| `status` | `RunStatus` | Filter by status | +| `limit` | `number` | Maximum number of runs to return | + +### Return Type + +| Property | Type | Description | +|----------|------|-------------| +| `runs` | `Run[]` | List of runs | +| `isLoading` | `boolean` | Loading state | +| `refresh` | `() => void` | Manually refresh the list | diff --git a/website/api/durably-react/client.md b/website/api/durably-react/client.md new file mode 100644 index 00000000..9223246a --- /dev/null +++ b/website/api/durably-react/client.md @@ -0,0 +1,341 @@ +# Server Hooks + +Connect to a Durably server via HTTP/SSE for real-time job monitoring. Jobs run on the server with updates streamed to the client. + +```tsx +import { + createDurablyClient, + useJob, + useJobRun, + useJobLogs, + useRuns, + useRunActions, +} from '@coji/durably-react/client' +``` + +## createDurablyClient + +Create a type-safe client for all registered jobs. This is the recommended way to use server-connected mode. + +### Server Setup + +```ts +// app/lib/durably.server.ts +import { createDurably, createDurablyHandler } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' + +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) + +export const durably = createDurably({ dialect }).register({ + importCsv: importCsvJob, + syncUsers: syncUsersJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.migrate() +durably.start() +``` + +```ts +// app/routes/api.durably.$.ts +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +### Client Setup + +```ts +// app/lib/durably.client.ts +import { createDurablyClient } from '@coji/durably-react/client' +import type { durably } from './durably.server' + +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) +``` + +### Usage + +```tsx +// Fully type-safe! +function CsvImporter() { + const { trigger, status, output, isRunning } = durablyClient.importCsv.useJob() + + return ( + + ) +} + +// Subscribe to an existing run +function RunViewer({ runId }: { runId: string }) { + const { status, output, progress } = durablyClient.importCsv.useRun(runId) + return
Status: {status}
+} + +// Subscribe to logs +function LogViewer({ runId }: { runId: string }) { + const { logs } = durablyClient.importCsv.useLogs(runId) + return
{logs.map(l => l.message).join('\n')}
+} +``` + +--- + +## useJob + +Direct hook for triggering jobs when not using `createDurablyClient`. + +```tsx +import { useJob } from '@coji/durably-react/client' + +function Component() { + const { + isReady, + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isCompleted, + currentRunId, + reset, + } = useJob< + { userId: string }, // Input type + { count: number } // Output type + >({ + api: '/api/durably', + jobName: 'sync-data', + initialRunId: undefined, // Optional: resume existing run + }) + + const handleClick = async () => { + const { runId } = await trigger({ userId: 'user_123' }) + console.log('Started:', runId) + } + + return +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `api` | `string` | API base path (e.g., `/api/durably`) | +| `jobName` | `string` | Name of the job to trigger | +| `initialRunId` | `string` | Resume subscription to an existing run | + +--- + +## useJobRun + +Subscribe to an existing run via SSE. + +```tsx +import { useJobRun } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { status, output, error, progress, logs } = useJobRun<{ count: number }>({ + api: '/api/durably', + runId, + }) + + return
Status: {status}
+} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `api` | `string` | API base path | +| `runId` | `string` | The run ID to subscribe to | + +--- + +## useJobLogs + +Subscribe to logs from a run via SSE. + +```tsx +import { useJobLogs } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { logs, clearLogs } = useJobLogs({ + api: '/api/durably', + runId, + maxLogs: 50, + }) + + return ( +
    + {logs.map((log) => ( +
  • {log.message}
  • + ))} +
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `api` | `string` | API base path | +| `runId` | `string` | The run ID to subscribe to | +| `maxLogs` | `number` | Maximum number of logs to keep | + +--- + +## useRuns + +List and paginate job runs with real-time updates on the first page. + +The first page (page 0) automatically subscribes to SSE for real-time updates. 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`, `step:fail` - refresh for step updates + +Other pages are static and require manual refresh. + +```tsx +import { useRuns } from '@coji/durably-react/client' + +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, + }) + + return ( +
+
    + {runs.map((run) => ( +
  • + {run.jobName}: {run.status} + {run.progress && ` (${run.progress.current}/${run.progress.total})`} +
  • + ))} +
+
+ + Page {page + 1} + + +
+
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `api` | `string` | API base path | +| `jobName` | `string` | Filter by job name | +| `status` | `RunStatus` | Filter by status | +| `pageSize` | `number` | Number of runs per page | + +### Return Type + +| Property | Type | Description | +|----------|------|-------------| +| `runs` | `RunRecord[]` | List of runs | +| `isLoading` | `boolean` | Loading state | +| `error` | `string \| null` | Error message | +| `page` | `number` | Current page (0-indexed) | +| `hasMore` | `boolean` | Whether more pages exist | +| `nextPage` | `() => void` | Go to next page | +| `prevPage` | `() => void` | Go to previous page | +| `goToPage` | `(page: number) => void` | Go to specific page | +| `refresh` | `() => void` | Refresh current page | + +--- + +## useRunActions + +Perform actions on runs (retry, cancel, delete). + +```tsx +import { useRunActions } from '@coji/durably-react/client' + +function RunActions({ runId, status }: { runId: string; status: string }) { + const { + retry, + cancel, + deleteRun, + getRun, + getSteps, + isLoading, + error, + } = useRunActions({ api: '/api/durably' }) + + return ( +
+ {(status === 'failed' || status === 'cancelled') && ( + + )} + {(status === 'pending' || status === 'running') && ( + + )} + {(status === 'completed' || status === 'failed' || status === 'cancelled') && ( + + )} + {error && {error}} +
+ ) +} +``` + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `api` | `string` | API base path | + +### Return Type + +| Property | Type | Description | +|----------|------|-------------| +| `retry` | `(runId: string) => Promise` | Retry a failed run | +| `cancel` | `(runId: string) => Promise` | Cancel a running job | +| `deleteRun` | `(runId: string) => Promise` | Delete a run | +| `getRun` | `(runId: string) => Promise` | Get run details | +| `getSteps` | `(runId: string) => Promise` | Get step details | +| `isLoading` | `boolean` | Loading state | +| `error` | `string \| null` | Error message | diff --git a/website/api/durably-react/index.md b/website/api/durably-react/index.md new file mode 100644 index 00000000..a602b63f --- /dev/null +++ b/website/api/durably-react/index.md @@ -0,0 +1,219 @@ +# React Hooks + +React bindings for Durably - hooks for triggering and monitoring jobs with real-time updates. + +## Requirements + +- **React 19+** (uses `React.use()` for Promise resolution) + +## Which Mode Should I Use? + +Durably React provides two modes for different architectures: + +| Question | Browser Hooks | Server Hooks | +|----------|---------------|--------------| +| Where do jobs run? | In the browser | On the server | +| Where is data stored? | Browser OPFS | Server database | +| Works offline? | Yes | No | +| Share state across tabs/users? | No | Yes | +| Needs backend? | No | Yes | + +### Choose Browser Hooks when: + +- Building **offline-capable** or **local-first** apps +- Data should stay on the user's device +- Prototyping without a backend +- Single-user, single-tab usage + +```tsx +import { DurablyProvider, useJob } from '@coji/durably-react' +``` + +[Browser Hooks Reference →](./browser) + +### Choose Server Hooks when: + +- Building **full-stack** applications +- Jobs need server resources (databases, APIs, secrets) +- Multiple users or tabs need to see the same state +- Need persistent storage across devices + +```tsx +import { createDurablyClient } from '@coji/durably-react/client' +``` + +[Server Hooks Reference →](./client) + +## Installation + +```bash +# Browser mode - runs Durably in the browser +pnpm add @coji/durably @coji/durably-react kysely zod sqlocal + +# Server mode - connects to Durably server +pnpm add @coji/durably-react +``` + +## Quick Examples + +### Browser Mode + +Jobs run entirely in the browser with OPFS persistence. + +```tsx +import { DurablyProvider, useJob } from '@coji/durably-react' +import { durably } from './lib/durably' +import { importCsvJob } from './jobs/import-csv' + +function App() { + return ( + Loading...

}> + +
+ ) +} + +function ImportButton() { + const { trigger, progress, isRunning, isCompleted, output } = useJob(importCsvJob) + + return ( +
+ + {progress &&

{progress.current}/{progress.total}

} + {isCompleted &&

Done: {output?.count} rows

} +
+ ) +} +``` + +### Server Mode + +Jobs run on the server, with real-time updates via SSE. + +```tsx +// 1. Create type-safe client (client-side file) +import { createDurablyClient } from '@coji/durably-react/client' +import type { durably } from './durably.server' + +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) + +// 2. Use in components +function ImportButton() { + const { trigger, progress, isRunning, isCompleted, output } = + durablyClient.importCsv.useJob() + + return ( +
+ + {progress &&

{progress.current}/{progress.total}

} + {isCompleted &&

Done: {output?.count} rows

} +
+ ) +} +``` + +## Available Hooks + +### Both Modes + +| Hook | Description | +|------|-------------| +| `useJob` | Trigger and monitor a job | +| `useJobRun` | Subscribe to an existing run by ID | +| `useJobLogs` | Subscribe to logs from a run | +| `useRuns` | List runs with filtering and pagination | + +### Browser Mode Only + +| Hook | Description | +|------|-------------| +| `useDurably` | Access the Durably instance directly | + +### Server Mode Only + +| Hook | Description | +|------|-------------| +| `useRunActions` | Retry, cancel, delete runs | + +## Common Patterns + +### Show Progress Bar + +```tsx +function ProgressBar({ runId }: { runId: string }) { + const { progress, isRunning } = useJobRun({ runId }) + + if (!isRunning || !progress) return null + + const percent = Math.round((progress.current / progress.total) * 100) + + return ( +
+ + {percent}% - {progress.message} +
+ ) +} +``` + +### Handle Errors + +```tsx +function JobRunner() { + const { trigger, isFailed, error, reset } = useJob(myJob) + + if (isFailed) { + return ( +
+

Error: {error}

+ +
+ ) + } + + return +} +``` + +### Run Dashboard (Server Mode) + +```tsx +function Dashboard() { + const { runs, page, hasMore, nextPage, prevPage } = useRuns({ + api: '/api/durably', + pageSize: 10, + }) + const { retry, cancel, deleteRun } = useRunActions({ api: '/api/durably' }) + + return ( + + + + + + {runs.map(run => ( + + + + + + ))} + +
JobStatusActions
{run.jobName}{run.status} + {run.status === 'failed' && } + {run.status === 'running' && } + +
+ ) +} +``` + +## Type Definitions + +See [Type Definitions](./types) for all exported types. diff --git a/website/api/durably-react/types.md b/website/api/durably-react/types.md new file mode 100644 index 00000000..e07b81ad --- /dev/null +++ b/website/api/durably-react/types.md @@ -0,0 +1,107 @@ +# Type Definitions + +Common types used across both Browser-Complete and Server-Connected modes. + +## RunStatus + +```ts +type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' +``` + +| Status | Description | +|--------|-------------| +| `pending` | Job is queued, waiting to be picked up by worker | +| `running` | Job is currently executing | +| `completed` | Job finished successfully | +| `failed` | Job encountered an error | +| `cancelled` | Job was cancelled before completion | + +## Progress + +```ts +interface Progress { + current: number + total?: number + message?: string +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `current` | `number` | Current progress value | +| `total` | `number \| undefined` | Total expected value | +| `message` | `string \| undefined` | Human-readable progress message | + +## LogEntry + +```ts +interface LogEntry { + id: string + runId: string + stepName: string | null + level: 'info' | 'warn' | 'error' + message: string + data: unknown + timestamp: string +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | Unique log entry ID | +| `runId` | `string` | Associated run ID | +| `stepName` | `string \| null` | Step that created the log | +| `level` | `'info' \| 'warn' \| 'error'` | Log severity | +| `message` | `string` | Log message | +| `data` | `unknown` | Optional structured data | +| `timestamp` | `string` | ISO timestamp | + +## RunRecord + +```ts +interface RunRecord { + id: string + jobName: string + status: RunStatus + payload: unknown + output: unknown + error: string | null + progress: Progress | null + createdAt: string + updatedAt: string +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | Unique run ID | +| `jobName` | `string` | Name of the job | +| `status` | `RunStatus` | Current status | +| `payload` | `unknown` | Input payload | +| `output` | `unknown` | Job output (when completed) | +| `error` | `string \| null` | Error message (when failed) | +| `progress` | `Progress \| null` | Current progress | +| `createdAt` | `string` | ISO timestamp of creation | +| `updatedAt` | `string` | ISO timestamp of last update | + +## StepRecord + +```ts +interface StepRecord { + name: string + status: 'completed' | 'failed' + output: unknown + error: string | null + startedAt: string + completedAt: string | null +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `name` | `string` | Step name | +| `status` | `'completed' \| 'failed'` | Step result | +| `output` | `unknown` | Step return value | +| `error` | `string \| null` | Error message (when failed) | +| `startedAt` | `string` | ISO timestamp of start | +| `completedAt` | `string \| null` | ISO timestamp of completion | diff --git a/website/api/events.md b/website/api/events.md index 8f9ab5fb..eb121a0b 100644 --- a/website/api/events.md +++ b/website/api/events.md @@ -12,6 +12,23 @@ durably.on(eventType: string, listener: (event) => void): void ### Run Events +#### `run:trigger` + +Fired when a job is triggered (before worker picks it up). + +```ts +durably.on('run:trigger', (event) => { + // event: { + // type: 'run:trigger', + // runId: string, + // jobName: string, + // payload: unknown, + // timestamp: string, + // sequence: number + // } +}) +``` + #### `run:start` Fired when a run begins execution. @@ -82,6 +99,38 @@ durably.on('run:progress', (event) => { }) ``` +#### `run:cancel` + +Fired when a run is cancelled via `cancel()` API. + +```ts +durably.on('run:cancel', (event) => { + // event: { + // type: 'run:cancel', + // runId: string, + // jobName: string, + // timestamp: string, + // sequence: number + // } +}) +``` + +#### `run:retry` + +Fired when a failed or cancelled run is retried via `retry()` API. + +```ts +durably.on('run:retry', (event) => { + // event: { + // type: 'run:retry', + // runId: string, + // jobName: string, + // timestamp: string, + // sequence: number + // } +}) +``` + ### Step Events #### `step:start` @@ -203,9 +252,12 @@ interface BaseEvent { } type DurablyEvent = + | RunTriggerEvent | RunStartEvent | RunCompleteEvent | RunFailEvent + | RunCancelEvent + | RunRetryEvent | RunProgressEvent | StepStartEvent | StepCompleteEvent diff --git a/website/api/http-handler.md b/website/api/http-handler.md new file mode 100644 index 00000000..9b4b47a9 --- /dev/null +++ b/website/api/http-handler.md @@ -0,0 +1,199 @@ +# HTTP Handler + +Expose Durably via HTTP/SSE endpoints for React clients and external integrations. + +## createDurablyHandler + +Create a handler that routes HTTP requests to the appropriate Durably operations. + +```ts +import { createDurablyHandler } from '@coji/durably' + +const handler = createDurablyHandler(durably, { + onRequest: async () => { + // Called before each request - useful for lazy initialization + await durably.init() + }, +}) +``` + +### Options + +```ts +interface CreateDurablyHandlerOptions { + /** Called before handling each request */ + onRequest?: () => Promise | void +} +``` + +## Framework Integration + +### React Router / Remix + +Use a splat route to handle all Durably endpoints under a single path. + +```ts +// app/routes/api.durably.$.ts +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +### Next.js + +```ts +// app/api/durably/[...path]/route.ts +import { durablyHandler } from '@/lib/durably' + +export async function GET(request: Request) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function POST(request: Request) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function DELETE(request: Request) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +### Express / Hono + +```ts +// Express +app.use('/api/durably', async (req, res, next) => { + const request = new Request(`http://localhost${req.url}`, { + method: req.method, + headers: req.headers, + body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined, + }) + const response = await handler.handle(request, '/api/durably') + res.status(response.status) + response.headers.forEach((v, k) => res.setHeader(k, v)) + res.send(await response.text()) +}) + +// Hono +app.all('/api/durably/*', (c) => handler.handle(c.req.raw, '/api/durably')) +``` + +## Endpoints + +The handler provides these endpoints: + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/trigger` | Trigger a job | +| `GET` | `/subscribe?runId=xxx` | SSE stream for run events | +| `GET` | `/runs` | List runs with filtering | +| `GET` | `/run?runId=xxx` | Get single run | +| `GET` | `/steps?runId=xxx` | Get steps for a run | +| `GET` | `/runs/subscribe` | SSE stream for run list updates | +| `POST` | `/retry?runId=xxx` | Retry a failed run | +| `POST` | `/cancel?runId=xxx` | Cancel a running job | +| `DELETE` | `/run?runId=xxx` | Delete a run | + +## Trigger Request + +```ts +// POST /api/durably/trigger +{ + "jobName": "import-csv", + "input": { "filename": "data.csv" }, + "idempotencyKey": "unique-key", // optional + "concurrencyKey": "user-123" // optional +} + +// Response +{ "runId": "run_abc123" } +``` + +## SSE Event Stream + +The `/subscribe` endpoint returns Server-Sent Events for real-time updates. + +```ts +// GET /api/durably/subscribe?runId=run_abc123 + +// Events: +data: {"type":"run:start","runId":"run_abc123","jobName":"import-csv",...} + +data: {"type":"run:progress","runId":"run_abc123","progress":{"current":1,"total":10},...} + +data: {"type":"step:complete","runId":"run_abc123","stepName":"parse",...} + +data: {"type":"run:complete","runId":"run_abc123","output":{"count":10},...} +``` + +The stream closes automatically when the run completes or fails. + +## List Runs + +```ts +// GET /api/durably/runs?jobName=import-csv&status=completed&limit=10&offset=0 + +// Response +{ + "runs": [ + { + "id": "run_abc123", + "jobName": "import-csv", + "status": "completed", + "input": { "filename": "data.csv" }, + "output": { "count": 10 }, + "createdAt": "2024-01-01T00:00:00.000Z", + "completedAt": "2024-01-01T00:01:00.000Z" + } + ], + "total": 100, + "hasMore": true +} +``` + +## Individual Handlers + +For custom routing, access individual handlers directly: + +```ts +const handler = createDurablyHandler(durably) + +// Use specific handlers +app.post('/jobs/trigger', (req) => handler.trigger(req)) +app.get('/jobs/subscribe', (req) => handler.subscribe(req)) +app.get('/jobs/runs', (req) => handler.runs(req)) +app.get('/jobs/run', (req) => handler.run(req)) +app.get('/jobs/steps', (req) => handler.steps(req)) +app.post('/jobs/retry', (req) => handler.retry(req)) +app.post('/jobs/cancel', (req) => handler.cancel(req)) +app.delete('/jobs/run', (req) => handler.delete(req)) +app.get('/jobs/runs/subscribe', (req) => handler.runsSubscribe(req)) +``` + +## Security Considerations + +The handler exposes all registered jobs and run data. In production: + +1. **Authentication**: Add middleware to verify requests before reaching the handler +2. **Authorization**: Check user permissions for specific jobs or runs +3. **Rate Limiting**: Protect against abuse + +```ts +// Example with authentication middleware +export async function action({ request }: Route.ActionArgs) { + const user = await getUser(request) + if (!user) { + return new Response('Unauthorized', { status: 401 }) + } + + // Add user context to the request if needed + return durablyHandler.handle(request, '/api/durably') +} +``` diff --git a/website/api/index.md b/website/api/index.md index 82900325..4d5ac289 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -1,101 +1,238 @@ -# API Reference +# Quick Reference -This section provides detailed API documentation for Durably. +A one-page overview of the Durably API. Use this as a cheat sheet or starting point. -## Core API +## Installation -| Export | Description | -|--------|-------------| -| [`createDurably`](/api/create-durably) | Create a Durably instance | -| [`defineJob`](/api/define-job) | Define a job (standalone function) | -| [`Step`](/api/step) | Step context for job handlers | -| [`Events`](/api/events) | Event types and subscriptions | +```bash +# Core package +pnpm add @coji/durably kysely zod + +# React bindings (optional) +pnpm add @coji/durably-react + +# SQLite driver (choose one) +pnpm add @libsql/client @libsql/kysely-libsql # Server (libSQL/Turso) +pnpm add sqlocal # Browser (OPFS) +``` -## Quick Reference +## Define a Job -### Creating an Instance +Jobs are the core unit of work. Each job has a name, input schema, and a run function. ```ts -import { createDurably, defineJob } from '@coji/durably' +import { defineJob } from '@coji/durably' +import { z } from 'zod' -const durably = createDurably({ - dialect, // Kysely SQLite dialect - pollingInterval: 1000, // Worker polling interval (ms) - heartbeatInterval: 5000, // Heartbeat update interval (ms) - staleThreshold: 30000, // Time until job is considered stale (ms) +const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ filename: z.string() }), + output: z.object({ count: z.number() }), + run: async (step, payload) => { + // Step 1: Parse file (cached on resume) + const rows = await step.run('parse', async () => { + return parseCSV(payload.filename) + }) + + // Step 2: Import each row + for (const [i, row] of rows.entries()) { + await step.run(`import-${i}`, () => db.insert(row)) + step.progress(i + 1, rows.length, `Importing row ${i + 1}`) + } + + step.log.info('Import complete', { count: rows.length }) + return { count: rows.length } + }, }) ``` -### Instance Methods +**See:** [defineJob](/api/define-job) | [Step Context](/api/step) + +## Create Instance + +Create a Durably instance with a SQLite dialect and register jobs. ```ts -// Lifecycle -await durably.migrate() // Run database migrations -durably.start() // Start the worker -await durably.stop() // Stop the worker gracefully +import { createDurably } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' -// Job management -const job = durably.register(jobDef) // Register a job definition -await durably.retry(runId) // Retry a failed run +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) -// Events -const unsub = durably.on(event, handler) +const durably = createDurably({ + dialect, + pollingInterval: 1000, // Check for jobs every 1s + heartbeatInterval: 5000, // Heartbeat every 5s + staleThreshold: 30000, // Stale after 30s +}).register({ + importCsv: importCsvJob, +}) + +await durably.init() // Migrate DB + start worker ``` -### Defining and Registering Jobs +**See:** [createDurably](/api/create-durably) + +## Trigger Jobs ```ts -import { defineJob } from '@coji/durably' +// Fire and forget +const run = await durably.jobs.importCsv.trigger({ filename: 'data.csv' }) +console.log('Started:', run.id) -// Define a job -const myJobDef = defineJob({ - name: 'my-job', - input: z.object({ id: z.string() }), - output: z.object({ result: z.string() }), - run: async (step, payload) => { - const result = await step.run('step-name', async () => { - return value - }) - return { result } - }, +// Wait for completion +const { id, output } = await durably.jobs.importCsv.triggerAndWait({ + filename: 'data.csv' }) +console.log('Done:', output.count) + +// With options +await durably.jobs.importCsv.trigger( + { filename: 'data.csv' }, + { + idempotencyKey: 'import-2024-01-01', // Prevent duplicates + concurrencyKey: 'csv-imports', // Limit concurrency + } +) +``` -// Register with durably instance -const myJob = durably.register(myJobDef) +## Monitor Events -// Trigger a new run -await myJob.trigger(input, options?) +```ts +durably.on('run:start', (e) => console.log(`Started: ${e.jobName}`)) +durably.on('run:complete', (e) => console.log(`Done in ${e.duration}ms`)) +durably.on('run:fail', (e) => console.error(`Failed: ${e.error}`)) +durably.on('run:progress', (e) => console.log(`${e.progress.current}/${e.progress.total}`)) ``` -### Step Methods +**See:** [Events](/api/events) + +## Server Integration + +Expose Durably via HTTP/SSE for React clients. ```ts -defineJob({ - name: 'example', - input: z.object({}), - run: async (step, payload) => { - // Execute a step - const result = await step.run('step-name', async () => { - return value - }) +import { createDurablyHandler } from '@coji/durably' - // Log a message - step.log.info('message', { data }) - }, +const handler = createDurablyHandler(durably) + +// React Router / Remix +export async function loader({ request }) { + return handler.handle(request, '/api/durably') +} + +export async function action({ request }) { + return handler.handle(request, '/api/durably') +} +``` + +**See:** [HTTP Handler](/api/http-handler) + +## React Hooks + +### Server-Connected (Full-Stack) + +Connect to a Durably server via HTTP/SSE. + +```tsx +// 1. Create type-safe client +import { createDurablyClient } from '@coji/durably-react/client' +import type { durably } from './durably.server' + +const durablyClient = createDurablyClient({ + api: '/api/durably', }) + +// 2. Use in components +function ImportButton() { + const { trigger, progress, isRunning, isCompleted, output } = + durablyClient.importCsv.useJob() + + return ( +
+ + {progress &&

{progress.current}/{progress.total}

} + {isCompleted &&

Imported {output?.count} rows

} +
+ ) +} +``` + +### Browser-Only (Offline) + +Run Durably entirely in the browser with OPFS persistence. + +```tsx +import { DurablyProvider, useJob } from '@coji/durably-react' +import { durably } from './durably' +import { importCsvJob } from './jobs' + +function App() { + return ( + Loading...

}> + +
+ ) +} + +function ImportButton() { + const { trigger, progress, isRunning } = useJob(importCsvJob) + // ... +} ``` +**See:** [React Hooks Overview](/api/durably-react/) | [Browser Hooks](/api/durably-react/browser) | [Server Hooks](/api/durably-react/client) + +## API at a Glance + +### Core (@coji/durably) + +| Export | Description | +|--------|-------------| +| `createDurably(options)` | Create instance with SQLite dialect | +| `defineJob(config)` | Define a job with typed schema | +| `createDurablyHandler(durably)` | Create HTTP/SSE handler | + +### Instance Methods + +| Method | Description | +|--------|-------------| +| `init()` | Migrate database and start worker | +| `register(jobs)` | Register job definitions | +| `on(event, handler)` | Subscribe to events | +| `stop()` | Stop worker gracefully | +| `retry(runId)` | Retry failed run | +| `cancel(runId)` | Cancel running job | + +### Step Context + +| Method | Description | +|--------|-------------| +| `step.run(name, fn)` | Create resumable checkpoint | +| `step.progress(current, total, msg)` | Report progress | +| `step.log.info/warn/error(msg)` | Write structured logs | + +### React Hooks (@coji/durably-react) + +| Hook | Mode | Description | +|------|------|-------------| +| `useJob` | Both | Trigger and monitor jobs | +| `useJobRun` | Both | Subscribe to existing run | +| `useRuns` | Both | List runs with pagination | +| `useRunActions` | Server | Retry, cancel, delete runs | +| `useDurably` | Browser | Access Durably instance | + ## Type Exports ```ts import type { - Durably, - DurablyOptions, - JobDefinition, - JobHandle, - StepContext, + Durably, DurablyOptions, + JobDefinition, JobHandle, + StepContext, Run, RunStatus, TriggerOptions, - RunStatus, - StepStatus, + DurablyEvent, EventType, } from '@coji/durably' ``` diff --git a/website/api/step.md b/website/api/step.md index becee63d..bf7f7e4e 100644 --- a/website/api/step.md +++ b/website/api/step.md @@ -90,14 +90,6 @@ The unique identifier of the current run. const id: string = step.runId ``` -### `stepIndex` - -The current step index (0-based). - -```ts -const index: number = step.stepIndex -``` - ## Example ```ts @@ -135,7 +127,9 @@ const processOrderJob = defineJob({ }) // Register and use -const processOrder = durably.register(processOrderJob) +const { processOrder } = durably.register({ + processOrder: processOrderJob, +}) await processOrder.trigger({ orderId: 'order_123' }) ``` diff --git a/website/guide/background-sync.md b/website/guide/background-sync.md new file mode 100644 index 00000000..6e7c1237 --- /dev/null +++ b/website/guide/background-sync.md @@ -0,0 +1,245 @@ +# Background Sync (Server) + +Run batch jobs on Node.js without a frontend. Perfect for cron jobs, data pipelines, and CLI tools. + +**Example code:** [server-node](https://github.com/coji/durably/tree/main/examples/server-node) + +## When to Use + +- Scheduled batch processing (cron) +- Data import/export pipelines +- CLI tools with resumable operations +- Microservice background workers + +## Installation + +```bash +pnpm add @coji/durably kysely zod @libsql/client @libsql/kysely-libsql +``` + +## Project Structure + +```txt +├── jobs/ +│ └── process-image.ts # Job definition +├── lib/ +│ ├── database.ts # Database dialect +│ └── durably.ts # Durably instance +└── basic.ts # Entry point +``` + +## Setup + +### Database + +Create a libsql dialect for SQLite persistence. Supports both local files and Turso cloud databases. + +```ts +// lib/database.ts +import { LibsqlDialect } from '@libsql/kysely-libsql' + +export const dialect = new LibsqlDialect({ + url: process.env.TURSO_DATABASE_URL ?? 'file:local.db', + authToken: process.env.TURSO_AUTH_TOKEN, +}) +``` + +### Job Definition + +Define a job with multiple steps. Each `step.run()` creates a checkpoint - if the process crashes, it resumes from the last completed step. + +```ts +// jobs/process-image.ts +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const processImageJob = defineJob({ + name: 'process-image', + input: z.object({ filename: z.string() }), + output: z.object({ url: z.string() }), + run: async (step, payload) => { + // Step 1: Download + const data = await step.run('download', async () => { + await delay(500) + return { size: 1024000 } + }) + + // Step 2: Resize + await step.run('resize', async () => { + await delay(500) + return { width: 800, height: 600, size: data.size / 2 } + }) + + // Step 3: Upload + const uploaded = await step.run('upload', async () => { + await delay(500) + return { url: `https://cdn.example.com/${payload.filename}` } + }) + + return { url: uploaded.url } + }, +}) +``` + +### Durably Instance + +Create the Durably instance and register jobs. The shorter intervals are suitable for development; use longer intervals in production to reduce database load. + +```ts +// lib/durably.ts +import { createDurably } from '@coji/durably' +import { processImageJob } from '../jobs/process-image' +import { dialect } from './database' + +export const durably = createDurably({ + dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, +}).register({ + processImage: processImageJob, +}) +``` + +## Basic Usage + +Use `triggerAndWait()` to trigger a job and wait for completion. This blocks until the job finishes and returns the output. + +```ts +// basic.ts +import { durably } from './lib/durably' + +async function main() { + await durably.init() + + // Trigger job and wait for completion + const { id, output } = await durably.jobs.processImage.triggerAndWait({ + filename: 'photo.jpg', + }) + console.log(`Run ${id} completed`) + console.log(`Output: ${JSON.stringify(output)}`) + + // Cleanup + await durably.stop() + await durably.db.destroy() +} + +main().catch(console.error) +``` + +## Event Monitoring + +Subscribe to events to monitor job execution. Useful for logging, metrics, and debugging. + +```ts +durably.on('run:start', (event) => { + console.log(`[run:start] ${event.jobName}`) +}) + +durably.on('step:complete', (event) => { + console.log(`[step:complete] ${event.stepName}`) +}) + +durably.on('run:complete', (event) => { + console.log(`[run:complete] output=${JSON.stringify(event.output)} duration=${event.duration}ms`) +}) + +durably.on('run:fail', (event) => { + console.log(`[run:fail] ${event.error}`) +}) +``` + +## Cron Integration + +Combine Durably with node-cron for scheduled job execution. Jobs remain resumable even when triggered by cron. + +```ts +// cron-job.ts +import cron from 'node-cron' +import { durably } from './lib/durably' + +await durably.init() + +// Run every hour +cron.schedule('0 * * * *', async () => { + await durably.jobs.processImage.trigger({ filename: 'scheduled.jpg' }) +}) + +// Keep process running +``` + +## CLI with Progress + +Build command-line tools with real-time progress output using the `run:progress` event. + +```ts +// cli.ts +import { program } from 'commander' +import { durably } from './lib/durably' + +program + .command('process ') + .action(async (filename) => { + await durably.init() + + durably.on('run:progress', ({ progress }) => { + process.stdout.write(`\r${progress.current}/${progress.total} - ${progress.message}`) + }) + + const { output } = await durably.jobs.processImage.triggerAndWait({ filename }) + console.log(`\nDone: ${output.url}`) + + await durably.stop() + }) + +program.parse() +``` + +## Idempotency + +Prevent duplicate runs with idempotency keys. If a run with the same key already exists, it returns the existing run instead of creating a new one. + +```ts +await durably.jobs.processImage.trigger( + { filename: 'photo.jpg' }, + { idempotencyKey: `process-${new Date().toISOString().slice(0, 10)}` } +) +// Same key = returns existing run instead of creating new one +``` + +## Concurrency Control + +Limit concurrent jobs with concurrency keys. Only one job with the same key can run at a time - others wait in the queue. + +```ts +await durably.jobs.processImage.trigger( + { filename: 'photo.jpg' }, + { concurrencyKey: 'image-processing' } +) +// Only one job with this key runs at a time +``` + +## Error Handling & Retry + +Durably doesn't auto-retry failures. Use `retry()` to manually retry failed runs, or `cancel()` to stop running jobs. + +```ts +// Manual retry on failure +const run = await durably.storage.getRun(runId) +if (run?.status === 'failed') { + await durably.retry(runId) +} + +// Or cancel a running job +if (run?.status === 'running') { + await durably.cancel(runId) +} +``` + +## Next Steps + +- [CSV Import](/guide/csv-import) — Add a React UI +- [Events Reference](/api/events) — All event types +- [API Reference](/api/create-durably) — Full configuration options diff --git a/website/guide/browser.md b/website/guide/browser.md deleted file mode 100644 index 6f94cceb..00000000 --- a/website/guide/browser.md +++ /dev/null @@ -1,124 +0,0 @@ -# Browser - -This guide covers using Durably in browser environments. - -## Requirements - -### Secure Context - -Durably requires a [Secure Context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) (HTTPS or localhost) for OPFS access. - -### COOP/COEP Headers - -SQLite WASM requires cross-origin isolation: - -``` -Cross-Origin-Embedder-Policy: require-corp -Cross-Origin-Opener-Policy: same-origin -``` - -#### Vite Configuration - -```ts -// vite.config.ts -export default defineConfig({ - plugins: [ - { - name: 'configure-response-headers', - configureServer: (server) => { - server.middlewares.use((_req, res, next) => { - res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') - next() - }) - }, - }, - ], - worker: { - format: 'es', - }, - optimizeDeps: { - exclude: ['sqlocal'], - }, -}) -``` - -## SQLite Setup - -Using [SQLocal](https://sqlocal.dev/) with OPFS: - -```ts -import { SQLocalKysely } from 'sqlocal/kysely' - -const { dialect, deleteDatabaseFile } = new SQLocalKysely('app.sqlite3') - -const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) -``` - -## Browser-Specific Configuration - -Lower intervals for responsive UI: - -```ts -const durably = createDurably({ - dialect, - pollingInterval: 100, // Check every 100ms - heartbeatInterval: 500, // Heartbeat every 500ms - staleThreshold: 3000, // Stale after 3 seconds -}) -``` - -## React Integration - -For React-specific patterns including hooks, StrictMode compatibility, and state management, see the dedicated [React guide](/guide/react). - -## Tab Suspension - -Browsers can suspend inactive tabs. Durably handles this: - -1. Tab becomes inactive → heartbeat stops -2. Job is marked stale after `staleThreshold` -3. Tab becomes active → worker restarts -4. Stale job is picked up and resumed - -### Testing Resumption - -1. Start a job -2. Reload the page mid-execution -3. The job resumes from the last completed step - -## Database Management - -### Reset Database - -```ts -import { SQLocalKysely } from 'sqlocal/kysely' - -const { dialect, deleteDatabaseFile } = new SQLocalKysely('app.sqlite3') - -// To reset: -await durably.stop() -await deleteDatabaseFile() -location.reload() -``` - -### Database Size - -OPFS has storage limits. Monitor usage: - -```ts -const estimate = await navigator.storage.estimate() -console.log(`Used: ${estimate.usage} / ${estimate.quota}`) -``` - -## Limitations - -1. **Single tab**: OPFS has exclusive access - only one tab can use the database -2. **No SharedWorker**: Workers must be in the same tab -3. **Storage limits**: Browser storage quotas apply -4. **No background sync**: Jobs only run when the tab is active diff --git a/website/guide/concepts.md b/website/guide/concepts.md new file mode 100644 index 00000000..d70d6297 --- /dev/null +++ b/website/guide/concepts.md @@ -0,0 +1,164 @@ +# Core Concepts + +Deep dive into Durably's architecture and behavior. + +## Jobs + +Jobs are defined with `defineJob()` and registered with `durably.register()`: + +```ts +const myJob = defineJob({ + name: 'my-job', + input: z.object({ id: z.string() }), + output: z.object({ result: z.string() }), + run: async (step, payload) => { + // Job implementation + return { result: 'done' } + }, +}) + +const { myJob: job } = durably.register({ myJob }) +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `name` | Yes | Unique job identifier | +| `input` | Yes | Zod schema for payload | +| `output` | No | Zod schema for return value | +| `run` | Yes | The job function | + +### Job Lifecycle + +![Job Lifecycle](/images/job-lifecycle.svg) + +## Steps + +Steps are checkpoints created with `step.run()`: + +```ts +const result = await step.run('step-name', async () => { + return someValue // Persisted to SQLite +}) +``` + +**First run:** Executes function, persists result. +**Subsequent runs:** Returns cached result instantly. + +### Step Names Must Be Unique + +```ts +// Good +await step.run('fetch-user', () => fetchUser()) +await step.run('update-profile', () => updateProfile()) + +// Bad - duplicate names +await step.run('step', () => doA()) +await step.run('step', () => doB()) // Returns cached result from doA! +``` + +### Break Large Operations into Steps + +```ts +// Bad - crash loses all progress +await step.run('import-all', async () => { + for (const row of rows) await db.insert(row) +}) + +// Good - checkpoint per batch +for (let i = 0; i < rows.length; i += 100) { + await step.run(`batch-${i}`, async () => { + for (const row of rows.slice(i, i + 100)) { + await db.insert(row) + } + }) +} +``` + +## Resumability + +### How It Works + +1. Each `step.run()` saves its result to SQLite +2. If process crashes, restart picks up the job +3. Completed steps return cached results +4. Execution continues from next incomplete step + +```ts +// First run +const data = await step.run('fetch', () => api.fetch()) // Runs, saves +await step.run('process', () => process(data)) // Crashes! + +// After restart +const data = await step.run('fetch', () => api.fetch()) // Returns cached +await step.run('process', () => process(data)) // Runs +``` + +### Heartbeat Mechanism + +Running jobs send heartbeats to indicate they're alive: + +```ts +createDurably({ + dialect, + heartbeatInterval: 5000, // Send heartbeat every 5s + staleThreshold: 30000, // Mark stale after 30s without heartbeat +}) +``` + +When a job's heartbeat expires, it's reset to `pending` and picked up again. + +### Idempotency + +Steps may re-run on failure. Design for safe retries: + +```ts +// Good: Upsert instead of insert +await step.run('save', () => db.upsert(user)) + +// Good: Idempotency key with external APIs +await step.run('charge', () => + stripe.charges.create({ + amount: 1000, + idempotency_key: `order_${orderId}`, + }) +) +``` + +## Trigger Options + +### Idempotency Key + +Prevent duplicate runs: + +```ts +await job.trigger({ id: '123' }, { + idempotencyKey: 'request-abc' +}) +// Same key returns existing run +``` + +### Concurrency Key + +Limit concurrent execution: + +```ts +await job.trigger({ userId: '123' }, { + concurrencyKey: 'user_123' +}) +// Only one job per key runs at a time +``` + +## Events + +Monitor job execution: + +```ts +durably.on('run:start', ({ runId, jobName }) => { ... }) +durably.on('run:progress', ({ runId, progress }) => { ... }) +durably.on('run:complete', ({ runId, output }) => { ... }) +durably.on('run:fail', ({ runId, error }) => { ... }) +durably.on('step:start', ({ runId, stepName }) => { ... }) +durably.on('step:complete', ({ runId, stepName, output }) => { ... }) +``` + +See [Events API](/api/events) for the full list. diff --git a/website/guide/csv-import.md b/website/guide/csv-import.md new file mode 100644 index 00000000..820e4fb0 --- /dev/null +++ b/website/guide/csv-import.md @@ -0,0 +1,254 @@ +# CSV Import (Full-Stack) + +A complete CSV import system with progress UI, run history, and job management. + +**Example code:** [fullstack-react-router](https://github.com/coji/durably/tree/main/examples/fullstack-react-router) + +## What You'll Build + +- CSV file upload with server-side parsing +- Real-time progress bar via SSE +- Run history dashboard with retry/cancel/delete +- Type-safe client hooks + +## Architecture + +![Full-Stack Architecture](/images/fullstack-architecture.svg) + +## Project Structure + +```txt +app/ +├── jobs/ +│ └── import-csv.ts # Job definition +├── lib/ +│ ├── durably.server.ts # Durably instance +│ └── durably.client.ts # Type-safe hooks +├── routes/ +│ ├── api.durably.$.ts # Splat route for all API +│ └── _index.tsx # UI +``` + +## Key Code + +### Job Definition + +Define the import job with validation and import steps. Each step is a checkpoint - if the server crashes, the job resumes from the last completed step. + +The job uses `step.progress()` to report real-time progress and `step.log` for structured logging. + +```ts +// app/jobs/import-csv.ts +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +const csvRowSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string(), + amount: z.number(), +}) + +const outputSchema = z.object({ imported: z.number(), failed: z.number() }) + +/** Output type for use in components */ +export type ImportCsvOutput = z.infer + +export const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ + filename: z.string(), + rows: z.array(csvRowSchema), + }), + output: outputSchema, + run: async (step, payload) => { + step.log.info(`Starting import of ${payload.filename} (${payload.rows.length} rows)`) + + // Step 1: Validate all rows + const validRows = await step.run('validate', async () => { + const valid: typeof payload.rows = [] + const invalid: { row: (typeof payload.rows)[0]; reason: string }[] = [] + + for (let i = 0; i < payload.rows.length; i++) { + const row = payload.rows[i] + step.progress(i + 1, payload.rows.length, `Validating ${row.name}...`) + await delay(50) + + if (row.amount < 0) { + invalid.push({ row, reason: `Invalid amount: ${row.amount}` }) + step.log.warn(`Validation failed for ${row.name}: negative amount`) + } else { + valid.push(row) + } + } + + step.log.info(`Validation complete: ${valid.length} valid, ${invalid.length} invalid`) + return { valid, invalidCount: invalid.length } + }) + + // Step 2: Import valid rows + const importResult = await step.run('import', async () => { + let imported = 0 + + for (let i = 0; i < validRows.valid.length; i++) { + const row = validRows.valid[i] + step.progress(i + 1, validRows.valid.length, `Importing ${row.name}...`) + await delay(80) + + // Simulate import + imported++ + step.log.info(`Imported: ${row.name} (${row.email}) - $${row.amount}`) + } + + return { imported } + }) + + // Step 3: Finalize + await step.run('finalize', async () => { + step.progress(1, 1, 'Finalizing...') + await delay(200) + step.log.info('Import finalized') + }) + + return { + imported: importResult.imported, + failed: validRows.invalidCount, + } + }, +}) +``` + +### Server Setup + +Create the Durably instance with libsql dialect and register the job. The `createDurablyHandler` provides HTTP/SSE endpoints for the client. + +```ts +// app/lib/durably.server.ts +import { createDurably, createDurablyHandler } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' +import { importCsvJob } from '~/jobs/import-csv' + +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) + +export const durably = createDurably({ dialect }).register({ + importCsv: importCsvJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.init() +``` + +### API Route (Splat) + +Use a React Router splat route to expose all Durably endpoints under `/api/durably/*`. This handles trigger, subscribe, and management endpoints. + +```ts +// app/routes/api.durably.$.ts +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +### Type-Safe Client + +Create a type-safe client using the server's Durably type. This gives you full TypeScript inference for job inputs and outputs without bundling server code. + +```ts +// app/lib/durably.client.ts +import { createDurablyClient } from '@coji/durably-react/client' +import type { durably } from './durably.server' + +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) +``` + +### Progress UI + +Use the `useRun` hook to subscribe to real-time progress via SSE. The hook returns status flags (`isRunning`, `isCompleted`, `isFailed`) and current progress. + +```tsx +function ImportProgress({ runId }: { runId: string | null }) { + const { progress, output, isRunning, isCompleted, isFailed, error } = + durablyClient.importCsv.useRun(runId) + + if (!runId) return null + + return ( +
+ {isRunning && progress && ( + <> + +

{progress.message}

+ + )} + {isCompleted && ( +

Imported {output?.imported}, failed {output?.failed}

+ )} + {isFailed &&

Error: {error}

} +
+ ) +} +``` + +### Dashboard with Actions + +Build a dashboard showing all runs with retry, cancel, and delete actions. The `useRuns` hook provides paginated run history, while `useRunActions` provides mutation functions. + +```tsx +import { useRuns, useRunActions } from '@coji/durably-react/client' + +function Dashboard() { + const { runs, refresh } = useRuns({ api: '/api/durably' }) + const { retry, cancel, deleteRun } = useRunActions({ api: '/api/durably' }) + + return ( + + {runs.map(run => ( + + + + + + ))} +
{run.jobName}{run.status} + {run.status === 'failed' && ( + + )} + {run.status === 'running' && ( + + )} +
+ ) +} +``` + +## Resumability + +If the server crashes mid-import: + +1. Restart the server +2. `durably.init()` picks up the stale run +3. Completed steps return cached results +4. Import continues from the next step + +## Next Steps + +- [Offline App](/guide/offline-app) — Run in the browser without a server +- [API Reference](/api/durably-react/) — All hooks and options diff --git a/website/guide/deployment.md b/website/guide/deployment.md deleted file mode 100644 index 9a303507..00000000 --- a/website/guide/deployment.md +++ /dev/null @@ -1,147 +0,0 @@ -# Deployment - -Durably requires a long-running process to poll for and execute jobs. This guide covers deployment options and limitations. - -## Requirements - -Durably workers need: - -- **Persistent process**: A process that runs continuously to poll for jobs -- **SQLite access**: Either local file, Turso cloud, or browser OPFS -- **No request timeouts**: Jobs may run for minutes or hours - -## Recommended Platforms - -### Fly.io - -Deploy as a long-running process: - -```dockerfile -FROM node:20-slim -WORKDIR /app -COPY package*.json ./ -RUN npm install -COPY . . -CMD ["node", "worker.js"] -``` - -```toml -# fly.toml -[processes] - worker = "node worker.js" -``` - -### Railway - -Works out of the box with standard Node.js deployment. Set up a separate worker service or run alongside your web server. - -### Docker / VPS - -Any environment that supports long-running Node.js processes: - -```bash -# PM2 -pm2 start worker.js --name durably-worker - -# systemd -[Service] -ExecStart=/usr/bin/node /app/worker.js -Restart=always -``` - -### Render - -Use a "Background Worker" service type, not a "Web Service". - -## Not Recommended - -### Serverless Functions - -Durably is **not compatible** with serverless environments: - -| Platform | Limitation | -|----------|------------| -| Vercel Functions | 10s-300s timeout | -| Cloudflare Workers | 30s CPU time limit | -| AWS Lambda | 15min max timeout | -| Netlify Functions | 10s-26s timeout | - -**Why it doesn't work:** - -1. **Polling model**: Durably continuously polls for pending jobs -2. **Long-running jobs**: Steps may take minutes to complete -3. **Cold starts**: Each invocation starts fresh, breaking continuity -4. **Cost**: Constant polling would be expensive on pay-per-invocation models - -### Workarounds (Advanced) - -If you must use serverless for triggering jobs, you can: - -1. **Trigger only**: Use serverless to call `job.trigger()` and store jobs in Turso -2. **Separate worker**: Run the actual worker on a long-running platform - -```ts -// Vercel API route - trigger only -export async function POST(req: Request) { - const payload = await req.json() - await myJob.trigger(payload) // Just inserts into DB - return Response.json({ status: 'queued' }) -} - -// Fly.io worker - processes jobs -durably.start() // Long-running polling -``` - -## Browser Deployment - -For browser-based workers (using SQLite WASM with OPFS): - -- Host your static site anywhere (Vercel, Netlify, GitHub Pages) -- The worker runs entirely in the user's browser -- Data persists in OPFS (Origin Private File System) -- Requires HTTPS (Secure Context) - -See [Browser Guide](/guide/browser) for details. - -## Database Considerations - -### Turso (Recommended for Production) - -- Hosted SQLite-compatible database -- Works from any platform (including serverless for triggers) -- Built-in replication and backups - -### Local SQLite - -- Works for single-server deployments -- Use persistent volumes on containerized platforms -- Not suitable for horizontal scaling - -## Health Checks - -Monitor your worker with events: - -```ts -durably.on('run:complete', (event) => { - metrics.increment('jobs.completed') -}) - -durably.on('run:fail', (event) => { - metrics.increment('jobs.failed') - alerting.notify(event.error) -}) -``` - -## Graceful Shutdown - -Always handle shutdown signals: - -```ts -process.on('SIGTERM', async () => { - console.log('Shutting down...') - await durably.stop() - process.exit(0) -}) -``` - -This ensures in-progress jobs complete their current step before stopping. diff --git a/website/guide/events.md b/website/guide/events.md deleted file mode 100644 index 969b6566..00000000 --- a/website/guide/events.md +++ /dev/null @@ -1,143 +0,0 @@ -# Events - -Durably provides an event system to monitor job execution. - -::: tip -Examples on this page assume you have a `durably` instance created via `createDurably()`. See [Getting Started](/guide/getting-started) for setup. -::: - -## Available Events - -| Event | Description | Payload | -|-------|-------------|---------| -| `run:start` | Job execution started | `{ runId, jobName, input }` | -| `run:complete` | Job completed successfully | `{ runId, jobName, output }` | -| `run:fail` | Job failed with error | `{ runId, jobName, error }` | -| `run:progress` | Progress updated | `{ runId, jobName, progress }` | -| `step:start` | Step execution started | `{ runId, stepName, stepIndex }` | -| `step:complete` | Step completed | `{ runId, stepName, stepIndex, output }` | -| `step:skip` | Step skipped (cached) | `{ runId, stepName, stepIndex, output }` | -| `log:write` | Log message written | `{ runId, level, message }` | - -## Subscribing to Events - -Use `durably.on()` to subscribe: - -```ts -// Single event -const unsubscribe = durably.on('run:complete', (event) => { - console.log(`Job ${event.jobName} completed:`, event.output) -}) - -// Multiple events -durably.on('run:start', (e) => console.log('Started:', e.jobName)) -durably.on('run:fail', (e) => console.error('Failed:', e.error)) -durably.on('step:complete', (e) => console.log('Step done:', e.stepName)) -``` - -## Unsubscribing - -The `on()` method returns an unsubscribe function: - -```ts -const unsubscribe = durably.on('run:complete', handler) - -// Later... -unsubscribe() -``` - -## React Integration - -Events are useful for updating UI state: - -```tsx -function useDurably() { - const [status, setStatus] = useState<'idle' | 'running' | 'done'>('idle') - const [currentStep, setCurrentStep] = useState(null) - - useEffect(() => { - const unsubscribes = [ - durably.on('run:start', () => setStatus('running')), - durably.on('run:complete', () => { - setStatus('done') - setCurrentStep(null) - }), - durably.on('step:complete', (e) => setCurrentStep(e.stepName)), - ] - - return () => unsubscribes.forEach((fn) => fn()) - }, []) - - return { status, currentStep } -} -``` - -## Logging - -Use `step.log` within jobs to emit log events: - -```ts -import { defineJob } from '@coji/durably' - -const myJob = durably.register( - defineJob({ - name: 'my-job', - input: z.object({}), - run: async (step) => { - step.log.info('Starting processing') - - await step.run('step1', async () => { - step.log.info('Step 1 details', { someData: 123 }) - return result - }) - - step.log.info('Completed') - }, - }), -) - -// Subscribe to logs -durably.on('log:write', (event) => { - console.log(`[${event.level}] ${event.message}`) -}) -``` - -## Event-Driven Patterns - -### Progress Tracking - -```ts -let totalSteps = 5 -let completedSteps = 0 - -durably.on('step:complete', () => { - completedSteps++ - updateProgressBar(completedSteps / totalSteps * 100) -}) -``` - -### Metrics Collection - -```ts -const metrics = { - jobsCompleted: 0, - jobsFailed: 0, - avgDuration: 0, -} - -const startTimes = new Map() - -durably.on('run:start', (e) => { - startTimes.set(e.runId, Date.now()) -}) - -durably.on('run:complete', (e) => { - metrics.jobsCompleted++ - const duration = Date.now() - startTimes.get(e.runId) - // Update average... -}) - -durably.on('run:fail', () => { - metrics.jobsFailed++ -}) -``` diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index f4931145..02914359 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -1,141 +1,190 @@ # Getting Started -## Installation +Build a CSV importer with real-time progress UI. This guide uses React Router v7 for full-stack development. -::: code-group +![Getting Started Overview](/images/getting-started-overview.svg) -```bash [npm] -npm install @coji/durably kysely zod -``` - -```bash [pnpm] -pnpm add @coji/durably kysely zod -``` +## Install -```bash [yarn] -yarn add @coji/durably kysely zod +```bash +pnpm add @coji/durably @coji/durably-react kysely zod @libsql/client @libsql/kysely-libsql ``` -::: - -### Node.js +--- -For Node.js, you'll also need a SQLite driver: +## Server -::: code-group +### 1. Define a Job -```bash [libsql (recommended)] -npm install @libsql/client @libsql/kysely-libsql -``` +Define a job with multiple steps using `step.run()`. Each step's completion state is automatically persisted to SQLite. -```bash [better-sqlite3] -npm install better-sqlite3 -``` +```ts +// app/jobs/import-csv.ts +import { defineJob } from '@coji/durably' +import { z } from 'zod' -::: +export const importCsvJob = defineJob({ + name: 'import-csv', + input: z.object({ + filename: z.string(), + rows: z.array(z.object({ + name: z.string(), + email: z.string(), + })), + }), + output: z.object({ imported: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting import of ${payload.filename}`) -### Browser + // Step 1: Validate + const validRows = await step.run('validate', async () => { + step.progress(1, 3, 'Validating...') + return payload.rows.filter(row => row.email.includes('@')) + }) -For browsers, use SQLite WASM with OPFS: + // Step 2: Import + await step.run('import', async () => { + for (let i = 0; i < validRows.length; i++) { + step.progress(i + 1, validRows.length, `Importing ${validRows[i].name}...`) + // await db.insert('users', validRows[i]) + } + }) -```bash -npm install sqlocal + return { imported: validRows.length } + }, +}) ``` -## Quick Start - -### Node.js Example +Create a Durably instance and register the job. `createDurablyHandler` provides HTTP/SSE endpoints for the client. ```ts -import { createDurably, defineJob } from '@coji/durably' -import { createClient } from '@libsql/client' +// app/lib/durably.server.ts +import { createDurably, createDurablyHandler } from '@coji/durably' import { LibsqlDialect } from '@libsql/kysely-libsql' -import { z } from 'zod' +import { createClient } from '@libsql/client' +import { importCsvJob } from '~/jobs/import-csv' -// Create SQLite client const client = createClient({ url: 'file:local.db' }) const dialect = new LibsqlDialect({ client }) -// Initialize Durably -const durably = createDurably({ dialect }) +export const durably = createDurably({ dialect }).register({ + importCsv: importCsvJob, +}) -// Define a job -const processOrderJob = defineJob({ - name: 'process-order', - input: z.object({ orderId: z.string() }), - output: z.object({ status: z.string() }), - run: async (step, payload) => { - // Step 1: Validate order - const order = await step.run('validate', async () => { - return await validateOrder(payload.orderId) - }) +export const durablyHandler = createDurablyHandler(durably) - // Step 2: Process payment - await step.run('payment', async () => { - await processPayment(order) - }) +await durably.init() +``` - // Step 3: Send confirmation - await step.run('notify', async () => { - await sendConfirmation(order) - }) +### 2. Create API Route - return { status: 'completed' } - }, -}) +Use a React Router splat route to expose the Durably API. This automatically provides `/api/durably/trigger`, `/api/durably/subscribe`, and other endpoints. -// Register the job -const processOrder = durably.register(processOrderJob) +```ts +// app/routes/api.durably.$.ts +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' -// Start the worker and run migrations -await durably.migrate() -durably.start() +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} -// Trigger a job -await processOrder.trigger({ orderId: 'order_123' }) +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} ``` -### Browser Example +--- -```ts -import { createDurably, defineJob } from '@coji/durably' -import { SQLocalKysely } from 'sqlocal/kysely' -import { z } from 'zod' +## Client -// Create SQLite client with OPFS -const { dialect } = new SQLocalKysely('app.sqlite3') +### 3. Create Type-Safe Client -// Initialize Durably -const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) +Create a type-safe client using the server's Durably type. This gives you full type inference for job inputs and outputs. -// Define and register jobs the same way as Node.js -const syncData = durably.register( - defineJob({ - name: 'sync-data', - input: z.object({ userId: z.string() }), - run: async (step, payload) => { - const data = await step.run('fetch', async () => { - return await fetchUserData(payload.userId) - }) - - await step.run('save', async () => { - await saveLocally(data) - }) - }, - }), -) +```ts +// app/lib/durably.client.ts +import { createDurablyClient } from '@coji/durably-react/client' +// Type-only import: no server code is bundled, just TypeScript types +import type { durably } from './durably.server' -await durably.migrate() -durably.start() +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) ``` +### 4. Build the UI + +Build the UI with real-time progress updates. + +- **action**: Trigger the job on the server when form is submitted +- **useRun**: Subscribe to job progress via SSE + +```tsx +// app/routes/_index.tsx +import { Form } from 'react-router' +import { durably } from '~/lib/durably.server' +import { durablyClient } from '~/lib/durably.client' +import type { Route } from './+types/_index' + +// Server: trigger job on form submit +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData() + const file = formData.get('file') as File + const text = await file.text() + const rows = text.split('\n').slice(1).map(line => { + const [name, email] = line.split(',') + return { name, email } + }) + const run = await durably.jobs.importCsv.trigger({ + filename: file.name, + rows, + }) + return { runId: run.id } +} + +// Client: subscribe to real-time progress via SSE +export default function Home({ actionData }: Route.ComponentProps) { + const { progress, output, isRunning, isCompleted } = + durablyClient.importCsv.useRun(actionData?.runId ?? null) + + return ( +
+
+ + +
+ + {progress && ( +

Progress: {progress.current}/{progress.total} - {progress.message}

+ )} + {isCompleted &&

Done! Imported {output?.imported} rows

} +
+ ) +} +``` + +--- + +## Try It + +1. Create `test.csv`: + ```csv + name,email + Alice,alice@example.com + Bob,bob@example.com + ``` + +2. Start server: `pnpm dev` + +3. Upload the CSV and watch real-time progress! + +**Resume support**: Stop the server mid-import and restart — it picks up right where it left off. + ## Next Steps -- [Jobs and Steps](/guide/jobs-and-steps) - Learn about defining jobs and steps -- [Resumability](/guide/resumability) - Understand how resumption works -- [Events](/guide/events) - Monitor job execution with events +- **[CSV Import (Full-Stack)](/guide/csv-import)** — Complete tutorial with dashboard +- **[Offline App (Browser-Only)](/guide/offline-app)** — Run entirely in the browser +- **[Background Sync (Server)](/guide/background-sync)** — Server-only batch processing diff --git a/website/guide/index.md b/website/guide/index.md index 8cfea182..582d0476 100644 --- a/website/guide/index.md +++ b/website/guide/index.md @@ -1,75 +1,49 @@ # What is Durably? -Durably is a step-oriented batch execution framework that enables **resumable workflows** in both Node.js and browsers. +Durably is a **resumable job execution** library for TypeScript. Split long-running tasks into steps — if interrupted, resume from the last successful step. ## The Problem -When running batch jobs or workflows, failures can happen at any point: -- Network errors during API calls -- Process crashes -- Browser tab closures -- Server restarts +Long-running tasks fail. Networks drop, servers restart, browsers close. Traditional approaches either: -Traditional approaches require you to either: -- Re-run the entire job from the beginning -- Implement complex checkpointing logic manually +- **Lose all progress** and restart from scratch +- **Require complex infrastructure** like Redis queues or cloud services ## The Solution -Durably automatically persists the result of each step to SQLite. If a job is interrupted, it resumes from the last successful step. Durably uses [Kysely](https://kysely.dev) for database access—you provide a dialect for your SQLite implementation. +Durably saves each step's result to SQLite. On resume, completed steps return cached results instantly. + +![Resumability](/images/resumability.svg) ```ts -import { createDurably, defineJob } from '@coji/durably' -import { LibsqlDialect } from '@libsql/kysely-libsql' // or your SQLite dialect -import { z } from 'zod' - -// Create durably instance with SQLite dialect -const dialect = new LibsqlDialect({ url: 'file:app.db' }) -const durably = createDurably({ dialect }) - -// Define job (static, can be in a separate file) -const syncUsersJob = defineJob({ - name: 'sync-users', - input: z.object({ orgId: z.string() }), +const job = defineJob({ + name: 'import-csv', run: async (step, payload) => { - // Step 1: Fetch users (persisted after completion) - const users = await step.run('fetch-users', async () => { - return api.fetchUsers(payload.orgId) - }) + // Step 1: Parse (cached after first run) + const rows = await step.run('parse', () => parseCSV(payload.file)) - // Step 2: Save to database (skipped if already done) - await step.run('save-to-db', async () => { - await db.upsertUsers(users) - }) + // Step 2: Import each row + for (const [i, row] of rows.entries()) { + await step.run(`import-${i}`, () => db.insert(row)) + step.progress(i + 1, rows.length) + } - return { syncedCount: users.length } + return { count: rows.length } }, }) - -// Register and trigger -const syncUsers = durably.register(syncUsersJob) -await syncUsers.trigger({ orgId: 'org_123' }) ``` -## Key Features - -- **Step-level persistence**: Each `step.run()` call creates a checkpoint -- **Automatic resumption**: Interrupted jobs resume from the last successful step -- **Cross-platform**: Same code runs in Node.js and browsers -- **Minimal dependencies**: Just Kysely and Zod -- **Type-safe**: Full TypeScript support with schema validation +If the process crashes after importing 500 of 1000 rows, restart picks up at row 501. -## When to Use Durably +## Where It Runs -Durably is ideal for: +| Environment | Storage | Use Case | +|-------------|---------|----------| +| **Node.js** | @libsql/client, better-sqlite3 | Server-side batch jobs | +| **Browser** | SQLite WASM + OPFS | Offline-capable apps | -- **Data synchronization jobs** - Fetching and processing data from external APIs -- **Batch processing** - Processing large datasets in steps -- **Browser workflows** - Long-running operations that survive page reloads -- **Offline-first applications** - Operations that need to resume after connectivity is restored +Same job definition works in both environments. -## Next Steps +## Next Step -- [Getting Started](/guide/getting-started) - Install and create your first job -- [Jobs and Steps](/guide/jobs-and-steps) - Learn about the core concepts -- [Live Demo](https://durably-demo.vercel.app) - Try it in your browser +**[Getting Started →](/guide/getting-started)** — Build a CSV importer with progress UI in 5 minutes. diff --git a/website/guide/jobs-and-steps.md b/website/guide/jobs-and-steps.md deleted file mode 100644 index 97b4c50b..00000000 --- a/website/guide/jobs-and-steps.md +++ /dev/null @@ -1,124 +0,0 @@ -# Jobs and Steps - -## Defining a Job - -Jobs are defined using the standalone `defineJob()` function and registered with `durably.register()`: - -```ts -import { createDurably, defineJob } from '@coji/durably' -import { z } from 'zod' - -// Create durably instance (see Getting Started for dialect setup) -const durably = createDurably({ dialect }) - -const myJobDef = defineJob({ - name: 'my-job', - input: z.object({ id: z.string() }), - output: z.object({ result: z.string() }), - run: async (step, payload) => { - // Job implementation - return { result: 'done' } - }, -}) - -// Register to get a job handle -const myJob = durably.register(myJobDef) -``` - -### Job Options - -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `name` | `string` | Yes | Unique job identifier | -| `input` | `ZodSchema` | Yes | Schema for job payload | -| `output` | `ZodSchema` | No | Schema for job return value | -| `run` | `Function` | Yes | The job's run function | - -## Creating Steps - -Steps are created using `step.run()`: - -```ts -const result = await step.run('step-name', async () => { - // Step logic here - return someValue -}) -``` - -### Step Behavior - -1. **First execution**: The function runs and its return value is persisted -2. **Subsequent executions**: The persisted value is returned without running the function -3. **Type inference**: The return type is inferred from the function - -### Step Names - -Step names must be unique within a job: - -```ts -// Good - unique names -await step.run('fetch-user', async () => { ... }) -await step.run('update-profile', async () => { ... }) - -// Bad - duplicate names will cause issues -await step.run('step', async () => { ... }) -await step.run('step', async () => { ... }) // Won't work correctly -``` - -## Triggering Jobs - -### Basic Trigger - -```ts -await myJob.trigger({ id: 'abc123' }) -``` - -### With Idempotency Key - -Prevent duplicate job runs: - -```ts -await myJob.trigger( - { id: 'abc123' }, - { idempotencyKey: 'unique-request-id' } -) -``` - -### With Concurrency Key - -Control concurrent execution: - -```ts -await myJob.trigger( - { id: 'abc123' }, - { concurrencyKey: 'user_123' } -) -``` - -## Job Lifecycle - -``` -trigger() → pending → running → completed - ↘ ↗ - → failed -``` - -1. **pending**: Job is queued, waiting for worker -2. **running**: Worker is executing the job -3. **completed**: Job finished successfully -4. **failed**: Job encountered an error - -## Error Handling - -Errors in steps cause the job to fail: - -```ts -await step.run('might-fail', async () => { - if (someCondition) { - throw new Error('Something went wrong') - } - return result -}) -``` - -The job status becomes `failed` and the error is stored. Failed jobs can be retried using `durably.retry(runId)`. diff --git a/website/guide/nodejs.md b/website/guide/nodejs.md deleted file mode 100644 index 754cca0e..00000000 --- a/website/guide/nodejs.md +++ /dev/null @@ -1,182 +0,0 @@ -# Node.js - -This guide covers using Durably in Node.js environments. - -## SQLite Drivers - -Durably works with any Kysely-compatible SQLite dialect. - -### Turso / libsql (Recommended) - -[Turso](https://turso.tech) is a SQLite-compatible database built on [libsql](https://github.com/tursodatabase/libsql). Use local files for development and Turso cloud for production: - -```ts -import { LibsqlDialect } from '@libsql/kysely-libsql' - -// Local development -const dialect = new LibsqlDialect({ - url: 'file:local.db', -}) - -// Production (Turso cloud) -const dialect = new LibsqlDialect({ - url: process.env.TURSO_DATABASE_URL, - authToken: process.env.TURSO_AUTH_TOKEN, -}) - -const durably = createDurably({ dialect }) -``` - -Install dependencies: - -```bash -npm install @libsql/client @libsql/kysely-libsql -``` - -### better-sqlite3 - -[better-sqlite3](https://github.com/WiseLibs/better-sqlite3) is a synchronous SQLite driver: - -```ts -import SQLite from 'better-sqlite3' -import { SqliteDialect } from 'kysely' - -const database = new SQLite('local.db') -const dialect = new SqliteDialect({ database }) - -const durably = createDurably({ dialect }) -``` - -## Configuration - -```ts -const durably = createDurably({ - dialect, - pollingInterval: 1000, // Check for pending jobs every 1s - heartbeatInterval: 5000, // Update heartbeat every 5s - staleThreshold: 30000, // Mark jobs stale after 30s -}) -``` - -## Lifecycle - -```ts -// Run database migrations -await durably.migrate() - -// Start the worker -durably.start() - -// Trigger jobs -await myJob.trigger({ data: 'value' }) - -// Stop gracefully -await durably.stop() -``` - -## Process Signals - -Handle graceful shutdown: - -```ts -process.on('SIGTERM', async () => { - console.log('Shutting down...') - await durably.stop() - process.exit(0) -}) - -process.on('SIGINT', async () => { - console.log('Interrupted...') - await durably.stop() - process.exit(0) -}) -``` - -## Worker Patterns - -### Single Worker - -The simplest pattern - one worker per process: - -```ts -const durably = createDurably({ dialect }) -await durably.migrate() -durably.start() -``` - -### Multiple Processes - -Durably supports multiple workers competing for jobs: - -```ts -// worker-1.ts -const durably = createDurably({ dialect }) -durably.start() - -// worker-2.ts (separate process) -const durably = createDurably({ dialect }) -durably.start() -``` - -Jobs are claimed atomically - only one worker processes each job. - -## Error Handling - -```ts -durably.on('run:fail', (event) => { - console.error(`Job ${event.runId} failed:`, event.error) - - // Send to error tracking - Sentry.captureException(new Error(event.error), { - extra: { runId: event.runId, jobName: event.jobName }, - }) -}) -``` - -## Retrying Failed Jobs - -```ts -// Get failed runs -const failedRuns = await durably.getFailedRuns() - -// Retry a specific run -await durably.retry(failedRuns[0].id) -``` - -## Integration with Frameworks - -### Express - -```ts -import express from 'express' - -const app = express() - -app.post('/api/trigger-job', async (req, res) => { - const { id } = req.body - await myJob.trigger({ id }) - res.json({ status: 'triggered' }) -}) - -// Start both -await durably.migrate() -durably.start() -app.listen(3000) -``` - -### Fastify - -```ts -import Fastify from 'fastify' - -const fastify = Fastify() - -fastify.post('/api/trigger-job', async (req) => { - await myJob.trigger(req.body) - return { status: 'triggered' } -}) - -await durably.migrate() -durably.start() -await fastify.listen({ port: 3000 }) -``` diff --git a/website/guide/offline-app.md b/website/guide/offline-app.md new file mode 100644 index 00000000..b4270272 --- /dev/null +++ b/website/guide/offline-app.md @@ -0,0 +1,230 @@ +# Offline App (Browser-Only) + +Run Durably entirely in the browser. Jobs execute locally using SQLite WASM with OPFS persistence. Works offline, survives tab closes. + +**Example code:** [browser-vite-react](https://github.com/coji/durably/tree/main/examples/browser-vite-react) + +## When to Use + +- Offline-capable applications +- Local-first apps (data stays on device) +- Prototyping without backend + +## Requirements + +### Secure Context + +Requires HTTPS or localhost for OPFS access. + +### COOP/COEP Headers + +SQLite WASM needs cross-origin isolation: + +```ts +// vite.config.ts +export default defineConfig({ + plugins: [ + { + name: 'coop-coep', + configureServer: (server) => { + server.middlewares.use((_req, res, next) => { + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + next() + }) + }, + }, + ], + optimizeDeps: { exclude: ['sqlocal'] }, +}) +``` + +## Installation + +```bash +pnpm add @coji/durably @coji/durably-react kysely zod sqlocal +``` + +## Setup + +### Database + +Create a SQLocal instance for SQLite WASM with OPFS persistence. This database file will be stored in the browser's Origin Private File System. + +```ts +// lib/database.ts +import { SQLocalKysely } from 'sqlocal/kysely' + +export const sqlocal = new SQLocalKysely('app.sqlite3') +``` + +### Job Definition + +Define a job with multiple steps. Each `step.run()` call creates a checkpoint - if the browser tab is closed mid-execution, the job will resume from the last completed step. + +```ts +// jobs/data-sync.ts +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +export const dataSyncJob = defineJob({ + name: 'data-sync', + input: z.object({ userId: z.string() }), + output: z.object({ synced: z.number(), failed: z.number() }), + run: async (step, payload) => { + step.log.info(`Starting sync for user: ${payload.userId}`) + + const items = await step.run('fetch-local', async () => { + step.progress(1, 4, 'Fetching local data...') + await delay(300) + return Array.from({ length: 10 }, (_, i) => ({ + id: `item-${i}`, + data: `Data for ${payload.userId}`, + })) + }) + + let synced = 0 + let failed = 0 + + for (let i = 0; i < items.length; i++) { + const item = items[i] + const success = await step.run(`sync-item-${item.id}`, async () => { + step.progress(2 + Math.floor(i / 5), 4, `Syncing item ${i + 1}...`) + await delay(100) + return Math.random() > 0.1 // 90% success rate + }) + + if (success) { + synced++ + } else { + failed++ + step.log.warn(`Failed to sync item: ${item.id}`) + } + } + + await step.run('finalize', async () => { + step.progress(4, 4, 'Finalizing...') + await delay(200) + }) + + step.log.info(`Sync complete: ${synced} synced, ${failed} failed`) + + return { synced, failed } + }, +}) +``` + +### Durably Instance + +Create the Durably instance with SQLocal's dialect. The shorter intervals are optimized for browser environments where tab suspension can occur more frequently. + +```ts +// lib/durably.ts +import { createDurably } from '@coji/durably' +import { dataSyncJob } from '../jobs/data-sync' +import { sqlocal } from './database' + +const durably = createDurably({ + dialect: sqlocal.dialect, + pollingInterval: 100, // Check for pending jobs every 100ms + heartbeatInterval: 500, // Send heartbeat every 500ms + staleThreshold: 3000, // Mark job as stale after 3s without heartbeat +}).register({ + dataSync: dataSyncJob, +}) + +await durably.init() + +export { durably } +``` + +## Usage + +Wrap your app with `DurablyProvider` to enable the hooks. The `fallback` component is shown while the database is initializing. + +Use `useJob` to trigger jobs and subscribe to their progress. The hook returns the current status, progress, and output. + +```tsx +// App.tsx +import { DurablyProvider, useDurably } from '@coji/durably-react' +import { useJob } from '@coji/durably-react' +import { useState } from 'react' +import { durably } from './lib/durably' +import { dataSyncJob } from './jobs/data-sync' + +function SyncButton() { + const { isReady } = useDurably() + const [runId, setRunId] = useState(null) + + const handleSync = async () => { + const run = await durably.jobs.dataSync.trigger({ userId: 'user_123' }) + setRunId(run.id) + } + + const { status, progress, output, isRunning, isCompleted, error } = + useJob(dataSyncJob, { initialRunId: runId ?? undefined }) + + return ( +
+ + + {progress &&

{progress.current}/{progress.total} - {progress.message}

} + {isCompleted &&

Synced {output?.synced}, failed {output?.failed}

} + {error &&

Error: {error}

} +
+ ) +} + +function Loading() { + return
Loading...
+} + +export function App() { + return ( + }> + + + ) +} +``` + +## Hook Options + +```tsx +const { trigger, ... } = useJob(dataSyncJob, { + initialRunId: 'run_123', // Resume existing run + maxLogs: 100, // Limit log entries +}) +``` + +## Available Hooks + +| Hook | Description | +|------|-------------| +| `useJob(jobDef)` | Trigger and monitor a job | +| `useJobRun({ runId })` | Subscribe to an existing run | +| `useJobLogs({ runId })` | Subscribe to logs | +| `useRuns()` | List runs with pagination | +| `useDurably()` | Access Durably instance | + +## Limitations + +- **Single tab** — OPFS exclusive access +- **Storage quotas** — Browser limits apply +- **No background** — Jobs only run when tab is active + +## Tab Suspension + +Browsers suspend inactive tabs. Durably handles this: + +1. Tab inactive → heartbeat stops → job marked stale +2. Tab active → worker restarts → job resumes + +## Next Steps + +- [CSV Import](/guide/csv-import) — Full-stack with server +- [API Reference](/api/durably-react/) — All hooks and options diff --git a/website/guide/react.md b/website/guide/react.md deleted file mode 100644 index a78ed101..00000000 --- a/website/guide/react.md +++ /dev/null @@ -1,422 +0,0 @@ -# React - -This guide covers using Durably in React applications with best practices for hooks, StrictMode, and state management. - -## Basic Setup - -### Creating a Durably Instance - -Create a singleton instance outside of React components: - -```tsx -// lib/durably.ts -import { createDurably } from '@coji/durably' -import { SQLocalKysely } from 'sqlocal/kysely' - -const { dialect } = new SQLocalKysely('app.sqlite3') - -export const durably = createDurably({ - dialect, - pollingInterval: 100, - heartbeatInterval: 500, - staleThreshold: 3000, -}) -``` - -### Initialization Hook - -```tsx -// hooks/useDurably.ts -import { useEffect, useState } from 'react' -import { durably } from '../lib/durably' - -export function useDurably() { - const [ready, setReady] = useState(false) - - useEffect(() => { - let cancelled = false - - async function init() { - await durably.migrate() - if (!cancelled) { - durably.start() - setReady(true) - } - } - init() - - return () => { - cancelled = true - durably.stop() - } - }, []) - - return { ready, durably } -} -``` - -## StrictMode Compatibility - -React StrictMode mounts and unmounts components twice in development. Durably handles this gracefully, but you should follow these patterns: - -### Use a Cancelled Flag - -```tsx -useEffect(() => { - let cancelled = false - - async function init() { - await durably.migrate() - if (cancelled) return // Check before state updates - - durably.start() - setReady(true) - } - init() - - return () => { - cancelled = true - durably.stop() - } -}, []) -``` - -### Singleton Pattern - -For shared state across components: - -```tsx -// lib/durably.ts -let instance: Durably | null = null -let initPromise: Promise | null = null - -export async function getDurably() { - if (!instance) { - const { dialect } = new SQLocalKysely('app.sqlite3') - instance = createDurably({ dialect }) - initPromise = instance.migrate() - } - await initPromise - return instance -} - -// hooks/useDurably.ts -export function useDurably() { - const [durably, setDurably] = useState(null) - - useEffect(() => { - let cancelled = false - - getDurably().then((d) => { - if (!cancelled) { - d.start() - setDurably(d) - } - }) - - return () => { - cancelled = true - } - }, []) - - return durably -} -``` - -## Job Status Tracking - -### Track Individual Job Runs - -```tsx -function useJobStatus(job: JobHandle) { - const [runs, setRuns] = useState([]) - - useEffect(() => { - // Load initial runs - job.getRuns().then(setRuns) - - // Subscribe to updates - const unsubs = [ - durably.on('run:start', async (e) => { - const run = await job.getRun(e.runId) - if (run) setRuns((prev) => [...prev, run]) - }), - durably.on('run:complete', async (e) => { - setRuns((prev) => - prev.map((r) => - r.id === e.runId ? { ...r, status: 'completed', output: e.output } : r - ) - ) - }), - durably.on('run:fail', async (e) => { - setRuns((prev) => - prev.map((r) => - r.id === e.runId ? { ...r, status: 'failed', error: e.error } : r - ) - ) - }), - ] - - return () => unsubs.forEach((fn) => fn()) - }, [job]) - - return runs -} -``` - -### Processing State Hook - -```tsx -function useProcessingState() { - const [processing, setProcessing] = useState(false) - const [currentRunId, setCurrentRunId] = useState(null) - - useEffect(() => { - const unsubs = [ - durably.on('run:start', (e) => { - setProcessing(true) - setCurrentRunId(e.runId) - }), - durably.on('run:complete', () => { - setProcessing(false) - setCurrentRunId(null) - }), - durably.on('run:fail', () => { - setProcessing(false) - setCurrentRunId(null) - }), - ] - - return () => unsubs.forEach((fn) => fn()) - }, []) - - return { processing, currentRunId } -} -``` - -## Progress Tracking - -### With Progress Events - -```tsx -function useProgress(runId: string | null) { - const [progress, setProgress] = useState<{ - current: number - total?: number - message?: string - } | null>(null) - - useEffect(() => { - if (!runId) { - setProgress(null) - return - } - - const unsub = durably.on('run:progress', (e) => { - if (e.runId === runId) { - setProgress({ - current: e.current, - total: e.total, - message: e.message, - }) - } - }) - - return unsub - }, [runId]) - - return progress -} -``` - -### Progress UI Component - -```tsx -function ProgressBar({ runId }: { runId: string | null }) { - const progress = useProgress(runId) - - if (!progress) return null - - const percent = progress.total - ? Math.round((progress.current / progress.total) * 100) - : null - - return ( -
- {percent !== null && ( -
- )} - {progress.message || `${progress.current}/${progress.total || '?'}`} -
- ) -} -``` - -## Complete Example - -```tsx -import { useEffect, useState } from 'react' -import { defineJob } from '@coji/durably' -import { z } from 'zod' -import { durably } from './lib/durably' - -// Define job outside component -const processDataJobDef = defineJob({ - name: 'process-data', - input: z.object({ items: z.array(z.string()) }), - output: z.object({ processed: z.number() }), - run: async (step, payload) => { - let processed = 0 - - for (const item of payload.items) { - await step.run(`process-${item}`, async () => { - // Simulate work - await new Promise((r) => setTimeout(r, 500)) - processed++ - }) - step.progress(processed, payload.items.length) - } - - return { processed } - }, -}) - -// Register the job -const processDataJob = durably.register(processDataJobDef) - -function App() { - const [ready, setReady] = useState(false) - const [processing, setProcessing] = useState(false) - const [result, setResult] = useState<{ processed: number } | null>(null) - - useEffect(() => { - let cancelled = false - - async function init() { - await durably.migrate() - if (cancelled) return - durably.start() - setReady(true) - } - init() - - const unsubs = [ - durably.on('run:start', () => setProcessing(true)), - durably.on('run:complete', (e) => { - setProcessing(false) - setResult(e.output as { processed: number }) - }), - durably.on('run:fail', () => setProcessing(false)), - ] - - return () => { - cancelled = true - unsubs.forEach((fn) => fn()) - durably.stop() - } - }, []) - - const handleProcess = async () => { - setResult(null) - await processDataJob.trigger({ - items: ['item-1', 'item-2', 'item-3'], - }) - } - - if (!ready) { - return
Initializing...
- } - - return ( -
- - - {result &&

Processed {result.processed} items

} -
- ) -} -``` - -## Context Provider Pattern - -For larger applications: - -```tsx -// context/DurablyContext.tsx -import { createContext, useContext, useEffect, useState, ReactNode } from 'react' -import { durably, type Durably } from '../lib/durably' - -interface DurablyContextValue { - durably: Durably - ready: boolean -} - -const DurablyContext = createContext(null) - -export function DurablyProvider({ children }: { children: ReactNode }) { - const [ready, setReady] = useState(false) - - useEffect(() => { - let cancelled = false - - durably.migrate().then(() => { - if (!cancelled) { - durably.start() - setReady(true) - } - }) - - return () => { - cancelled = true - durably.stop() - } - }, []) - - return ( - - {children} - - ) -} - -export function useDurablyContext() { - const context = useContext(DurablyContext) - if (!context) { - throw new Error('useDurablyContext must be used within DurablyProvider') - } - return context -} -``` - -Usage: - -```tsx -// App.tsx -import { DurablyProvider, useDurablyContext } from './context/DurablyContext' - -function JobRunner() { - const { ready } = useDurablyContext() - - if (!ready) return
Loading...
- - return -} - -function App() { - return ( - - - - ) -} -``` - -## Best Practices - -1. **Create durably instance outside components** - Avoid creating instances inside useEffect -2. **Use cancelled flags** - Prevent state updates after unmount -3. **Clean up event listeners** - Always unsubscribe in cleanup -4. **Define jobs at module level** - Jobs should be defined once, not per render -5. **Handle StrictMode** - Test your app in development mode to catch issues early diff --git a/website/guide/resumability.md b/website/guide/resumability.md deleted file mode 100644 index 08478de3..00000000 --- a/website/guide/resumability.md +++ /dev/null @@ -1,134 +0,0 @@ -# Resumability - -Durably's core feature is automatic job resumption. This page explains how it works. - -## How It Works - -### Step Persistence - -Every `step.run()` call creates a checkpoint: - -```ts -// Step 1: Result persisted to SQLite -const users = await step.run('fetch-users', async () => { - return await api.fetchUsers() // Takes 5 seconds -}) - -// Step 2: If crash happens here... -await step.run('process-users', async () => { - await processAll(users) // Crash! -}) - -// Step 3: Never reached -await step.run('notify', async () => { - await sendNotification() -}) -``` - -### On Resume - -When the job restarts: - -```ts -// Step 1: Returns cached result instantly (no API call) -const users = await step.run('fetch-users', async () => { - return await api.fetchUsers() // Skipped! -}) - -// Step 2: Re-executes from the beginning -await step.run('process-users', async () => { - await processAll(users) // Runs again -}) - -// Step 3: Runs normally -await step.run('notify', async () => { - await sendNotification() -}) -``` - -## Heartbeat Mechanism - -Durably uses heartbeats to detect abandoned jobs: - -```ts -const durably = createDurably({ - dialect, - heartbeatInterval: 5000, // Update heartbeat every 5 seconds - staleThreshold: 30000, // Consider stale after 30 seconds -}) -``` - -### How It Works - -1. Running jobs update their `heartbeat_at` timestamp periodically -2. Worker checks for stale jobs (no heartbeat update for `staleThreshold` ms) -3. Stale jobs are reset to `pending` and picked up again - -### Browser Tab Handling - -In browsers, tabs can be suspended. When the tab becomes active: - -1. The heartbeat resumes -2. If the job was marked stale, it restarts from the last checkpoint - -## Idempotency - -Steps should be designed to be safely re-runnable: - -### Good: Idempotent Operations - -```ts -// Using upsert instead of insert -await step.run('save-user', async () => { - await db.upsertUser(user) // Safe to retry -}) - -// Checking before action -await step.run('send-email', async () => { - const sent = await db.wasEmailSent(userId) - if (!sent) { - await sendEmail(user) - await db.markEmailSent(userId) - } -}) -``` - -### Caution: Non-Idempotent Operations - -```ts -// Be careful with operations that can't be safely repeated -await step.run('charge-card', async () => { - // Use idempotency keys with payment providers - await stripe.charges.create({ - amount: 1000, - idempotency_key: `charge_${orderId}`, - }) -}) -``` - -## Partial Step Completion - -If a step crashes mid-execution, the entire step is re-run: - -```ts -await step.run('process-items', async () => { - for (const item of items) { - await processItem(item) // Crash after 50 items - } - // On resume: ALL items are processed again -}) -``` - -For large operations, consider breaking into smaller steps: - -```ts -// Better: Process in batches -for (let i = 0; i < items.length; i += 100) { - await step.run(`batch-${i}`, async () => { - const batch = items.slice(i, i + 100) - for (const item of batch) { - await processItem(item) - } - }) -} -``` diff --git a/website/package.json b/website/package.json index 520a1dec..2cb707e1 100644 --- a/website/package.json +++ b/website/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "scripts": { - "dev": "vitepress dev", - "build": "vitepress build", + "generate:llms": "node scripts/generate-llms.js", + "dev": "pnpm generate:llms && vitepress dev", + "build": "pnpm generate:llms && vitepress build", "preview": "vitepress preview" }, "devDependencies": { diff --git a/website/public/images/fullstack-architecture.svg b/website/public/images/fullstack-architecture.svg new file mode 100644 index 00000000..af8767db --- /dev/null +++ b/website/public/images/fullstack-architecture.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + Browser + + + + Form (CSV Upload) + + + useRun(runId) + + + Progress UI + 50/100 rows + + + + Server (React Router) + + + + action: Parse CSV + Trigger Job + /api/durably/trigger + + + Durably Worker + Execute steps, emit events + + + SSE Stream + /api/durably/subscribe + + + + + + POST + + + + + SSE + + + + + Request + + Events + diff --git a/website/public/images/getting-started-overview.svg b/website/public/images/getting-started-overview.svg new file mode 100644 index 00000000..a4f66e46 --- /dev/null +++ b/website/public/images/getting-started-overview.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + SERVER + + + + + 1 + Define Job + Create job with + steps and schema + import-csv.ts + durably.server.ts + + + + + + + + 2 + API Route + Expose HTTP + and SSE endpoints + api.durably.$.ts + + + + CLIENT + + + + + + + + 3 + Create Client + Type-safe hooks + from server types + durably.client.ts + + + + + + + + 4 + Build UI + Form + Progress + with real-time SSE + _index.tsx + diff --git a/website/public/images/job-lifecycle.svg b/website/public/images/job-lifecycle.svg new file mode 100644 index 00000000..70f2ec82 --- /dev/null +++ b/website/public/images/job-lifecycle.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + trigger() + + + + + pending + + + + worker + + + + running + + + + + + + + + + cancel() + + + + completed + + + + failed + + + + cancelled + + + + + retry() + diff --git a/website/public/images/resumability.svg b/website/public/images/resumability.svg new file mode 100644 index 00000000..e5f9424f --- /dev/null +++ b/website/public/images/resumability.svg @@ -0,0 +1,94 @@ + + + + + + + First Run + + + + Step 1: Parse + + + Step 2: Import + + + Step 3: Notify + CRASH! + + + + + + + + + + + + + + saved + saved + + + + Process restarts... + + + After Restart + + + + Step 1: Parse + CACHED + + + Step 2: Import + CACHED + + + Step 3: Notify + RUNS + + + + + + + + skip completed steps + + + + + How Durably Works + + + Each step.run() saves result to SQLite + + + Process crashes mid-execution + + + On restart: completed steps return + cached results instantly + + + Execution continues from next + incomplete step + + + + + + + + diff --git a/website/public/llms.txt b/website/public/llms.txt deleted file mode 120000 index 5840e17c..00000000 --- a/website/public/llms.txt +++ /dev/null @@ -1 +0,0 @@ -../../packages/durably/docs/llms.md \ No newline at end of file diff --git a/website/public/llms.txt b/website/public/llms.txt new file mode 100644 index 00000000..c6afd344 --- /dev/null +++ b/website/public/llms.txt @@ -0,0 +1,1062 @@ +# Durably - LLM Documentation + +> Step-oriented resumable batch execution for Node.js and browsers using SQLite. + +## Overview + +Durably is a minimal workflow engine that persists step results to SQLite. If a job is interrupted (server restart, browser tab close, crash), it automatically resumes from the last successful step. + +## Installation + +```bash +# Node.js with libsql (recommended) +pnpm add @coji/durably kysely zod @libsql/client @libsql/kysely-libsql + +# Browser with SQLocal +pnpm add @coji/durably kysely zod sqlocal +``` + +## Core Concepts + +### 1. Durably Instance + +```ts +import { createDurably } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' + +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) + +const durably = createDurably({ + dialect, + pollingInterval: 1000, // Job polling interval (ms) + heartbeatInterval: 5000, // Heartbeat update interval (ms) + staleThreshold: 30000, // When to consider a job abandoned (ms) +}) +``` + +### 2. Job Definition + +```ts +import { defineJob } from '@coji/durably' +import { z } from 'zod' + +const syncUsersJob = defineJob({ + name: 'sync-users', + input: z.object({ orgId: z.string() }), + output: z.object({ syncedCount: z.number() }), + run: async (step, payload) => { + // Step 1: Fetch users (result is persisted) + const users = await step.run('fetch-users', async () => { + return await api.fetchUsers(payload.orgId) + }) + + // Step 2: Save to database + await step.run('save-to-db', async () => { + await db.upsertUsers(users) + }) + + return { syncedCount: users.length } + }, +}) + +// Register jobs with durably instance +const { syncUsers } = durably.register({ + syncUsers: syncUsersJob, +}) +``` + +### 3. Starting the Worker + +```ts +// Initialize: runs migrations and starts the worker +await durably.init() + +// Or separately if needed: +// await durably.migrate() // Run migrations only +// durably.start() // Start worker only +``` + +### 4. Triggering Jobs + +```ts +// Basic trigger (fire and forget) +const run = await syncUsers.trigger({ orgId: 'org_123' }) +console.log(run.id, run.status) // "pending" + +// Wait for completion +const result = await syncUsers.triggerAndWait( + { orgId: 'org_123' }, + { timeout: 5000 }, +) +console.log(result.output.syncedCount) + +// With idempotency key (prevents duplicate jobs) +await syncUsers.trigger( + { orgId: 'org_123' }, + { idempotencyKey: 'webhook-event-456' }, +) + +// With concurrency key (serializes execution) +await syncUsers.trigger({ orgId: 'org_123' }, { concurrencyKey: 'org_123' }) +``` + +## Step Context API + +The `step` object provides these methods: + +### step.run(name, fn) + +Executes a step and persists its result. On resume, returns cached result without re-executing. + +```ts +const result = await step.run('step-name', async () => { + return await someAsyncOperation() +}) +``` + +### step.progress(current, total?, message?) + +Updates progress information for the run. + +```ts +step.progress(50, 100, 'Processing items...') +``` + +### step.log + +Structured logging within jobs. + +```ts +step.log.info('Starting process', { userId: '123' }) +step.log.warn('Rate limit approaching') +step.log.error('Failed to connect', { error: err.message }) +``` + +## Run Management + +### Get Run Status + +```ts +// Via job handle (type-safe output) +const run = await syncUsers.getRun(runId) +if (run?.status === 'completed') { + console.log(run.output.syncedCount) +} + +// Via durably instance (cross-job) +const run = await durably.getRun(runId) +``` + +### Query Runs + +```ts +// Get failed runs +const failedRuns = await durably.getRuns({ status: 'failed' }) + +// Filter by job name with pagination +const runs = await durably.getRuns({ + jobName: 'sync-users', + status: 'completed', + limit: 10, + offset: 0, +}) +``` + +### Retry Failed Runs + +```ts +await durably.retry(runId) +``` + +### Cancel Runs + +```ts +await durably.cancel(runId) +``` + +### Delete Runs + +```ts +await durably.deleteRun(runId) +``` + +## Events + +Subscribe to job execution events: + +```ts +// Run lifecycle events +durably.on('run:trigger', (e) => console.log('Triggered:', e.runId)) +durably.on('run:start', (e) => console.log('Started:', e.runId)) +durably.on('run:complete', (e) => console.log('Done:', e.output)) +durably.on('run:fail', (e) => console.error('Failed:', e.error)) +durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId)) +durably.on('run:retry', (e) => console.log('Retried:', e.runId)) +durably.on('run:progress', (e) => + console.log('Progress:', e.progress.current, '/', e.progress.total), +) + +// Step events +durably.on('step:start', (e) => console.log('Step:', e.stepName)) +durably.on('step:complete', (e) => console.log('Step done:', e.stepName)) +durably.on('step:fail', (e) => console.error('Step failed:', e.stepName)) + +// Log events +durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message)) +``` + +## Advanced APIs + +### getJob + +Get a registered job by name: + +```ts +const job = durably.getJob('sync-users') +if (job) { + const run = await job.trigger({ orgId: 'org_123' }) +} +``` + +### subscribe + +Subscribe to events for a specific run as a ReadableStream: + +```ts +const stream = durably.subscribe(runId) +const reader = stream.getReader() + +while (true) { + const { done, value } = await reader.read() + if (done) break + + switch (value.type) { + case 'run:start': + console.log('Started') + break + case 'run:complete': + console.log('Completed:', value.output) + break + case 'run:fail': + console.error('Failed:', value.error) + break + case 'run:progress': + console.log('Progress:', value.progress) + break + case 'log:write': + console.log(`[${value.level}]`, value.message) + break + } +} +``` + +### createDurablyHandler + +Create HTTP handlers for client/server architecture using Web Standard Request/Response: + +```ts +import { createDurablyHandler } from '@coji/durably' + +const handler = createDurablyHandler(durably) + +// Use the unified handle() method with automatic routing +app.all('/api/durably/*', async (req) => { + return await handler.handle(req, '/api/durably') +}) + +// Or use individual endpoints +app.post('/api/durably/trigger', (req) => handler.trigger(req)) +app.get('/api/durably/subscribe', (req) => handler.subscribe(req)) +app.get('/api/durably/runs', (req) => handler.runs(req)) +app.get('/api/durably/run', (req) => handler.run(req)) +app.get('/api/durably/steps', (req) => handler.steps(req)) +app.get('/api/durably/runs/subscribe', (req) => handler.runsSubscribe(req)) +app.post('/api/durably/retry', (req) => handler.retry(req)) +app.post('/api/durably/cancel', (req) => handler.cancel(req)) +app.delete('/api/durably/run', (req) => handler.delete(req)) +``` + +**Handler Interface:** + +```ts +interface DurablyHandler { + // Unified routing handler + handle(request: Request, basePath: string): Promise + + // Individual endpoints + trigger(request: Request): Promise // POST /trigger + subscribe(request: Request): Response // GET /subscribe?runId=xxx (SSE) + runs(request: Request): Promise // GET /runs + run(request: Request): Promise // GET /run?runId=xxx + steps(request: Request): Promise // GET /steps?runId=xxx + runsSubscribe(request: Request): Response // GET /runs/subscribe (SSE) + retry(request: Request): Promise // POST /retry?runId=xxx + cancel(request: Request): Promise // POST /cancel?runId=xxx + delete(request: Request): Promise // DELETE /run?runId=xxx +} + +interface TriggerRequest { + jobName: string + input: Record + idempotencyKey?: string + concurrencyKey?: string +} + +interface TriggerResponse { + runId: string +} +``` + +## Plugins + +### Log Persistence + +```ts +import { withLogPersistence } from '@coji/durably' + +durably.use(withLogPersistence()) +``` + +## Browser Usage + +```ts +import { createDurably, defineJob } from '@coji/durably' +import { SQLocalKysely } from 'sqlocal/kysely' +import { z } from 'zod' + +const { dialect } = new SQLocalKysely('app.sqlite3') + +const durably = createDurably({ + dialect, + pollingInterval: 100, + heartbeatInterval: 500, + staleThreshold: 3000, +}) + +// Same API as Node.js +const { myJob } = durably.register({ + myJob: defineJob({ + name: 'my-job', + input: z.object({}), + run: async (step) => { + /* ... */ + }, + }), +}) + +// Initialize (same as Node.js) +await durably.init() +``` + +## Run Lifecycle + +```text +trigger() → pending → running → completed + ↘ ↗ + → failed +``` + +- **pending**: Waiting for worker to pick up +- **running**: Worker is executing steps +- **completed**: All steps finished successfully +- **failed**: A step threw an error +- **cancelled**: Manually cancelled via `cancel()` + +## Resumability + +When a job resumes after interruption: + +1. Worker polls for pending/stale runs +2. Job function is re-executed from the beginning +3. `step.run()` checks SQLite for cached results +4. Completed steps return cached values immediately (no re-execution) +5. Execution continues from the first incomplete step + +## Type Definitions + +```ts +interface JobDefinition { + name: TName + input: ZodType + output?: ZodType + run: (step: StepContext, payload: TInput) => Promise +} + +interface StepContext { + runId: string + run(name: string, fn: () => T | Promise): Promise + progress(current: number, total?: number, message?: string): void + log: { + info(message: string, data?: unknown): void + warn(message: string, data?: unknown): void + error(message: string, data?: unknown): void + } +} + +interface Run { + id: string + jobName: string + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + payload: unknown + output?: TOutput + error?: string + progress?: { current: number; total?: number; message?: string } + createdAt: string + updatedAt: string +} + +interface JobHandle { + name: TName + trigger(input: TInput, options?: TriggerOptions): Promise> + triggerAndWait( + input: TInput, + options?: TriggerOptions, + ): Promise<{ id: string; output: TOutput }> + batchTrigger(inputs: BatchTriggerInput[]): Promise[]> + getRun(id: string): Promise | null> + getRuns(filter?: RunFilter): Promise[]> +} + +interface TriggerOptions { + idempotencyKey?: string + concurrencyKey?: string + timeout?: number +} +``` + +## License + +MIT + + +--- + +# Durably React - LLM Documentation + +> React bindings for Durably - step-oriented resumable batch execution. + +## Requirements + +- **React 19+** (uses `React.use()` for Promise resolution) + +## Overview + +`@coji/durably-react` provides React hooks for triggering and monitoring Durably jobs. It supports two modes: + +1. **Browser Hooks**: Run Durably entirely in the browser with SQLite WASM (OPFS) +2. **Server Hooks**: Connect to a remote Durably server via HTTP/SSE + +## Installation + +```bash +# Browser mode - runs Durably in the browser +pnpm add @coji/durably-react @coji/durably kysely zod sqlocal + +# Server mode - connects to Durably server +pnpm add @coji/durably-react +``` + +## Browser Hooks + +Import from `@coji/durably-react` for browser-complete mode. + +### DurablyProvider + +Wraps your app and provides the Durably instance to all hooks: + +```tsx +import { DurablyProvider } from '@coji/durably-react' +import { createDurably } from '@coji/durably' +import { SQLocalKysely } from 'sqlocal/kysely' + +// Create and initialize Durably +async function initDurably() { + const sqlocal = new SQLocalKysely('app.sqlite3') + const durably = createDurably({ dialect: sqlocal.dialect }) + await durably.init() + return durably +} + +const durablyPromise = initDurably() + +// With fallback prop (recommended) +function App() { + return ( + Loading...
}> + +
+ ) +} + +// Or with external Suspense +function AppAlt() { + return ( + Loading...}> + + + + + ) +} +``` + +**Props:** + +- `durably: Durably | Promise` - Durably instance or Promise (should be initialized via `await durably.init()`) +- `fallback?: ReactNode` - Fallback to show while Promise resolves (wraps in Suspense automatically) + +### useDurably + +Access the Durably instance directly: + +```tsx +import { useDurably } from '@coji/durably-react' + +function Component() { + const { durably } = useDurably() + + // Use durably instance directly + const handleGetRuns = async () => { + const runs = await durably.getRuns() + } +} +``` + +**Return type:** + +```ts +interface UseDurablyResult { + durably: Durably +} +``` + +### useJob + +Trigger and monitor a job: + +```tsx +import { defineJob } from '@coji/durably' +import { useJob } from '@coji/durably-react' +import { z } from 'zod' + +const myJob = defineJob({ + name: 'my-job', + input: z.object({ value: z.string() }), + output: z.object({ result: z.number() }), + run: async (step, payload) => { + const data = await step.run('process', () => process(payload.value)) + return { result: data.length } + }, +}) + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + isCancelled, + currentRunId, + reset, + } = useJob(myJob, { + initialRunId: undefined, + autoResume: true, // Auto-resume pending/running jobs (default: true) + followLatest: true, // Switch to tracking new runs (default: true) + }) + + // Trigger job + const handleClick = async () => { + const { runId } = await trigger({ value: 'test' }) + console.log('Started:', runId) + } + + // Or trigger and wait for result + const handleSync = async () => { + const { runId, output } = await triggerAndWait({ value: 'test' }) + console.log('Result:', output.result) + } + + return ( +
+ +

Status: {status}

+ {progress && ( +

+ Progress: {progress.current}/{progress.total} +

+ )} + {isCompleted &&

Result: {output?.result}

} + {isFailed &&

Error: {error}

} + +
+ ) +} +``` + +**Options:** + +```ts +interface UseJobOptions { + initialRunId?: string // Initial Run ID to subscribe to + autoResume?: boolean // Auto-resume pending/running jobs (default: true) + followLatest?: boolean // Switch to tracking new runs (default: true) +} +``` + +**Return type:** + +```ts +interface UseJobResult { + trigger: (input: TInput) => Promise<{ runId: string }> + triggerAndWait: (input: TInput) => Promise<{ runId: string; output: TOutput }> + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | null + output: TOutput | null + error: string | null + logs: LogEntry[] + progress: Progress | null + isRunning: boolean + isPending: boolean + isCompleted: boolean + isFailed: boolean + isCancelled: boolean + currentRunId: string | null + reset: () => void +} +``` + +### useJobRun + +Subscribe to an existing run by ID: + +```tsx +import { useJobRun } from '@coji/durably-react' + +function RunMonitor({ runId }: { runId: string | null }) { + const { + status, + output, + error, + progress, + logs, + isRunning, + isCompleted, + isFailed, + } = useJobRun<{ result: number }>({ runId }) + + if (!runId) return
No run selected
+ + return ( +
+

Status: {status}

+ {isCompleted &&

Output: {JSON.stringify(output)}

} +
+ ) +} +``` + +### useJobLogs + +Subscribe to logs from a run: + +```tsx +import { useJobLogs } from '@coji/durably-react' + +function LogViewer({ runId }: { runId: string | null }) { + const { logs, clearLogs } = useJobLogs({ + runId, + maxLogs: 100, // Optional: limit stored logs + }) + + return ( +
+ +
    + {logs.map((log) => ( +
  • + [{log.level}] {log.message} + {log.data &&
    {JSON.stringify(log.data)}
    } +
  • + ))} +
+
+ ) +} +``` + +### useRuns + +List runs with filtering and real-time updates: + +```tsx +import { useRuns } from '@coji/durably-react' + +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 + }) + + return ( +
+ + {runs.map((run) => ( +
+ {run.jobName}: {run.status} +
+ ))} +
+ ) +} +``` + +## Server Hooks + +Import from `@coji/durably-react/client` for server-connected mode. + +### createDurablyClient + +Create a type-safe client for all registered jobs (recommended): + +```tsx +// Server: register jobs (app/lib/durably.server.ts) +import { createDurably, createDurablyHandler } from '@coji/durably' + +export const durably = createDurably({ dialect }).register({ + importCsv: importCsvJob, + syncUsers: syncUsersJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.init() + +// Client: create typed client (app/lib/durably.client.ts) +import type { durably } from '~/lib/durably.server' +import { createDurablyClient } from '@coji/durably-react/client' + +export const durablyClient = createDurablyClient({ + api: '/api/durably', +}) + +// In your component - fully type-safe with autocomplete +function CsvImporter() { + const { trigger, output, isRunning } = durablyClient.importCsv.useJob() + + return ( + + ) +} + +// Subscribe to an existing run +function RunViewer({ runId }: { runId: string }) { + const { status, output, progress } = durablyClient.importCsv.useRun(runId) + return
Status: {status}
+} + +// Subscribe to logs +function LogViewer({ runId }: { runId: string }) { + const { logs } = durablyClient.importCsv.useLogs(runId) + return
{logs.map(l => l.message).join('\n')}
+} +``` + +### Client useJob + +Direct hook when not using `createDurablyClient`: + +```tsx +import { useJob } from '@coji/durably-react/client' + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isCompleted, + currentRunId, + reset, + } = useJob< + { userId: string }, // Input type + { count: number } // Output type + >({ + api: '/api/durably', + jobName: 'sync-data', + initialRunId: undefined, // Optional: resume existing run + }) + + const handleClick = async () => { + const { runId } = await trigger({ userId: 'user_123' }) + console.log('Started:', runId) + } + + return +} +``` + +### Client useJobRun + +```tsx +import { useJobRun } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { status, output, error, progress, logs } = useJobRun<{ + count: number + }>({ + api: '/api/durably', + runId, + }) + + return
Status: {status}
+} +``` + +### Client useJobLogs + +```tsx +import { useJobLogs } from '@coji/durably-react/client' + +function Component({ runId }: { runId: string }) { + const { logs, clearLogs } = useJobLogs({ + api: '/api/durably', + runId, + maxLogs: 50, + }) + + return ( +
    + {logs.map((log) => ( +
  • {log.message}
  • + ))} +
+ ) +} +``` + +### Client useRuns + +List runs with pagination and real-time updates: + +```tsx +import { useRuns } from '@coji/durably-react/client' + +function Dashboard() { + const { + runs, + page, + hasMore, + isLoading, + nextPage, + prevPage, + goToPage, + refresh, + } = useRuns({ + api: '/api/durably', + jobName: 'sync-data', // Optional: filter by job + status: 'running', // Optional: filter by status + pageSize: 20, // Optional: items per page + }) + + return ( +
+ {runs.map((run) => ( +
+ {run.jobName}: {run.status} +
+ ))} + + +
+ ) +} +``` + +### Client useRunActions + +Actions for runs (retry, cancel, delete): + +```tsx +import { useRunActions } from '@coji/durably-react/client' + +function RunActions({ runId, status }: { runId: string; status: string }) { + const { retry, cancel, deleteRun, getRun, getSteps, isLoading, error } = + useRunActions({ + api: '/api/durably', + }) + + return ( +
+ {(status === 'failed' || status === 'cancelled') && ( + + )} + {(status === 'pending' || status === 'running') && ( + + )} + + {error && {error}} +
+ ) +} +``` + +## Server Handler Setup + +On your server, use `createDurablyHandler`: + +```ts +// app/lib/durably.server.ts +import { createDurably } from '@coji/durably' +import { createDurablyHandler } from '@coji/durably' +import { LibsqlDialect } from '@libsql/kysely-libsql' +import { createClient } from '@libsql/client' + +const client = createClient({ url: 'file:local.db' }) +const dialect = new LibsqlDialect({ client }) + +export const durably = createDurably({ dialect }).register({ + syncData: syncDataJob, +}) + +export const durablyHandler = createDurablyHandler(durably) + +await durably.init() + +// app/routes/api.durably.$.ts (React Router / Remix) +import { durablyHandler } from '~/lib/durably.server' +import type { Route } from './+types/api.durably.$' + +export async function loader({ request }: Route.LoaderArgs) { + return durablyHandler.handle(request, '/api/durably') +} + +export async function action({ request }: Route.ActionArgs) { + return durablyHandler.handle(request, '/api/durably') +} +``` + +## Type Definitions + +```ts +type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' + +interface Progress { + current: number + total?: number + message?: string +} + +interface LogEntry { + id: string + runId: string + stepName: string | null + level: 'info' | 'warn' | 'error' + message: string + data: unknown + timestamp: string +} +``` + +## Common Patterns + +### Loading States + +```tsx +function Component() { + const { isRunning, trigger } = useJob(myJob) + + return ( + + ) +} +``` + +### Error Handling + +```tsx +function Component() { + const { trigger, error, isFailed, reset } = useJob(myJob) + + const handleClick = async () => { + try { + await trigger({ value: 'test' }) + } catch (e) { + console.error('Trigger failed:', e) + } + } + + if (isFailed) { + return ( +
+

Error: {error}

+ +
+ ) + } + + return +} +``` + +### Progress Tracking + +```tsx +function Component() { + const { trigger, progress, isRunning } = useJob(progressJob) + + return ( +
+ + {isRunning && progress && ( +
+ +

{progress.message}

+
+ )} +
+ ) +} +``` + +### Reconnecting to Existing Run + +```tsx +function Component({ existingRunId }: { existingRunId?: string }) { + const { status, output } = useJob(myJob, { + initialRunId: existingRunId, + }) + + // Will automatically subscribe to the existing run + return
Status: {status}
+} +``` + +## License + +MIT + diff --git a/website/scripts/generate-llms.js b/website/scripts/generate-llms.js new file mode 100644 index 00000000..2b6842db --- /dev/null +++ b/website/scripts/generate-llms.js @@ -0,0 +1,33 @@ +/** + * Generate combined llms.txt from package documentation + * + * This script concatenates llms.md from both @coji/durably and @coji/durably-react + * into a single llms.txt file for the website. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const rootDir = join(__dirname, '..') + +const coreLlms = readFileSync( + join(rootDir, '../packages/durably/docs/llms.md'), + 'utf-8', +) +const reactLlms = readFileSync( + join(rootDir, '../packages/durably-react/docs/llms.md'), + 'utf-8', +) + +const combined = `${coreLlms} + +--- + +${reactLlms} +` + +writeFileSync(join(rootDir, 'public/llms.txt'), combined) + +console.log('Generated public/llms.txt')