From f6ba38cfc6c9a856ce8024c2eadcc1cff56afb3e Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 22:14:24 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20redesign=20@coji/durably-react=20?= =?UTF-8?q?API=20=E2=80=94=20fullstack-first,=20new=20import=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Root import (`@coji/durably-react`) now exports fullstack hooks (was SPA) - SPA hooks moved to `@coji/durably-react/spa` subpath - Rename `createDurablyClient` → `createDurablyHooks` - Add `jobs` option to `createDurably()` for 1-step initialization - Rename example dirs: `browser-*` → `spa-*` to match mode names - Update all documentation, guides, examples, and skills - Fix DurablyProvider to accept `Durably` (was too restrictive) Closes #81 Co-Authored-By: Claude Opus 4.6 --- .claude/skills/doc-check/SKILL.md | 36 +- .claude/skills/release-check/SKILL.md | 20 +- .../app/lib/durably.server.ts | 11 +- .../app/routes/_index/dashboard.tsx | 4 +- .../app/routes/_index/data-sync-progress.tsx | 2 +- .../_index/image-processing-progress.tsx | 2 +- .../app/routes/_index/run-progress.tsx | 40 +- examples/server-node/lib/durably.ts | 8 +- .../.gitignore | 0 .../README.md | 0 .../app/app.css | 0 .../app/entry.client.tsx | 0 .../app/jobs/data-sync.ts | 0 .../app/jobs/import-csv.ts | 0 .../app/jobs/index.ts | 0 .../app/jobs/process-image.ts | 0 .../app/lib/database.ts | 0 .../app/lib/durably.ts | 10 +- .../app/root.tsx | 6 +- .../app/routes.ts | 0 .../app/routes/_index.tsx | 0 .../app/routes/_index/dashboard.tsx | 2 +- .../app/routes/_index/data-sync-form.tsx | 6 +- .../app/routes/_index/data-sync-progress.tsx | 2 +- .../routes/_index/image-processing-form.tsx | 10 +- .../_index/image-processing-progress.tsx | 2 +- .../app/routes/_index/run-progress.tsx | 40 +- .../biome.json | 0 .../package.json | 2 +- .../prettier.config.js | 0 .../public/favicon.ico | Bin .../react-router.config.ts | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 .../.gitignore | 0 .../biome.json | 0 .../index.html | 0 .../package.json | 2 +- .../prettier.config.js | 0 .../src/App.tsx | 2 +- .../src/app.css | 0 .../src/components/dashboard.tsx | 2 +- .../src/components/data-sync-form.tsx | 6 +- .../src/components/data-sync-progress.tsx | 2 +- .../src/components/image-processing-form.tsx | 10 +- .../components/image-processing-progress.tsx | 2 +- .../src/components/index.ts | 0 .../src/components/run-progress.tsx | 40 +- .../src/jobs/data-sync.ts | 0 .../src/jobs/index.ts | 0 .../src/jobs/process-image.ts | 0 .../src/lib/database.ts | 0 .../src/lib/durably.ts | 10 +- .../src/main.tsx | 0 .../tsconfig.json | 0 .../vercel.json | 0 .../vite.config.ts | 0 packages/durably-react/README.md | 84 ++- packages/durably-react/docs/llms.md | 524 ++++++++--------- packages/durably-react/package.json | 6 +- packages/durably-react/src/client.ts | 47 -- ...ably-client.ts => create-durably-hooks.ts} | 51 +- .../src/client/create-job-hooks.ts | 2 +- packages/durably-react/src/client/index.ts | 16 +- packages/durably-react/src/context.tsx | 7 +- packages/durably-react/src/index.ts | 65 ++- packages/durably-react/src/spa.ts | 14 + .../tests/browser/provider.test.tsx | 2 +- .../tests/browser/use-job-logs.test.tsx | 2 +- .../tests/browser/use-job-run.test.tsx | 2 +- .../tests/browser/use-job.test.tsx | 2 +- .../tests/browser/use-runs.test.tsx | 2 +- .../client/create-durably-client.test.tsx | 16 +- packages/durably-react/tsup.config.ts | 2 +- packages/durably/docs/llms.md | 16 +- packages/durably/src/durably.ts | 56 +- pnpm-lock.yaml | 116 ++-- website/.vitepress/config.ts | 123 +++- website/api/durably-react/browser.md | 20 +- website/api/durably-react/client.md | 44 +- website/api/durably-react/index.md | 165 +++--- website/api/index.md | 30 +- website/guide/csv-import.md | 6 +- website/guide/getting-started.md | 4 +- website/guide/offline-app.md | 6 +- website/public/llms.txt | 540 +++++++++--------- 86 files changed, 1214 insertions(+), 1035 deletions(-) rename examples/{browser-react-router-spa => spa-react-router}/.gitignore (100%) rename examples/{browser-react-router-spa => spa-react-router}/README.md (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/app.css (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/entry.client.tsx (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/jobs/data-sync.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/jobs/import-csv.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/jobs/index.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/jobs/process-image.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/lib/database.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/lib/durably.ts (68%) rename examples/{browser-react-router-spa => spa-react-router}/app/root.tsx (91%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index.tsx (100%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/dashboard.tsx (99%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/data-sync-form.tsx (76%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/data-sync-progress.tsx (94%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/image-processing-form.tsx (72%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/image-processing-progress.tsx (95%) rename examples/{browser-react-router-spa => spa-react-router}/app/routes/_index/run-progress.tsx (64%) rename examples/{browser-react-router-spa => spa-react-router}/biome.json (100%) rename examples/{browser-react-router-spa => spa-react-router}/package.json (96%) rename examples/{browser-react-router-spa => spa-react-router}/prettier.config.js (100%) rename examples/{browser-react-router-spa => spa-react-router}/public/favicon.ico (100%) rename examples/{browser-react-router-spa => spa-react-router}/react-router.config.ts (100%) rename examples/{browser-react-router-spa => spa-react-router}/tsconfig.json (100%) rename examples/{browser-react-router-spa => spa-react-router}/vite.config.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/.gitignore (100%) rename examples/{browser-vite-react => spa-vite-react}/biome.json (100%) rename examples/{browser-vite-react => spa-vite-react}/index.html (100%) rename examples/{browser-vite-react => spa-vite-react}/package.json (95%) rename examples/{browser-vite-react => spa-vite-react}/prettier.config.js (100%) rename examples/{browser-vite-react => spa-vite-react}/src/App.tsx (98%) rename examples/{browser-vite-react => spa-vite-react}/src/app.css (100%) rename examples/{browser-vite-react => spa-vite-react}/src/components/dashboard.tsx (99%) rename examples/{browser-vite-react => spa-vite-react}/src/components/data-sync-form.tsx (76%) rename examples/{browser-vite-react => spa-vite-react}/src/components/data-sync-progress.tsx (93%) rename examples/{browser-vite-react => spa-vite-react}/src/components/image-processing-form.tsx (73%) rename examples/{browser-vite-react => spa-vite-react}/src/components/image-processing-progress.tsx (94%) rename examples/{browser-vite-react => spa-vite-react}/src/components/index.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/src/components/run-progress.tsx (64%) rename examples/{browser-vite-react => spa-vite-react}/src/jobs/data-sync.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/src/jobs/index.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/src/jobs/process-image.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/src/lib/database.ts (100%) rename examples/{browser-vite-react => spa-vite-react}/src/lib/durably.ts (68%) rename examples/{browser-vite-react => spa-vite-react}/src/main.tsx (100%) rename examples/{browser-vite-react => spa-vite-react}/tsconfig.json (100%) rename examples/{browser-vite-react => spa-vite-react}/vercel.json (100%) rename examples/{browser-vite-react => spa-vite-react}/vite.config.ts (100%) delete mode 100644 packages/durably-react/src/client.ts rename packages/durably-react/src/client/{create-durably-client.ts => create-durably-hooks.ts} (54%) create mode 100644 packages/durably-react/src/spa.ts diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md index d7618509..7fa7672e 100644 --- a/.claude/skills/doc-check/SKILL.md +++ b/.claude/skills/doc-check/SKILL.md @@ -64,12 +64,12 @@ These are the primary references for AI coding agents. Grep for the changed symbol name in `examples/` to find usage. Each example demonstrates a different deployment pattern: -| Directory | Pattern | Key files | -| ----------------------------------- | ------------------------------ | ------------------------------------------------------------------- | -| `examples/server-node` | Node.js server (core API only) | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | -| `examples/browser-vite-react` | Browser SPA (Vite + React) | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | -| `examples/browser-react-router-spa` | Browser SPA (React Router) | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | -| `examples/fullstack-react-router` | Fullstack (React Router + SSE) | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | +| Directory | Pattern | Key files | +| --------------------------------- | ------------------------------ | ------------------------------------------------------------------- | +| `examples/server-node` | Node.js server (core API only) | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | +| `examples/spa-vite-react` | SPA mode (Vite + React) | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | +| `examples/spa-react-router` | SPA mode (React Router) | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | +| `examples/fullstack-react-router` | Fullstack (React Router + SSE) | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | ## 3. Generated Files @@ -85,18 +85,18 @@ pnpm --filter durably-website generate:llms Use this table to quickly determine which docs to check based on what changed: -| Change Type | Docs to Check | -| -------------------------------- | ----------------------------------------------------------------------------------------------------- | -| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | -| New event field | llms.md (core), events.md, index.md | -| New step method | llms.md (core), step.md, index.md | -| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | -| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | -| HTTP handler change | llms.md (core), http-handler.md, client.md | -| New config option | llms.md (core), create-durably.md, index.md | -| Job/step API change | All example apps (`examples/`) | -| Event type change | `examples/fullstack-react-router` (SSE), `examples/browser-*` (direct events) | -| React hook change | `examples/browser-vite-react`, `examples/browser-react-router-spa`, `examples/fullstack-react-router` | +| Change Type | Docs to Check | +| -------------------------------- | ------------------------------------------------------------------------------------------ | +| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | +| New event field | llms.md (core), events.md, index.md | +| New step method | llms.md (core), step.md, index.md | +| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | +| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | +| HTTP handler change | llms.md (core), http-handler.md, client.md | +| New config option | llms.md (core), create-durably.md, index.md | +| Job/step API change | All example apps (`examples/`) | +| Event type change | `examples/fullstack-react-router` (SSE), `examples/spa-*` (direct events) | +| React hook change | `examples/spa-vite-react`, `examples/spa-react-router`, `examples/fullstack-react-router` | ## 5. Common Oversights diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md index e5d097fd..d32c23ac 100644 --- a/.claude/skills/release-check/SKILL.md +++ b/.claude/skills/release-check/SKILL.md @@ -52,9 +52,9 @@ Verify package integrity for API changes and spec updates. ## 5. Examples -- [ ] `examples/browser-vite-react/` - Browser mode example -- [ ] `examples/browser-react-router-spa/` - Browser mode with React Router -- [ ] `examples/fullstack-react-router/` - Client mode (server-connected) +- [ ] `examples/spa-vite-react/` - SPA mode example +- [ ] `examples/spa-react-router/` - SPA mode with React Router +- [ ] `examples/fullstack-react-router/` - Fullstack mode (server-connected) - [ ] `examples/server-node/` - Node.js server example ## 6. Tests @@ -89,14 +89,14 @@ Check `git status` for uncommitted changes. ## Common Oversights -### Browser/Client Mode Consistency +### SPA/Fullstack Mode Consistency -When React hooks should provide the same API in both Browser and Client modes: +When React hooks should provide the same API in both SPA and Fullstack modes: -| File | Mode | -| ------------------- | ------------ | -| `hooks/use-job.ts` | Browser mode | -| `client/use-job.ts` | Client mode | +| File | Mode | +| ------------------- | -------------- | +| `hooks/use-job.ts` | SPA mode | +| `client/use-job.ts` | Fullstack mode | Ensure consistency in: @@ -114,7 +114,7 @@ Verify code examples in docs match actual API: ### Type Exports -Check if new types are exported in `index.ts` / `client.ts`. +Check if new types are exported in `index.ts` / `spa.ts`. ### Examples Consistency diff --git a/examples/fullstack-react-router/app/lib/durably.server.ts b/examples/fullstack-react-router/app/lib/durably.server.ts index e62361d0..5ae24010 100644 --- a/examples/fullstack-react-router/app/lib/durably.server.ts +++ b/examples/fullstack-react-router/app/lib/durably.server.ts @@ -13,13 +13,14 @@ import { createDurably, createDurablyHandler } from '@coji/durably' import { dataSyncJob, importCsvJob, processImageJob } from '~/jobs' import { dialect } from './database.server' -// Create Durably instance with registered jobs +// Create Durably instance with jobs export const durably = createDurably({ dialect, -}).register({ - processImage: processImageJob, - dataSync: dataSyncJob, - importCsv: importCsvJob, + jobs: { + processImage: processImageJob, + dataSync: dataSyncJob, + importCsv: importCsvJob, + }, }) // HTTP handler for SSE streaming diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index 1d6d58ec..c20c846e 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -7,12 +7,12 @@ * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ -import type { ClientRun, StepRecord } from '@coji/durably-react/client' +import type { ClientRun, StepRecord } from '@coji/durably-react' import { type TypedClientRun, useRunActions, useRuns, -} from '@coji/durably-react/client' +} from '@coji/durably-react' import { useState } from 'react' import type { DataSyncInput, 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 index c698e8c8..2ece5dce 100644 --- a/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the data sync job using useJobRun. */ -import { useJobRun } from '@coji/durably-react/client' +import { useJobRun } from '@coji/durably-react' import { useActionData } from 'react-router' import type { action } from '../_index' import { RunProgress } from './run-progress' 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 index 5d11af4f..9db9d129 100644 --- a/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the image processing job using useJobRun. */ -import { useJobRun } from '@coji/durably-react/client' +import { useJobRun } from '@coji/durably-react' import { useActionData } from 'react-router' import type { action } from '../_index' import { RunProgress } from './run-progress' diff --git a/examples/fullstack-react-router/app/routes/_index/run-progress.tsx b/examples/fullstack-react-router/app/routes/_index/run-progress.tsx index 9611fa54..f238ebd7 100644 --- a/examples/fullstack-react-router/app/routes/_index/run-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/run-progress.tsx @@ -4,7 +4,7 @@ * Displays real-time progress and result for jobs. */ -import type { LogEntry } from '@coji/durably-react/client' +import type { LogEntry } from '@coji/durably-react' interface RunProgressProps { progress: { current: number; total?: number; message?: string } | null @@ -38,39 +38,39 @@ export function RunProgress({ <> {/* Pending State */} {isPending && ( -
+
Waiting to start...
)} {/* Progress Display */} {isRunning && progress && ( -
-
+
+
Progress {progress.current}/{progress.total || '?'}
-
+
{progress.message && ( -
{progress.message}
+
{progress.message}
)}
)} {/* Success Result */} {isCompleted && output !== null && output !== undefined && ( -
-
Completed!
-
+        
+
Completed!
+
             {JSON.stringify(output, null, 2)}
           
@@ -78,17 +78,17 @@ export function RunProgress({ {/* Error Result */} {isFailed && ( -
-
Failed
-
{error}
+
+
Failed
+
{error}
)} {/* Cancelled Result */} {isCancelled && ( -
-
Cancelled
-
+
+
Cancelled
+
The job was cancelled before completion.
@@ -96,12 +96,12 @@ export function RunProgress({ {/* Logs */} {logs.length > 0 && ( -
-

Logs

-
+
+

Logs

+
    {logs.map((log) => ( -
  • +
  • +

    {message}

    {details}

    {stack && ( -
    +        
               {stack}
             
    )} diff --git a/examples/browser-react-router-spa/app/routes.ts b/examples/spa-react-router/app/routes.ts similarity index 100% rename from examples/browser-react-router-spa/app/routes.ts rename to examples/spa-react-router/app/routes.ts diff --git a/examples/browser-react-router-spa/app/routes/_index.tsx b/examples/spa-react-router/app/routes/_index.tsx similarity index 100% rename from examples/browser-react-router-spa/app/routes/_index.tsx rename to examples/spa-react-router/app/routes/_index.tsx diff --git a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx b/examples/spa-react-router/app/routes/_index/dashboard.tsx similarity index 99% rename from examples/browser-react-router-spa/app/routes/_index/dashboard.tsx rename to examples/spa-react-router/app/routes/_index/dashboard.tsx index cc86fbb1..7a965711 100644 --- a/examples/browser-react-router-spa/app/routes/_index/dashboard.tsx +++ b/examples/spa-react-router/app/routes/_index/dashboard.tsx @@ -7,7 +7,7 @@ * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ -import { type TypedRun, useDurably, useRuns } from '@coji/durably-react' +import { type TypedRun, useDurably, useRuns } from '@coji/durably-react/spa' import { useState } from 'react' import type { DataSyncInput, diff --git a/examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx b/examples/spa-react-router/app/routes/_index/data-sync-form.tsx similarity index 76% rename from examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx rename to examples/spa-react-router/app/routes/_index/data-sync-form.tsx index fec71898..d6b8b437 100644 --- a/examples/browser-react-router-spa/app/routes/_index/data-sync-form.tsx +++ b/examples/spa-react-router/app/routes/_index/data-sync-form.tsx @@ -19,7 +19,7 @@ export function DataSyncForm() {
    @@ -27,13 +27,13 @@ export function DataSyncForm() { id="userId" name="userId" defaultValue="user_123" - className="border border-gray-300 rounded-md px-3 py-2 w-full focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    diff --git a/examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx b/examples/spa-react-router/app/routes/_index/data-sync-progress.tsx similarity index 94% rename from examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx rename to examples/spa-react-router/app/routes/_index/data-sync-progress.tsx index c37a00ad..392eb1b6 100644 --- a/examples/browser-react-router-spa/app/routes/_index/data-sync-progress.tsx +++ b/examples/spa-react-router/app/routes/_index/data-sync-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the data sync job using initialRunId. */ -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { useActionData } from 'react-router' import { dataSyncJob } from '~/jobs' import type { clientAction } from '../_index' diff --git a/examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx b/examples/spa-react-router/app/routes/_index/image-processing-form.tsx similarity index 72% rename from examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx rename to examples/spa-react-router/app/routes/_index/image-processing-form.tsx index 2e877417..0f0d411e 100644 --- a/examples/browser-react-router-spa/app/routes/_index/image-processing-form.tsx +++ b/examples/spa-react-router/app/routes/_index/image-processing-form.tsx @@ -19,7 +19,7 @@ export function ImageProcessingForm() {
    @@ -27,13 +27,13 @@ export function ImageProcessingForm() { id="filename" name="filename" defaultValue="photo.jpg" - className="border border-gray-300 rounded-md px-3 py-2 w-full focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    @@ -44,13 +44,13 @@ export function ImageProcessingForm() { defaultValue={800} 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" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    diff --git a/examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx b/examples/spa-react-router/app/routes/_index/image-processing-progress.tsx similarity index 95% rename from examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx rename to examples/spa-react-router/app/routes/_index/image-processing-progress.tsx index 3b869e51..7473d430 100644 --- a/examples/browser-react-router-spa/app/routes/_index/image-processing-progress.tsx +++ b/examples/spa-react-router/app/routes/_index/image-processing-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the image processing job using initialRunId. */ -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { useActionData } from 'react-router' import { processImageJob } from '~/jobs' import type { clientAction } from '../_index' diff --git a/examples/browser-react-router-spa/app/routes/_index/run-progress.tsx b/examples/spa-react-router/app/routes/_index/run-progress.tsx similarity index 64% rename from examples/browser-react-router-spa/app/routes/_index/run-progress.tsx rename to examples/spa-react-router/app/routes/_index/run-progress.tsx index b167f90d..c1c2ffba 100644 --- a/examples/browser-react-router-spa/app/routes/_index/run-progress.tsx +++ b/examples/spa-react-router/app/routes/_index/run-progress.tsx @@ -4,7 +4,7 @@ * Displays real-time progress and result for browser-only jobs. */ -import type { LogEntry } from '@coji/durably-react' +import type { LogEntry } from '@coji/durably-react/spa' interface RunProgressProps { progress: { current: number; total?: number; message?: string } | null @@ -38,39 +38,39 @@ export function RunProgress({ <> {/* Pending State */} {isPending && ( -
    +
    Waiting to start...
    )} {/* Progress Display */} {isRunning && progress && ( -
    -
    +
    +
    Progress {progress.current}/{progress.total || '?'}
    -
    +
    {progress.message && ( -
    {progress.message}
    +
    {progress.message}
    )}
    )} {/* Success Result */} {isCompleted && output !== null && output !== undefined && ( -
    -
    Completed!
    -
    +        
    +
    Completed!
    +
                 {JSON.stringify(output, null, 2)}
               
    @@ -78,17 +78,17 @@ export function RunProgress({ {/* Error Result */} {isFailed && ( -
    -
    Failed
    -
    {error}
    +
    +
    Failed
    +
    {error}
    )} {/* Cancelled Result */} {isCancelled && ( -
    -
    Cancelled
    -
    +
    +
    Cancelled
    +
    The job was cancelled before completion.
    @@ -96,12 +96,12 @@ export function RunProgress({ {/* Logs */} {logs.length > 0 && ( -
    -

    Logs

    -
    +
    +

    Logs

    +
      {logs.map((log) => ( -
    • +
    • @@ -37,13 +37,13 @@ export function DataSyncForm({ id="userId" value={userId} onChange={(e) => 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" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    diff --git a/examples/browser-vite-react/src/components/data-sync-progress.tsx b/examples/spa-vite-react/src/components/data-sync-progress.tsx similarity index 93% rename from examples/browser-vite-react/src/components/data-sync-progress.tsx rename to examples/spa-vite-react/src/components/data-sync-progress.tsx index d7c0b121..41ed0e47 100644 --- a/examples/browser-vite-react/src/components/data-sync-progress.tsx +++ b/examples/spa-vite-react/src/components/data-sync-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the data sync job. */ -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { dataSyncJob } from '../jobs' import { RunProgress } from './run-progress' diff --git a/examples/browser-vite-react/src/components/image-processing-form.tsx b/examples/spa-vite-react/src/components/image-processing-form.tsx similarity index 73% rename from examples/browser-vite-react/src/components/image-processing-form.tsx rename to examples/spa-vite-react/src/components/image-processing-form.tsx index 574340c3..3aed3604 100644 --- a/examples/browser-vite-react/src/components/image-processing-form.tsx +++ b/examples/spa-vite-react/src/components/image-processing-form.tsx @@ -30,7 +30,7 @@ export function ImageProcessingForm({
    @@ -38,13 +38,13 @@ export function ImageProcessingForm({ id="filename" value={filename} onChange={(e) => 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" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    @@ -55,13 +55,13 @@ export function ImageProcessingForm({ onChange={(e) => 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" + className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" />
    diff --git a/examples/browser-vite-react/src/components/image-processing-progress.tsx b/examples/spa-vite-react/src/components/image-processing-progress.tsx similarity index 94% rename from examples/browser-vite-react/src/components/image-processing-progress.tsx rename to examples/spa-vite-react/src/components/image-processing-progress.tsx index abb31fd4..8f68946f 100644 --- a/examples/browser-vite-react/src/components/image-processing-progress.tsx +++ b/examples/spa-vite-react/src/components/image-processing-progress.tsx @@ -4,7 +4,7 @@ * Displays progress for the image processing job. */ -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { processImageJob } from '../jobs' import { RunProgress } from './run-progress' diff --git a/examples/browser-vite-react/src/components/index.ts b/examples/spa-vite-react/src/components/index.ts similarity index 100% rename from examples/browser-vite-react/src/components/index.ts rename to examples/spa-vite-react/src/components/index.ts diff --git a/examples/browser-vite-react/src/components/run-progress.tsx b/examples/spa-vite-react/src/components/run-progress.tsx similarity index 64% rename from examples/browser-vite-react/src/components/run-progress.tsx rename to examples/spa-vite-react/src/components/run-progress.tsx index b167f90d..c1c2ffba 100644 --- a/examples/browser-vite-react/src/components/run-progress.tsx +++ b/examples/spa-vite-react/src/components/run-progress.tsx @@ -4,7 +4,7 @@ * Displays real-time progress and result for browser-only jobs. */ -import type { LogEntry } from '@coji/durably-react' +import type { LogEntry } from '@coji/durably-react/spa' interface RunProgressProps { progress: { current: number; total?: number; message?: string } | null @@ -38,39 +38,39 @@ export function RunProgress({ <> {/* Pending State */} {isPending && ( -
    +
    Waiting to start...
    )} {/* Progress Display */} {isRunning && progress && ( -
    -
    +
    +
    Progress {progress.current}/{progress.total || '?'}
    -
    +
    {progress.message && ( -
    {progress.message}
    +
    {progress.message}
    )}
    )} {/* Success Result */} {isCompleted && output !== null && output !== undefined && ( -
    -
    Completed!
    -
    +        
    +
    Completed!
    +
                 {JSON.stringify(output, null, 2)}
               
    @@ -78,17 +78,17 @@ export function RunProgress({ {/* Error Result */} {isFailed && ( -
    -
    Failed
    -
    {error}
    +
    +
    Failed
    +
    {error}
    )} {/* Cancelled Result */} {isCancelled && ( -
    -
    Cancelled
    -
    +
    +
    Cancelled
    +
    The job was cancelled before completion.
    @@ -96,12 +96,12 @@ export function RunProgress({ {/* Logs */} {logs.length > 0 && ( -
    -

    Logs

    -
    +
    +

    Logs

    +
      {logs.map((log) => ( -
    • +
    • ({ + api: '/api/durably', +}) + +function MyComponent() { + const { trigger, isRunning, isCompleted, output } = durably.myJob.useJob() + + return ( + + ) +} ``` -## Quick Start +## SPA Mode + +For browser-only apps, import from `@coji/durably-react/spa`: ```tsx -import { Suspense } from 'react' import { createDurably, defineJob } from '@coji/durably' -import { DurablyProvider, useJob } from '@coji/durably-react' +import { DurablyProvider, useJob } from '@coji/durably-react/spa' import { SQLocalKysely } from 'sqlocal/kysely' import { z } from 'zod' @@ -38,8 +61,9 @@ const myJob = defineJob({ // Initialize Durably async function initDurably() { const sqlocal = new SQLocalKysely('app.sqlite3') - const durably = createDurably({ dialect: sqlocal.dialect }).register({ - myJob, + const durably = createDurably({ + dialect: sqlocal.dialect, + jobs: { myJob }, }) await durably.init() // migrate + start return durably @@ -48,11 +72,9 @@ const durablyPromise = initDurably() function App() { return ( - Loading...
    }> - - - - + Loading...
    }> + + ) } @@ -66,44 +88,12 @@ function MyComponent() { } ``` -## 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, - isPending, - isCompleted, - isFailed, - isCancelled, - } = useJob<{ id: string }, { result: number }>({ - api: '/api/durably', - jobName: 'my-job', - autoResume: true, // Auto-resume running/pending jobs on mount (default) - followLatest: true, // Switch to tracking new runs via SSE (default) - }) - - 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 +- [SPA Hooks](https://coji.github.io/durably/api/durably-react/browser) - Browser mode with OPFS +- [Fullstack Hooks](https://coji.github.io/durably/api/durably-react/client) - Server-connected mode ## License diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index b6ea371e..11e4be8a 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -10,29 +10,273 @@ `@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 +1. **Fullstack Hooks** (default): Connect to a remote Durably server via HTTP/SSE +2. **SPA Hooks**: Run Durably entirely in the browser with SQLite WASM (OPFS) ## Installation ```bash -# Browser mode - runs Durably in the browser +# Fullstack mode - connects to Durably server +pnpm add @coji/durably-react + +# SPA 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 +## Fullstack Hooks + +Import from `@coji/durably-react` (root) for server-connected mode. + +### createDurablyHooks + +Create a type-safe hooks factory for all registered jobs. + +```tsx +// Server: register jobs (app/lib/durably.server.ts) +import { createDurably, createDurablyHandler } from '@coji/durably' + +export const durably = createDurably({ + dialect, + jobs: { + importCsv: importCsvJob, + syncUsers: syncUsersJob, + }, +}) + +export const durablyHandler = createDurablyHandler(durably, { + sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable) +}) + +await durably.init() + +// Client: create typed hooks (app/lib/durably.client.ts) +import type { durably } from '~/lib/durably.server' +import { createDurablyHooks } from '@coji/durably-react' + +export const durably = createDurablyHooks({ + api: '/api/durably', +}) + +// In your component - fully type-safe with autocomplete +function CsvImporter() { + const { trigger, output, isRunning } = durably.importCsv.useJob() + + return ( + + ) +} + +// Subscribe to an existing run +function RunViewer({ runId }: { runId: string }) { + const { status, output, progress } = durably.importCsv.useRun(runId) + return
    Status: {status}
    +} + +// Subscribe to logs +function LogViewer({ runId }: { runId: string }) { + const { logs } = durably.importCsv.useLogs(runId) + return
    {logs.map(l => l.message).join('\n')}
    +} +``` + +### Fullstack useJob + +Direct hook when not using `createDurablyHooks`: + +```tsx +import { useJob } from '@coji/durably-react' + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + isCancelled, + currentRunId, + reset, + } = useJob< + { userId: string }, // Input type + { count: number } // Output type + >({ + api: '/api/durably', + jobName: 'sync-data', + initialRunId: undefined, // Optional: resume existing run + autoResume: true, // Auto-resume pending/running jobs on mount (default: true) + followLatest: true, // Switch to tracking new runs (default: true) + }) + + const handleClick = async () => { + const { runId } = await trigger({ userId: 'user_123' }) + console.log('Started:', runId) + } + + return +} +``` + +**Options:** + +```ts +interface UseJobClientOptions { + api: string // API endpoint URL (e.g., '/api/durably') + jobName: string // Job name to trigger + initialRunId?: string // Initial Run ID to subscribe to + autoResume?: boolean // Auto-resume pending/running jobs on mount (default: true) + followLatest?: boolean // Switch to tracking new runs via SSE (default: true) +} +``` + +The `autoResume` option automatically fetches running/pending jobs on mount and subscribes to them. This is useful for SSR applications where users may refresh the page while a job is running. + +The `followLatest` option subscribes to job-level SSE events and automatically switches to tracking the latest triggered job. This enables real-time updates when jobs are triggered from other tabs or clients. + +### Fullstack useJobRun + +```tsx +import { useJobRun } from '@coji/durably-react' + +function Component({ runId }: { runId: string }) { + const { status, output, error, progress, logs } = useJobRun<{ + count: number + }>({ + api: '/api/durably', + runId, + }) + + return
    Status: {status}
    +} +``` + +### Fullstack useJobLogs + +```tsx +import { useJobLogs } from '@coji/durably-react' + +function Component({ runId }: { runId: string }) { + const { logs, clearLogs } = useJobLogs({ + api: '/api/durably', + runId, + maxLogs: 50, + }) + + return ( +
      + {logs.map((log) => ( +
    • {log.message}
    • + ))} +
    + ) +} +``` + +### Fullstack useRuns + +List runs with pagination and real-time updates: + +```tsx +import { useRuns, TypedClientRun } from '@coji/durably-react' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedClientRun<{ file: string }, { count: number }> +type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun + +function Dashboard() { + const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + // runs are typed as DashboardRun[] +} + +// Option 2: JobDefinition (single job, auto-filters by jobName) +const syncDataJob = defineJob({ + name: 'sync-data', + input: z.object({ userId: z.string() }), + output: z.object({ count: z.number() }), + run: async (step, input) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(syncDataJob, { + api: '/api/durably', + status: 'completed', + pageSize: 20, + }) + // runs[0].output is typed as { count: number } | null +} + +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) + // runs[0].output is unknown +} + +// Filter by labels +function FilteredDashboard() { + const { runs } = useRuns({ + api: '/api/durably', + labels: { source: 'browser' }, + pageSize: 10, + }) + // runs[0].labels is Record +} +``` + +### Fullstack useRunActions + +Actions for runs (retry, cancel, delete): + +```tsx +import { useRunActions } from '@coji/durably-react' + +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}} +
    + ) +} ``` -## Browser Hooks +## SPA Hooks -Import from `@coji/durably-react` for browser-complete mode. +Import from `@coji/durably-react/spa` for browser-complete mode. ### DurablyProvider Wraps your app and provides the Durably instance to all hooks: ```tsx -import { DurablyProvider } from '@coji/durably-react' +import { DurablyProvider } from '@coji/durably-react/spa' import { createDurably } from '@coji/durably' import { SQLocalKysely } from 'sqlocal/kysely' @@ -77,7 +321,7 @@ function AppAlt() { Access the Durably instance directly: ```tsx -import { useDurably } from '@coji/durably-react' +import { useDurably } from '@coji/durably-react/spa' function Component() { const { durably } = useDurably() @@ -103,7 +347,7 @@ Trigger and monitor a job: ```tsx import { defineJob } from '@coji/durably' -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { z } from 'zod' const myJob = defineJob({ @@ -214,7 +458,7 @@ interface UseJobResult { Subscribe to an existing run by ID: ```tsx -import { useJobRun } from '@coji/durably-react' +import { useJobRun } from '@coji/durably-react/spa' function RunMonitor({ runId }: { runId: string | null }) { const { @@ -244,7 +488,7 @@ function RunMonitor({ runId }: { runId: string | null }) { Subscribe to logs from a run: ```tsx -import { useJobLogs } from '@coji/durably-react' +import { useJobLogs } from '@coji/durably-react/spa' function LogViewer({ runId }: { runId: string | null }) { const { logs, clearLogs } = useJobLogs({ @@ -273,7 +517,7 @@ function LogViewer({ runId }: { runId: string | null }) { List runs with filtering, pagination, and real-time updates: ```tsx -import { useRuns, TypedRun } from '@coji/durably-react' +import { useRuns, TypedRun } from '@coji/durably-react/spa' import { defineJob } from '@coji/durably' // Option 1: Generic type parameter (dashboard with multiple job types) @@ -315,249 +559,6 @@ function FilteredDashboard() { } ``` -## Server Hooks - -Import from `@coji/durably-react/client` for server-connected mode. - -### createDurablyClient - -Create a type-safe client for all registered jobs. Recommended for SPA (non-SSR) apps. - -Not compatible with SSR. For SSR apps, use `useJob`/`useRuns` hooks directly with the `api` option. - -```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, { - sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable) -}) - -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, - isPending, - isCompleted, - isFailed, - isCancelled, - currentRunId, - reset, - } = useJob< - { userId: string }, // Input type - { count: number } // Output type - >({ - api: '/api/durably', - jobName: 'sync-data', - initialRunId: undefined, // Optional: resume existing run - autoResume: true, // Auto-resume pending/running jobs on mount (default: true) - followLatest: true, // Switch to tracking new runs (default: true) - }) - - const handleClick = async () => { - const { runId } = await trigger({ userId: 'user_123' }) - console.log('Started:', runId) - } - - return -} -``` - -**Options:** - -```ts -interface UseJobClientOptions { - api: string // API endpoint URL (e.g., '/api/durably') - jobName: string // Job name to trigger - initialRunId?: string // Initial Run ID to subscribe to - autoResume?: boolean // Auto-resume pending/running jobs on mount (default: true) - followLatest?: boolean // Switch to tracking new runs via SSE (default: true) -} -``` - -The `autoResume` option automatically fetches running/pending jobs on mount and subscribes to them. This is useful for SSR applications where users may refresh the page while a job is running. - -The `followLatest` option subscribes to job-level SSE events and automatically switches to tracking the latest triggered job. This enables real-time updates when jobs are triggered from other tabs or clients. - -### 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, TypedClientRun } from '@coji/durably-react/client' -import { defineJob } from '@coji/durably' - -// Option 1: Generic type parameter (dashboard with multiple job types) -type ImportRun = TypedClientRun<{ file: string }, { count: number }> -type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> -type DashboardRun = ImportRun | SyncRun - -function Dashboard() { - const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) - // runs are typed as DashboardRun[] -} - -// Option 2: JobDefinition (single job, auto-filters by jobName) -const syncDataJob = defineJob({ - name: 'sync-data', - input: z.object({ userId: z.string() }), - output: z.object({ count: z.number() }), - run: async (step, input) => { - /* ... */ - }, -}) - -function SingleJobDashboard() { - const { runs } = useRuns(syncDataJob, { - api: '/api/durably', - status: 'completed', - pageSize: 20, - }) - // runs[0].output is typed as { count: number } | null -} - -// Option 3: Untyped (simple cases) -function UntypedDashboard() { - const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) - // runs[0].output is unknown -} - -// Filter by labels -function FilteredDashboard() { - const { runs } = useRuns({ - api: '/api/durably', - labels: { source: 'browser' }, - pageSize: 10, - }) - // runs[0].labels is Record -} -``` - -### 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`: @@ -572,8 +573,11 @@ 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 durably = createDurably({ + dialect, + jobs: { + syncData: syncDataJob, + }, }) export const durablyHandler = createDurablyHandler(durably, { @@ -616,7 +620,7 @@ interface LogEntry { timestamp: string } -// Browser hooks: TypedRun with generic input/output +// SPA hooks: TypedRun with generic input/output type TypedRun< TInput extends Record = Record, TOutput extends Record | undefined = Record, @@ -627,7 +631,7 @@ type TypedRun< // ClientRun is re-exported from @coji/durably (excludes heartbeatAt, idempotencyKey, concurrencyKey, updatedAt) -// Client hooks: TypedClientRun with generic input/output +// Fullstack hooks: TypedClientRun with generic input/output type TypedClientRun< TInput extends Record = Record, TOutput extends Record | undefined = Record, diff --git a/packages/durably-react/package.json b/packages/durably-react/package.json index 323b3e7b..be1600da 100644 --- a/packages/durably-react/package.json +++ b/packages/durably-react/package.json @@ -10,9 +10,9 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./client": { - "types": "./dist/client.d.ts", - "import": "./dist/client.js" + "./spa": { + "types": "./dist/spa.d.ts", + "import": "./dist/spa.js" } }, "files": [ diff --git a/packages/durably-react/src/client.ts b/packages/durably-react/src/client.ts deleted file mode 100644 index f0cb822c..00000000 --- a/packages/durably-react/src/client.ts +++ /dev/null @@ -1,47 +0,0 @@ -// @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, - TypedClientRun, - UseRunsClientOptions, - UseRunsClientResult, -} from './client/use-runs' - -export { useRunActions } from './client/use-run-actions' -export type { - 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-hooks.ts similarity index 54% rename from packages/durably-react/src/client/create-durably-client.ts rename to packages/durably-react/src/client/create-durably-hooks.ts index 1eaef017..2da6a219 100644 --- a/packages/durably-react/src/client/create-durably-client.ts +++ b/packages/durably-react/src/client/create-durably-hooks.ts @@ -6,7 +6,7 @@ import { useJobRun, type UseJobRunClientResult } from './use-job-run' /** * Type-safe hooks for a specific job */ -export interface JobClient { +export interface JobHooks { /** * Hook for triggering and monitoring the job */ @@ -27,9 +27,9 @@ export interface JobClient { } /** - * Options for createDurablyClient + * Options for createDurablyHooks */ -export interface CreateDurablyClientOptions { +export interface CreateDurablyHooksOptions { /** * API endpoint URL (e.g., '/api/durably') */ @@ -37,30 +37,30 @@ export interface CreateDurablyClientOptions { } /** - * A type-safe client with hooks for each registered job + * A type-safe hooks collection for each registered job */ -export type DurablyClient> = { - [K in keyof TJobs]: JobClient, InferOutput> +export type DurablyHooks> = { + [K in keyof TJobs]: JobHooks, InferOutput> } /** - * Create a type-safe Durably client with hooks for all registered jobs. + * Create type-safe hooks for all registered jobs. * * @example * ```tsx * // Server: register jobs * // app/lib/durably.server.ts - * export const jobs = durably.register({ - * importCsv: importCsvJob, - * syncUsers: syncUsersJob, + * export const durably = createDurably({ + * dialect, + * jobs: { 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' + * // Client: create typed hooks + * // app/lib/durably.hooks.ts + * import type { durably } from '~/lib/durably.server' + * import { createDurablyHooks } from '@coji/durably-react/fullstack' * - * export const durably = createDurablyClient({ + * export const durably = createDurablyHooks({ * api: '/api/durably', * }) * @@ -76,13 +76,13 @@ export type DurablyClient> = { * } * ``` */ -export function createDurablyClient>( - options: CreateDurablyClientOptions, -): DurablyClient { +export function createDurablyHooks>( + options: CreateDurablyHooksOptions, +): DurablyHooks { const { api } = options - // Create a proxy that generates job clients on demand - return new Proxy({} as DurablyClient, { + // Create a proxy that generates job hooks on demand + return new Proxy({} as DurablyHooks, { get(_target, jobKey: string) { return { useJob: () => { @@ -100,3 +100,14 @@ export function createDurablyClient>( }, }) } + +// Backward compatibility re-exports +/** @deprecated Use `createDurablyHooks` instead */ +export const createDurablyClient = createDurablyHooks +/** @deprecated Use `CreateDurablyHooksOptions` instead */ +export type CreateDurablyClientOptions = CreateDurablyHooksOptions +/** @deprecated Use `DurablyHooks` instead */ +export type DurablyClient> = + DurablyHooks +/** @deprecated Use `JobHooks` instead */ +export type JobClient = JobHooks diff --git a/packages/durably-react/src/client/create-job-hooks.ts b/packages/durably-react/src/client/create-job-hooks.ts index 90d3f091..10ef52a9 100644 --- a/packages/durably-react/src/client/create-job-hooks.ts +++ b/packages/durably-react/src/client/create-job-hooks.ts @@ -48,7 +48,7 @@ export interface JobHooks { * ```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' + * import { createJobHooks } from '@coji/durably-react' * * const importCsv = createJobHooks({ * api: '/api/durably', diff --git a/packages/durably-react/src/client/index.ts b/packages/durably-react/src/client/index.ts index f8796c61..c8c4ac15 100644 --- a/packages/durably-react/src/client/index.ts +++ b/packages/durably-react/src/client/index.ts @@ -1,17 +1,17 @@ /** - * Server-connected (client mode) exports - * Use these when connecting to a remote Durably server via HTTP/SSE + * Internal client hooks module + * Public API is exported via root (index.ts) for fullstack mode */ -export { createDurablyClient } from './create-durably-client' +export { createDurablyHooks } from './create-durably-hooks' export type { - CreateDurablyClientOptions, - DurablyClient, - JobClient, -} from './create-durably-client' + CreateDurablyHooksOptions, + DurablyHooks, + JobHooks, +} from './create-durably-hooks' export { createJobHooks } from './create-job-hooks' -export type { CreateJobHooksOptions, JobHooks } from './create-job-hooks' +export type { CreateJobHooksOptions } from './create-job-hooks' export { useJob } from './use-job' export type { UseJobClientOptions, UseJobClientResult } from './use-job' diff --git a/packages/durably-react/src/context.tsx b/packages/durably-react/src/context.tsx index 008e4cf5..90505e8a 100644 --- a/packages/durably-react/src/context.tsx +++ b/packages/durably-react/src/context.tsx @@ -1,8 +1,11 @@ import type { Durably } from '@coji/durably' import { Suspense, createContext, use, useContext, type ReactNode } from 'react' +// biome-ignore lint/suspicious/noExplicitAny: Durably context accepts any job/label configuration +type AnyDurably = Durably + interface DurablyContextValue { - durably: Durably + durably: AnyDurably } const DurablyContext = createContext(null) @@ -28,7 +31,7 @@ export interface DurablyProviderProps { * * */ - durably: Durably | Promise + durably: AnyDurably | Promise /** * Fallback to show while waiting for the Durably Promise to resolve. * This wraps the provider content in a Suspense boundary automatically. diff --git a/packages/durably-react/src/index.ts b/packages/durably-react/src/index.ts index 0f4738c5..37f2e36e 100644 --- a/packages/durably-react/src/index.ts +++ b/packages/durably-react/src/index.ts @@ -1,14 +1,53 @@ -// @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 { TypedRun, UseRunsOptions, UseRunsResult } from './hooks/use-runs' +// @coji/durably-react - Fullstack mode (default) +// Connect to a Durably server via HTTP/SSE +// +// For SPA/offline mode (browser-only with OPFS), use: +// @coji/durably-react/spa + +// Type-safe hooks factory +export { createDurablyHooks } from './client/create-durably-hooks' +export type { + CreateDurablyHooksOptions, + DurablyHooks, + JobHooks, +} from './client/create-durably-hooks' + +export { createJobHooks } from './client/create-job-hooks' +export type { + CreateJobHooksOptions, + JobHooks as SingleJobHooks, +} from './client/create-job-hooks' + +// Direct hooks +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, + TypedClientRun, + UseRunsClientOptions, + UseRunsClientResult, +} from './client/use-runs' + +export { useRunActions } from './client/use-run-actions' +export type { + StepRecord, + UseRunActionsClientOptions, + UseRunActionsClientResult, +} from './client/use-run-actions' + +// Shared types export type { DurablyEvent, LogEntry, Progress, RunStatus } from './types' diff --git a/packages/durably-react/src/spa.ts b/packages/durably-react/src/spa.ts new file mode 100644 index 00000000..6d33fcb4 --- /dev/null +++ b/packages/durably-react/src/spa.ts @@ -0,0 +1,14 @@ +// @coji/durably-react/spa - SPA mode +// Run Durably entirely in the browser with OPFS persistence + +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 { TypedRun, UseRunsOptions, UseRunsResult } from './hooks/use-runs' +export type { DurablyEvent, LogEntry, Progress, RunStatus } from './types' diff --git a/packages/durably-react/tests/browser/provider.test.tsx b/packages/durably-react/tests/browser/provider.test.tsx index 5eea0319..e6c791e9 100644 --- a/packages/durably-react/tests/browser/provider.test.tsx +++ b/packages/durably-react/tests/browser/provider.test.tsx @@ -8,7 +8,7 @@ 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 { DurablyProvider, useDurably } from '../../src/spa' import { createTestDurably } from '../helpers/create-test-durably' describe('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 index 06004ab7..ded62119 100644 --- a/packages/durably-react/tests/browser/use-job-logs.test.tsx +++ b/packages/durably-react/tests/browser/use-job-logs.test.tsx @@ -10,7 +10,7 @@ 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 { DurablyProvider, useDurably, useJobLogs } from '../../src/spa' import { createTestDurably } from '../helpers/create-test-durably' // Test job that generates logs with delay to ensure we can subscribe diff --git a/packages/durably-react/tests/browser/use-job-run.test.tsx b/packages/durably-react/tests/browser/use-job-run.test.tsx index 9005079b..4bbeb6ff 100644 --- a/packages/durably-react/tests/browser/use-job-run.test.tsx +++ b/packages/durably-react/tests/browser/use-job-run.test.tsx @@ -10,7 +10,7 @@ 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 { DurablyProvider, useDurably, useJobRun } from '../../src/spa' import { createTestDurably } from '../helpers/create-test-durably' // Test job definitions - use slow jobs to ensure we can subscribe before completion diff --git a/packages/durably-react/tests/browser/use-job.test.tsx b/packages/durably-react/tests/browser/use-job.test.tsx index b6ccffcf..02c16eac 100644 --- a/packages/durably-react/tests/browser/use-job.test.tsx +++ b/packages/durably-react/tests/browser/use-job.test.tsx @@ -9,7 +9,7 @@ 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 { DurablyProvider, useJob } from '../../src/spa' import { createTestDurably } from '../helpers/create-test-durably' // Test job definitions diff --git a/packages/durably-react/tests/browser/use-runs.test.tsx b/packages/durably-react/tests/browser/use-runs.test.tsx index 0e734666..6f2ef3f7 100644 --- a/packages/durably-react/tests/browser/use-runs.test.tsx +++ b/packages/durably-react/tests/browser/use-runs.test.tsx @@ -9,7 +9,7 @@ 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 { DurablyProvider, useRuns } from '../../src/spa' import { createTestDurably } from '../helpers/create-test-durably' // Test job definition diff --git a/packages/durably-react/tests/client/create-durably-client.test.tsx b/packages/durably-react/tests/client/create-durably-client.test.tsx index c9361cb1..654cfc73 100644 --- a/packages/durably-react/tests/client/create-durably-client.test.tsx +++ b/packages/durably-react/tests/client/create-durably-client.test.tsx @@ -1,5 +1,5 @@ /** - * createDurablyClient Tests + * createDurablyHooks Tests * * Test the type-safe client factory. * Note: Hook behavior (SSE subscription, logs, etc.) is tested in the individual hook tests. @@ -8,7 +8,7 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createDurablyClient } from '../../src/client/create-durably-client' +import { createDurablyHooks } from '../../src/client/create-durably-hooks' import { createMockEventSource, type MockEventSourceConstructor, @@ -29,7 +29,7 @@ type MockJobs = { } } -describe('createDurablyClient', () => { +describe('createDurablyHooks', () => { let mockEventSource: MockEventSourceConstructor let originalEventSource: typeof EventSource let originalFetch: typeof fetch @@ -48,7 +48,7 @@ describe('createDurablyClient', () => { }) it('creates a client with job accessors via proxy', () => { - const client = createDurablyClient({ api: '/api/durably' }) + const client = createDurablyHooks({ api: '/api/durably' }) // Verify the proxy creates job clients on access expect(client.importCsv).toBeDefined() @@ -75,7 +75,7 @@ describe('createDurablyClient', () => { }) globalThis.fetch = fetchMock - const client = createDurablyClient({ api: '/api/durably' }) + const client = createDurablyHooks({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useJob()) await result.current.trigger({ filename: 'data.csv' }) @@ -109,7 +109,7 @@ describe('createDurablyClient', () => { }) globalThis.fetch = fetchMock - const client = createDurablyClient({ api: '/api/durably' }) + const client = createDurablyHooks({ api: '/api/durably' }) // Trigger via importCsv const { result: importResult } = renderHook(() => client.importCsv.useJob()) @@ -135,7 +135,7 @@ describe('createDurablyClient', () => { }) it('useRun returns a hook function', () => { - const client = createDurablyClient({ api: '/api/durably' }) + const client = createDurablyHooks({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useRun(null)) @@ -144,7 +144,7 @@ describe('createDurablyClient', () => { }) it('useLogs returns a hook function', () => { - const client = createDurablyClient({ api: '/api/durably' }) + const client = createDurablyHooks({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useLogs(null)) diff --git a/packages/durably-react/tsup.config.ts b/packages/durably-react/tsup.config.ts index b05419bf..a690b60c 100644 --- a/packages/durably-react/tsup.config.ts +++ b/packages/durably-react/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: { index: 'src/index.ts', - client: 'src/client.ts', + spa: 'src/spa.ts', }, format: ['esm'], dts: true, diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index fe32bab0..41665c8e 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -29,6 +29,7 @@ import { z } from 'zod' const client = createClient({ url: 'file:local.db' }) const dialect = new LibsqlDialect({ client }) +// Option 1: With jobs (1-step initialization, returns typed instance) const durably = createDurably({ dialect, pollingInterval: 1000, // Job polling interval (ms) @@ -36,6 +37,16 @@ const durably = createDurably({ staleThreshold: 30000, // When to consider a job abandoned (ms) // Optional: type-safe labels with Zod schema // labels: z.object({ organizationId: z.string(), env: z.string() }), + jobs: { + syncUsers: syncUsersJob, + }, +}) +// durably.jobs.syncUsers is immediately available and type-safe + +// Option 2: Without jobs (register later) +const durably = createDurably({ dialect }) +const { syncUsers } = durably.register({ + syncUsers: syncUsersJob, }) ``` @@ -63,11 +74,6 @@ const syncUsersJob = defineJob({ return { syncedCount: users.length } }, }) - -// Register jobs with durably instance -const { syncUsers } = durably.register({ - syncUsers: syncUsersJob, -}) ``` ### 3. Starting the Worker diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index 771dd67e..aff4934d 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -33,6 +33,11 @@ import { type Worker, createWorker } from './worker' */ export interface DurablyOptions< TLabels extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions + TJobs extends Record> = Record< + string, + never + >, > { dialect: Dialect pollingInterval?: number @@ -44,6 +49,17 @@ export interface DurablyOptions< * - Labels are validated at runtime on trigger() */ labels?: z.ZodType + /** + * Job definitions to register. Shorthand for calling .register() after creation. + * @example + * ```ts + * const durably = createDurably({ + * dialect, + * jobs: { importCsv: importCsvJob, syncUsers: syncUsersJob }, + * }) + * ``` + */ + jobs?: TJobs } /** @@ -553,9 +569,36 @@ function createDurablyInstance< /** * Create a Durably instance */ +// Overload: with jobs export function createDurably< TLabels extends Record = Record, ->(options: DurablyOptions): Durably, TLabels> { + // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions + TJobs extends Record> = Record< + string, + never + >, +>( + options: DurablyOptions & { jobs: TJobs }, +): Durably, TLabels> + +// Overload: without jobs +export function createDurably< + TLabels extends Record = Record, +>(options: DurablyOptions): Durably, TLabels> + +// Implementation +export function createDurably< + TLabels extends Record = Record, + // biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions + TJobs extends Record> = Record< + string, + never + >, +>( + options: DurablyOptions, +): + | Durably, TLabels> + | Durably, TLabels> { const config = { pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval, heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval, @@ -579,5 +622,14 @@ export function createDurably< migrated: false, } - return createDurablyInstance, TLabels>(state, {}) + const instance = createDurablyInstance, TLabels>( + state, + {}, + ) + + if (options.jobs) { + return instance.register(options.jobs) + } + + return instance } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d767e34..03622d4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: specifier: ^5.9.3 version: 5.9.3 - examples/browser-react-router-spa: + examples/fullstack-react-router: dependencies: '@coji/durably': specifier: workspace:* @@ -47,15 +47,18 @@ importers: '@coji/durably-react': specifier: workspace:* version: link:../../packages/durably-react + '@libsql/kysely-libsql': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.28.11) '@react-router/node': specifier: 7.13.1 version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@react-router/serve': + specifier: 7.13.1 + version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) isbot: specifier: ^5.1.35 version: 5.1.35 - kysely: - specifier: ^0.28.11 - version: 0.28.11 react: specifier: ^19.2.4 version: 19.2.4 @@ -65,9 +68,6 @@ importers: react-router: specifier: 7.13.1 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - sqlocal: - specifier: ^0.17.0 - version: 0.17.0(kysely@0.28.11)(react@19.2.4)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3)) zod: specifier: ^4.3.6 version: 4.3.6 @@ -109,26 +109,20 @@ importers: specifier: ^6.1.1 version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) - examples/browser-vite-react: + examples/server-node: dependencies: '@coji/durably': specifier: workspace:* version: link:../../packages/durably - '@coji/durably-react': - specifier: workspace:* - version: link:../../packages/durably-react + '@libsql/client': + specifier: ^0.17.0 + version: 0.17.0 + '@libsql/kysely-libsql': + specifier: ^0.4.1 + version: 0.4.1(kysely@0.28.11) kysely: specifier: ^0.28.11 version: 0.28.11 - react: - specifier: ^19.2.4 - version: 19.2.4 - react-dom: - specifier: ^19.2.4 - version: 19.2.4(react@19.2.4) - sqlocal: - specifier: ^0.17.0 - version: 0.17.0(kysely@0.28.11)(react@19.2.4)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3)) zod: specifier: ^4.3.6 version: 4.3.6 @@ -136,35 +130,23 @@ importers: '@biomejs/biome': specifier: ^2.4.5 version: 2.4.5 - '@tailwindcss/vite': - specifier: ^4.2.1 - version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@types/node': + specifier: ^25.3.3 + version: 25.3.3 prettier: specifier: ^3.8.1 version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.3.0 version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) - tailwindcss: - specifier: ^4.2.1 - version: 4.2.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 - vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) - examples/fullstack-react-router: + examples/spa-react-router: dependencies: '@coji/durably': specifier: workspace:* @@ -172,18 +154,15 @@ importers: '@coji/durably-react': specifier: workspace:* version: link:../../packages/durably-react - '@libsql/kysely-libsql': - specifier: ^0.4.1 - version: 0.4.1(kysely@0.28.11) '@react-router/node': specifier: 7.13.1 version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) - '@react-router/serve': - specifier: 7.13.1 - version: 7.13.1(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) isbot: specifier: ^5.1.35 version: 5.1.35 + kysely: + specifier: ^0.28.11 + version: 0.28.11 react: specifier: ^19.2.4 version: 19.2.4 @@ -193,6 +172,9 @@ importers: react-router: specifier: 7.13.1 version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + sqlocal: + specifier: ^0.17.0 + version: 0.17.0(kysely@0.28.11)(react@19.2.4)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3)) zod: specifier: ^4.3.6 version: 4.3.6 @@ -234,20 +216,26 @@ importers: specifier: ^6.1.1 version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) - examples/server-node: + examples/spa-vite-react: dependencies: '@coji/durably': specifier: workspace:* version: link:../../packages/durably - '@libsql/client': - specifier: ^0.17.0 - version: 0.17.0 - '@libsql/kysely-libsql': - specifier: ^0.4.1 - version: 0.4.1(kysely@0.28.11) + '@coji/durably-react': + specifier: workspace:* + version: link:../../packages/durably-react kysely: specifier: ^0.28.11 version: 0.28.11 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + sqlocal: + specifier: ^0.17.0 + version: 0.17.0(kysely@0.28.11)(react@19.2.4)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3)) zod: specifier: ^4.3.6 version: 4.3.6 @@ -255,21 +243,33 @@ importers: '@biomejs/biome': specifier: ^2.4.5 version: 2.4.5 - '@types/node': - specifier: ^25.3.3 - version: 25.3.3 + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.4 + version: 5.1.4(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) prettier: specifier: ^3.8.1 version: 3.8.1 prettier-plugin-organize-imports: specifier: ^4.3.0 version: 4.3.0(prettier@3.8.1)(typescript@5.9.3) - tsx: - specifier: ^4.21.0 - version: 4.21.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 typescript: specifier: ^5.9.3 version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) packages/durably: dependencies: diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 9740083f..a41915d9 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -2,19 +2,44 @@ import { defineConfig } from 'vitepress' export default defineConfig({ title: 'Durably', - description: 'Step-oriented resumable batch execution for Node.js and browsers', + description: + 'Step-oriented resumable batch execution for Node.js and browsers', base: '/durably/', head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/durably/logo.svg' }], ['meta', { property: 'og:title', content: 'Durably' }], - ['meta', { property: 'og:description', content: 'Just SQLite. No Redis required.' }], - ['meta', { property: 'og:image', content: 'https://coji.github.io/durably/og-image.png' }], + [ + 'meta', + { + property: 'og:description', + content: 'Just SQLite. No Redis required.', + }, + ], + [ + 'meta', + { + property: 'og:image', + content: 'https://coji.github.io/durably/og-image.png', + }, + ], ['meta', { property: 'og:type', content: 'website' }], ['meta', { name: 'twitter:card', content: 'summary_large_image' }], ['meta', { name: 'twitter:title', content: 'Durably' }], - ['meta', { name: 'twitter:description', content: 'Just SQLite. No Redis required.' }], - ['meta', { name: 'twitter:image', content: 'https://coji.github.io/durably/og-image.png' }], + [ + 'meta', + { + name: 'twitter:description', + content: 'Just SQLite. No Redis required.', + }, + ], + [ + 'meta', + { + name: 'twitter:image', + content: 'https://coji.github.io/durably/og-image.png', + }, + ], ], themeConfig: { @@ -42,16 +67,17 @@ export default defineConfig({ items: [ { 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' }, + { + text: 'Background Sync (Server)', + link: '/guide/background-sync', + }, ], }, ], '/api/': [ { text: 'Getting Started', - items: [ - { text: 'Quick Reference', link: '/api/' }, - ], + items: [{ text: 'Quick Reference', link: '/api/' }], }, { text: 'Job Definition', @@ -62,7 +88,10 @@ export default defineConfig({ collapsed: false, items: [ { text: 'trigger', link: '/api/define-job#trigger' }, - { text: 'triggerAndWait', link: '/api/define-job#triggerandwait' }, + { + text: 'triggerAndWait', + link: '/api/define-job#triggerandwait', + }, { text: 'batchTrigger', link: '/api/define-job#batchtrigger' }, ], }, @@ -86,12 +115,18 @@ export default defineConfig({ link: '/api/create-durably', collapsed: false, items: [ - { text: 'init / migrate / start', link: '/api/create-durably#init' }, + { + 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: 'getRun / getRuns', + link: '/api/create-durably#getrun', + }, ], }, { @@ -115,11 +150,23 @@ export default defineConfig({ link: '/api/http-handler', collapsed: false, items: [ - { text: 'createDurablyHandler', link: '/api/http-handler#createdurablyhandler' }, - { text: 'Framework Integration', link: '/api/http-handler#framework-integration' }, + { + 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' }, + { + text: 'SSE Events', + link: '/api/http-handler#sse-event-stream', + }, + { + text: 'Security', + link: '/api/http-handler#security-considerations', + }, ], }, ], @@ -129,29 +176,53 @@ export default defineConfig({ items: [ { text: 'Overview', link: '/api/durably-react/' }, { - text: 'Browser Hooks', + text: 'SPA 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: '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: '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', + text: 'Fullstack Hooks', link: '/api/durably-react/client', collapsed: false, items: [ - { text: 'createDurablyClient', link: '/api/durably-react/client#createdurablyclient' }, + { + text: 'createDurablyHooks', + link: '/api/durably-react/client#createdurablyhooks', + }, { 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: '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: 'useRunActions', + link: '/api/durably-react/client#userunactions', + }, ], }, { text: 'Type Definitions', link: '/api/durably-react/types' }, diff --git a/website/api/durably-react/browser.md b/website/api/durably-react/browser.md index c9e13fe1..68def04b 100644 --- a/website/api/durably-react/browser.md +++ b/website/api/durably-react/browser.md @@ -1,4 +1,4 @@ -# Browser Hooks +# SPA 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. @@ -10,7 +10,7 @@ import { useJobRun, useJobLogs, useRuns, -} from '@coji/durably-react' +} from '@coji/durably-react/spa' ``` ## DurablyProvider @@ -18,7 +18,7 @@ import { Wraps your app and initializes Durably with a browser SQLite database. ```tsx -import { DurablyProvider } from '@coji/durably-react' +import { DurablyProvider } from '@coji/durably-react/spa' import { createDurably } from '@coji/durably' import { SQLocalKysely } from 'sqlocal/kysely' @@ -58,7 +58,7 @@ function App() { Access the Durably instance directly. ```tsx -import { useDurably } from '@coji/durably-react' +import { useDurably } from '@coji/durably-react/spa' function Component() { const { durably, isReady, error } = useDurably() @@ -87,7 +87,7 @@ 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 { useJob } from '@coji/durably-react/spa' import { z } from 'zod' const myJob = defineJob({ @@ -176,7 +176,7 @@ interface UseJobResult { Subscribe to an existing run by ID. ```tsx -import { useJobRun } from '@coji/durably-react' +import { useJobRun } from '@coji/durably-react/spa' function RunMonitor({ runId }: { runId: string | null }) { const { @@ -215,7 +215,7 @@ function RunMonitor({ runId }: { runId: string | null }) { Subscribe to logs from a run. ```tsx -import { useJobLogs } from '@coji/durably-react' +import { useJobLogs } from '@coji/durably-react/spa' function LogViewer({ runId }: { runId: string | null }) { const { logs, clearLogs } = useJobLogs({ @@ -263,7 +263,7 @@ The hook automatically subscribes to Durably events and refreshes the list when Use a type parameter to specify the run type for dashboards with multiple job types: ```tsx -import { useRuns, TypedRun } from '@coji/durably-react' +import { useRuns, TypedRun } from '@coji/durably-react/spa' // Define your run types type ImportRun = TypedRun<{ file: string }, { count: number }> @@ -293,7 +293,7 @@ Pass a `JobDefinition` to get typed runs and auto-filter by job name: ```tsx import { defineJob } from '@coji/durably' -import { useRuns } from '@coji/durably-react' +import { useRuns } from '@coji/durably-react/spa' const myJob = defineJob({ name: 'my-job', @@ -323,7 +323,7 @@ function RunList() { ### Without type parameter (untyped) ```tsx -import { useRuns } from '@coji/durably-react' +import { useRuns } from '@coji/durably-react/spa' function RunList() { const { runs } = useRuns({ jobName: 'my-job', pageSize: 10 }) diff --git a/website/api/durably-react/client.md b/website/api/durably-react/client.md index 081de1c1..f8bee8f9 100644 --- a/website/api/durably-react/client.md +++ b/website/api/durably-react/client.md @@ -1,9 +1,9 @@ -# Server Hooks +# Fullstack 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 { useJob, useRuns, useRunActions } from '@coji/durably-react/client' +import { useJob, useRuns, useRunActions } from '@coji/durably-react' ``` ## Server Setup @@ -19,9 +19,12 @@ 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 durably = createDurably({ + dialect, + jobs: { + importCsv: importCsvJob, + syncUsers: syncUsersJob, + }, }) export const durablyHandler = createDurablyHandler(durably) @@ -51,7 +54,7 @@ export async function action({ request }: Route.ActionArgs) { Pass `api` and `jobName` to each hook. Works with any setup. ```tsx -import { useJob } from '@coji/durably-react/client' +import { useJob } from '@coji/durably-react' function CsvImporter() { const { trigger, status, output, isRunning } = useJob< @@ -73,26 +76,23 @@ function CsvImporter() { } ``` -### 2. createDurablyClient (SPA only, type-safe) +### 2. createDurablyHooks (type-safe) Wraps hooks with a type-safe Proxy — autocomplete for job names, inferred input/output types. -> [!NOTE] -> Not compatible with SSR. For SSR apps (e.g., React Router with `ssr: true`), use hooks directly instead. See the [fullstack-react-router example](https://github.com/coji/durably/tree/main/examples/fullstack-react-router). - ```ts // app/lib/durably.client.ts -import { createDurablyClient } from '@coji/durably-react/client' +import { createDurablyHooks } from '@coji/durably-react' import type { durably } from './durably.server' -export const durablyClient = createDurablyClient({ +export const durably = createDurablyHooks({ api: '/api/durably', }) ``` ```tsx function CsvImporter() { - const { trigger, status, output, isRunning } = durablyClient.importCsv.useJob() + const { trigger, status, output, isRunning } = durably.importCsv.useJob() return ( - {progress &&

    {progress.current}/{progress.total}

    } + {progress && ( +

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

    + )} {isCompleted &&

    Done: {output?.count} rows

    }
    ) } ``` -### Server Mode +### SPA Mode -Jobs run on the server, with real-time updates via SSE. +Jobs run entirely in the browser with OPFS persistence. ```tsx -// 1. Create type-safe client (client-side file) -import { createDurablyClient } from '@coji/durably-react/client' -import type { durably } from './durably.server' +import { DurablyProvider, useJob } from '@coji/durably-react/spa' +import { durably } from './lib/durably' +import { importCsvJob } from './jobs/import-csv' -export const durablyClient = createDurablyClient({ - api: '/api/durably', -}) +function App() { + return ( + Loading...

    }> + +
    + ) +} -// 2. Use in components function ImportButton() { const { trigger, progress, isRunning, isCompleted, output } = - durablyClient.importCsv.useJob() + useJob(importCsvJob) return (
    - - {progress &&

    {progress.current}/{progress.total}

    } + {progress && ( +

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

    + )} {isCompleted &&

    Done: {output?.count} rows

    }
    ) @@ -122,23 +137,23 @@ function ImportButton() { ### 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 | +| 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 +### SPA Mode Only -| Hook | Description | -|------|-------------| +| Hook | Description | +| ------------ | ------------------------------------ | | `useDurably` | Access the Durably instance directly | -### Server Mode Only +### Fullstack Mode Only -| Hook | Description | -|------|-------------| +| Hook | Description | +| --------------- | -------------------------- | | `useRunActions` | Retry, cancel, delete runs | ## Common Patterns @@ -156,7 +171,9 @@ function ProgressBar({ runId }: { runId: string }) { return (
    - {percent}% - {progress.message} + + {percent}% - {progress.message} +
    ) } @@ -181,7 +198,7 @@ function JobRunner() { } ``` -### Run Dashboard (Server Mode) +### Run Dashboard (Fullstack Mode) ```tsx function Dashboard() { @@ -194,16 +211,24 @@ function Dashboard() { return ( - + + + + + - {runs.map(run => ( + {runs.map((run) => ( diff --git a/website/api/index.md b/website/api/index.md index 43f48456..12c98c75 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -135,23 +135,23 @@ export async function action({ request }) { ## React Hooks -### Server-Connected (Full-Stack) +### Fullstack Mode Connect to a Durably server via HTTP/SSE. ```tsx -// 1. Create type-safe client -import { createDurablyClient } from '@coji/durably-react/client' +// 1. Create type-safe hooks +import { createDurablyHooks } from '@coji/durably-react' import type { durably } from './durably.server' -const durablyClient = createDurablyClient({ +const durably = createDurablyHooks({ api: '/api/durably', }) // 2. Use in components function ImportButton() { const { trigger, progress, isRunning, isCompleted, output } = - durablyClient.importCsv.useJob() + durably.importCsv.useJob() return (
    @@ -172,12 +172,12 @@ function ImportButton() { } ``` -### Browser-Only (Offline) +### SPA Mode (Offline) Run Durably entirely in the browser with OPFS persistence. ```tsx -import { DurablyProvider, useJob } from '@coji/durably-react' +import { DurablyProvider, useJob } from '@coji/durably-react/spa' import { durably } from './durably' import { importCsvJob } from './jobs' @@ -195,7 +195,7 @@ function ImportButton() { } ``` -**See:** [React Hooks Overview](/api/durably-react/) | [Browser Hooks](/api/durably-react/browser) | [Server Hooks](/api/durably-react/client) +**See:** [React Hooks Overview](/api/durably-react/) | [SPA Hooks](/api/durably-react/browser) | [Fullstack Hooks](/api/durably-react/client) ## API at a Glance @@ -228,13 +228,13 @@ function ImportButton() { ### 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 | +| Hook | Mode | Description | +| --------------- | --------- | -------------------------- | +| `useJob` | Both | Trigger and monitor jobs | +| `useJobRun` | Both | Subscribe to existing run | +| `useRuns` | Both | List runs with pagination | +| `useRunActions` | Fullstack | Retry, cancel, delete runs | +| `useDurably` | SPA | Access Durably instance | ## Type Exports diff --git a/website/guide/csv-import.md b/website/guide/csv-import.md index bb82b160..d56790b0 100644 --- a/website/guide/csv-import.md +++ b/website/guide/csv-import.md @@ -171,10 +171,10 @@ Create a type-safe client using the server's Durably type. This gives you full T ```ts // app/lib/durably.client.ts -import { createDurablyClient } from '@coji/durably-react/client' +import { createDurablyHooks } from '@coji/durably-react' import type { durably } from './durably.server' -export const durablyClient = createDurablyClient({ +export const durablyClient = createDurablyHooks({ api: '/api/durably', }) ``` @@ -214,7 +214,7 @@ function ImportProgress({ runId }: { runId: string | null }) { 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' +import { useRuns, useRunActions } from '@coji/durably-react' function Dashboard() { const { runs, refresh } = useRuns({ api: '/api/durably' }) diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 1f578786..dbf59a27 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -110,11 +110,11 @@ Create a type-safe client using the server's Durably type. This gives you full t ```ts // app/lib/durably.client.ts -import { createDurablyClient } from '@coji/durably-react/client' +import { createDurablyHooks } from '@coji/durably-react' // Type-only import: no server code is bundled, just TypeScript types import type { durably } from './durably.server' -export const durablyClient = createDurablyClient({ +export const durablyClient = createDurablyHooks({ api: '/api/durably', }) ``` diff --git a/website/guide/offline-app.md b/website/guide/offline-app.md index 99df8bc8..11eecc9e 100644 --- a/website/guide/offline-app.md +++ b/website/guide/offline-app.md @@ -2,7 +2,7 @@ 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) +**Example code:** [spa-vite-react](https://github.com/coji/durably/tree/main/examples/spa-vite-react) ## When to Use @@ -148,8 +148,8 @@ Use `useJob` to trigger jobs and subscribe to their progress. The hook returns t ```tsx // App.tsx -import { DurablyProvider, useDurably } from '@coji/durably-react' -import { useJob } from '@coji/durably-react' +import { DurablyProvider, useDurably } from '@coji/durably-react/spa' +import { useJob } from '@coji/durably-react/spa' import { useState } from 'react' import { durably } from './lib/durably' import { dataSyncJob } from './jobs/data-sync' diff --git a/website/public/llms.txt b/website/public/llms.txt index 0d9d274c..a6ce1d44 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -29,6 +29,7 @@ import { z } from 'zod' const client = createClient({ url: 'file:local.db' }) const dialect = new LibsqlDialect({ client }) +// Option 1: With jobs (1-step initialization, returns typed instance) const durably = createDurably({ dialect, pollingInterval: 1000, // Job polling interval (ms) @@ -36,6 +37,16 @@ const durably = createDurably({ staleThreshold: 30000, // When to consider a job abandoned (ms) // Optional: type-safe labels with Zod schema // labels: z.object({ organizationId: z.string(), env: z.string() }), + jobs: { + syncUsers: syncUsersJob, + }, +}) +// durably.jobs.syncUsers is immediately available and type-safe + +// Option 2: Without jobs (register later) +const durably = createDurably({ dialect }) +const { syncUsers } = durably.register({ + syncUsers: syncUsersJob, }) ``` @@ -63,11 +74,6 @@ const syncUsersJob = defineJob({ return { syncedCount: users.length } }, }) - -// Register jobs with durably instance -const { syncUsers } = durably.register({ - syncUsers: syncUsersJob, -}) ``` ### 3. Starting the Worker @@ -624,29 +630,273 @@ MIT `@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 +1. **Fullstack Hooks** (default): Connect to a remote Durably server via HTTP/SSE +2. **SPA Hooks**: Run Durably entirely in the browser with SQLite WASM (OPFS) ## Installation ```bash -# Browser mode - runs Durably in the browser +# Fullstack mode - connects to Durably server +pnpm add @coji/durably-react + +# SPA 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 +## Fullstack Hooks + +Import from `@coji/durably-react` (root) for server-connected mode. + +### createDurablyHooks + +Create a type-safe hooks factory for all registered jobs. + +```tsx +// Server: register jobs (app/lib/durably.server.ts) +import { createDurably, createDurablyHandler } from '@coji/durably' + +export const durably = createDurably({ + dialect, + jobs: { + importCsv: importCsvJob, + syncUsers: syncUsersJob, + }, +}) + +export const durablyHandler = createDurablyHandler(durably, { + sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable) +}) + +await durably.init() + +// Client: create typed hooks (app/lib/durably.client.ts) +import type { durably } from '~/lib/durably.server' +import { createDurablyHooks } from '@coji/durably-react' + +export const durably = createDurablyHooks({ + api: '/api/durably', +}) + +// In your component - fully type-safe with autocomplete +function CsvImporter() { + const { trigger, output, isRunning } = durably.importCsv.useJob() + + return ( + + ) +} + +// Subscribe to an existing run +function RunViewer({ runId }: { runId: string }) { + const { status, output, progress } = durably.importCsv.useRun(runId) + return
    Status: {status}
    +} + +// Subscribe to logs +function LogViewer({ runId }: { runId: string }) { + const { logs } = durably.importCsv.useLogs(runId) + return
    {logs.map(l => l.message).join('\n')}
    +} ``` -## Browser Hooks +### Fullstack useJob -Import from `@coji/durably-react` for browser-complete mode. +Direct hook when not using `createDurablyHooks`: + +```tsx +import { useJob } from '@coji/durably-react' + +function Component() { + const { + trigger, + triggerAndWait, + status, + output, + error, + logs, + progress, + isRunning, + isPending, + isCompleted, + isFailed, + isCancelled, + currentRunId, + reset, + } = useJob< + { userId: string }, // Input type + { count: number } // Output type + >({ + api: '/api/durably', + jobName: 'sync-data', + initialRunId: undefined, // Optional: resume existing run + autoResume: true, // Auto-resume pending/running jobs on mount (default: true) + followLatest: true, // Switch to tracking new runs (default: true) + }) + + const handleClick = async () => { + const { runId } = await trigger({ userId: 'user_123' }) + console.log('Started:', runId) + } + + return +} +``` + +**Options:** + +```ts +interface UseJobClientOptions { + api: string // API endpoint URL (e.g., '/api/durably') + jobName: string // Job name to trigger + initialRunId?: string // Initial Run ID to subscribe to + autoResume?: boolean // Auto-resume pending/running jobs on mount (default: true) + followLatest?: boolean // Switch to tracking new runs via SSE (default: true) +} +``` + +The `autoResume` option automatically fetches running/pending jobs on mount and subscribes to them. This is useful for SSR applications where users may refresh the page while a job is running. + +The `followLatest` option subscribes to job-level SSE events and automatically switches to tracking the latest triggered job. This enables real-time updates when jobs are triggered from other tabs or clients. + +### Fullstack useJobRun + +```tsx +import { useJobRun } from '@coji/durably-react' + +function Component({ runId }: { runId: string }) { + const { status, output, error, progress, logs } = useJobRun<{ + count: number + }>({ + api: '/api/durably', + runId, + }) + + return
    Status: {status}
    +} +``` + +### Fullstack useJobLogs + +```tsx +import { useJobLogs } from '@coji/durably-react' + +function Component({ runId }: { runId: string }) { + const { logs, clearLogs } = useJobLogs({ + api: '/api/durably', + runId, + maxLogs: 50, + }) + + return ( +
      + {logs.map((log) => ( +
    • {log.message}
    • + ))} +
    + ) +} +``` + +### Fullstack useRuns + +List runs with pagination and real-time updates: + +```tsx +import { useRuns, TypedClientRun } from '@coji/durably-react' +import { defineJob } from '@coji/durably' + +// Option 1: Generic type parameter (dashboard with multiple job types) +type ImportRun = TypedClientRun<{ file: string }, { count: number }> +type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> +type DashboardRun = ImportRun | SyncRun + +function Dashboard() { + const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) + // runs are typed as DashboardRun[] +} + +// Option 2: JobDefinition (single job, auto-filters by jobName) +const syncDataJob = defineJob({ + name: 'sync-data', + input: z.object({ userId: z.string() }), + output: z.object({ count: z.number() }), + run: async (step, input) => { + /* ... */ + }, +}) + +function SingleJobDashboard() { + const { runs } = useRuns(syncDataJob, { + api: '/api/durably', + status: 'completed', + pageSize: 20, + }) + // runs[0].output is typed as { count: number } | null +} + +// Option 3: Untyped (simple cases) +function UntypedDashboard() { + const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) + // runs[0].output is unknown +} + +// Filter by labels +function FilteredDashboard() { + const { runs } = useRuns({ + api: '/api/durably', + labels: { source: 'browser' }, + pageSize: 10, + }) + // runs[0].labels is Record +} +``` + +### Fullstack useRunActions + +Actions for runs (retry, cancel, delete): + +```tsx +import { useRunActions } from '@coji/durably-react' + +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}} +
    + ) +} +``` + +## SPA Hooks + +Import from `@coji/durably-react/spa` for browser-complete mode. ### DurablyProvider Wraps your app and provides the Durably instance to all hooks: ```tsx -import { DurablyProvider } from '@coji/durably-react' +import { DurablyProvider } from '@coji/durably-react/spa' import { createDurably } from '@coji/durably' import { SQLocalKysely } from 'sqlocal/kysely' @@ -691,7 +941,7 @@ function AppAlt() { Access the Durably instance directly: ```tsx -import { useDurably } from '@coji/durably-react' +import { useDurably } from '@coji/durably-react/spa' function Component() { const { durably } = useDurably() @@ -717,7 +967,7 @@ Trigger and monitor a job: ```tsx import { defineJob } from '@coji/durably' -import { useJob } from '@coji/durably-react' +import { useJob } from '@coji/durably-react/spa' import { z } from 'zod' const myJob = defineJob({ @@ -828,7 +1078,7 @@ interface UseJobResult { Subscribe to an existing run by ID: ```tsx -import { useJobRun } from '@coji/durably-react' +import { useJobRun } from '@coji/durably-react/spa' function RunMonitor({ runId }: { runId: string | null }) { const { @@ -858,7 +1108,7 @@ function RunMonitor({ runId }: { runId: string | null }) { Subscribe to logs from a run: ```tsx -import { useJobLogs } from '@coji/durably-react' +import { useJobLogs } from '@coji/durably-react/spa' function LogViewer({ runId }: { runId: string | null }) { const { logs, clearLogs } = useJobLogs({ @@ -887,7 +1137,7 @@ function LogViewer({ runId }: { runId: string | null }) { List runs with filtering, pagination, and real-time updates: ```tsx -import { useRuns, TypedRun } from '@coji/durably-react' +import { useRuns, TypedRun } from '@coji/durably-react/spa' import { defineJob } from '@coji/durably' // Option 1: Generic type parameter (dashboard with multiple job types) @@ -929,249 +1179,6 @@ function FilteredDashboard() { } ``` -## Server Hooks - -Import from `@coji/durably-react/client` for server-connected mode. - -### createDurablyClient - -Create a type-safe client for all registered jobs. Recommended for SPA (non-SSR) apps. - -Not compatible with SSR. For SSR apps, use `useJob`/`useRuns` hooks directly with the `api` option. - -```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, { - sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable) -}) - -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, - isPending, - isCompleted, - isFailed, - isCancelled, - currentRunId, - reset, - } = useJob< - { userId: string }, // Input type - { count: number } // Output type - >({ - api: '/api/durably', - jobName: 'sync-data', - initialRunId: undefined, // Optional: resume existing run - autoResume: true, // Auto-resume pending/running jobs on mount (default: true) - followLatest: true, // Switch to tracking new runs (default: true) - }) - - const handleClick = async () => { - const { runId } = await trigger({ userId: 'user_123' }) - console.log('Started:', runId) - } - - return -} -``` - -**Options:** - -```ts -interface UseJobClientOptions { - api: string // API endpoint URL (e.g., '/api/durably') - jobName: string // Job name to trigger - initialRunId?: string // Initial Run ID to subscribe to - autoResume?: boolean // Auto-resume pending/running jobs on mount (default: true) - followLatest?: boolean // Switch to tracking new runs via SSE (default: true) -} -``` - -The `autoResume` option automatically fetches running/pending jobs on mount and subscribes to them. This is useful for SSR applications where users may refresh the page while a job is running. - -The `followLatest` option subscribes to job-level SSE events and automatically switches to tracking the latest triggered job. This enables real-time updates when jobs are triggered from other tabs or clients. - -### 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, TypedClientRun } from '@coji/durably-react/client' -import { defineJob } from '@coji/durably' - -// Option 1: Generic type parameter (dashboard with multiple job types) -type ImportRun = TypedClientRun<{ file: string }, { count: number }> -type SyncRun = TypedClientRun<{ userId: string }, { synced: boolean }> -type DashboardRun = ImportRun | SyncRun - -function Dashboard() { - const { runs } = useRuns({ api: '/api/durably', pageSize: 10 }) - // runs are typed as DashboardRun[] -} - -// Option 2: JobDefinition (single job, auto-filters by jobName) -const syncDataJob = defineJob({ - name: 'sync-data', - input: z.object({ userId: z.string() }), - output: z.object({ count: z.number() }), - run: async (step, input) => { - /* ... */ - }, -}) - -function SingleJobDashboard() { - const { runs } = useRuns(syncDataJob, { - api: '/api/durably', - status: 'completed', - pageSize: 20, - }) - // runs[0].output is typed as { count: number } | null -} - -// Option 3: Untyped (simple cases) -function UntypedDashboard() { - const { runs } = useRuns({ api: '/api/durably', jobName: 'sync-data' }) - // runs[0].output is unknown -} - -// Filter by labels -function FilteredDashboard() { - const { runs } = useRuns({ - api: '/api/durably', - labels: { source: 'browser' }, - pageSize: 10, - }) - // runs[0].labels is Record -} -``` - -### 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`: @@ -1186,8 +1193,11 @@ 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 durably = createDurably({ + dialect, + jobs: { + syncData: syncDataJob, + }, }) export const durablyHandler = createDurablyHandler(durably, { @@ -1230,7 +1240,7 @@ interface LogEntry { timestamp: string } -// Browser hooks: TypedRun with generic input/output +// SPA hooks: TypedRun with generic input/output type TypedRun< TInput extends Record = Record, TOutput extends Record | undefined = Record, @@ -1241,7 +1251,7 @@ type TypedRun< // ClientRun is re-exported from @coji/durably (excludes heartbeatAt, idempotencyKey, concurrencyKey, updatedAt) -// Client hooks: TypedClientRun with generic input/output +// Fullstack hooks: TypedClientRun with generic input/output type TypedClientRun< TInput extends Record = Record, TOutput extends Record | undefined = Record, From b11c357312ba6534e0dcc97e64821c4a60862736 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 22:19:04 +0900 Subject: [PATCH 02/15] refactor: deduplicate JobHooks, cache proxy results, guard Symbol keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate JobHooks interface from create-durably-hooks.ts, import from create-job-hooks.ts instead - Delegate proxy handler to createJobHooks() instead of re-implementing - Cache job hooks per jobKey to avoid allocations on every property access - Guard proxy get trap against Symbol keys (React DevTools compatibility) - Fix incorrect import path in docstring (@coji/durably-react/fullstack → @coji/durably-react) - Remove unnecessary SingleJobHooks alias from index.ts Co-Authored-By: Claude Opus 4.6 --- .../src/client/create-durably-hooks.ts | 52 ++++--------------- packages/durably-react/src/client/index.ts | 3 +- packages/durably-react/src/index.ts | 6 +-- 3 files changed, 13 insertions(+), 48 deletions(-) diff --git a/packages/durably-react/src/client/create-durably-hooks.ts b/packages/durably-react/src/client/create-durably-hooks.ts index 2da6a219..4256f46d 100644 --- a/packages/durably-react/src/client/create-durably-hooks.ts +++ b/packages/durably-react/src/client/create-durably-hooks.ts @@ -1,30 +1,5 @@ import type { InferInput, InferOutput } from '../types' -import { useJob, type UseJobClientResult } from './use-job' -import { useJobLogs, type UseJobLogsClientResult } from './use-job-logs' -import { useJobRun, type UseJobRunClientResult } from './use-job-run' - -/** - * 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 -} +import { createJobHooks, type JobHooks } from './create-job-hooks' /** * Options for createDurablyHooks @@ -58,7 +33,7 @@ export type DurablyHooks> = { * // Client: create typed hooks * // app/lib/durably.hooks.ts * import type { durably } from '~/lib/durably.server' - * import { createDurablyHooks } from '@coji/durably-react/fullstack' + * import { createDurablyHooks } from '@coji/durably-react' * * export const durably = createDurablyHooks({ * api: '/api/durably', @@ -80,23 +55,18 @@ export function createDurablyHooks>( options: CreateDurablyHooksOptions, ): DurablyHooks { const { api } = options + const cache = new Map>() - // Create a proxy that generates job hooks on demand + // Create a proxy that generates and caches job hooks on demand return new Proxy({} as DurablyHooks, { - 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 }) - }, + get(_target, jobKey) { + if (typeof jobKey !== 'string') return undefined + let hooks = cache.get(jobKey) + if (!hooks) { + hooks = createJobHooks({ api, jobName: jobKey }) + cache.set(jobKey, hooks) } + return hooks }, }) } diff --git a/packages/durably-react/src/client/index.ts b/packages/durably-react/src/client/index.ts index c8c4ac15..ce385eec 100644 --- a/packages/durably-react/src/client/index.ts +++ b/packages/durably-react/src/client/index.ts @@ -7,11 +7,10 @@ export { createDurablyHooks } from './create-durably-hooks' export type { CreateDurablyHooksOptions, DurablyHooks, - JobHooks, } from './create-durably-hooks' export { createJobHooks } from './create-job-hooks' -export type { CreateJobHooksOptions } from './create-job-hooks' +export type { CreateJobHooksOptions, JobHooks } from './create-job-hooks' export { useJob } from './use-job' export type { UseJobClientOptions, UseJobClientResult } from './use-job' diff --git a/packages/durably-react/src/index.ts b/packages/durably-react/src/index.ts index 37f2e36e..0aae7196 100644 --- a/packages/durably-react/src/index.ts +++ b/packages/durably-react/src/index.ts @@ -9,14 +9,10 @@ export { createDurablyHooks } from './client/create-durably-hooks' export type { CreateDurablyHooksOptions, DurablyHooks, - JobHooks, } from './client/create-durably-hooks' export { createJobHooks } from './client/create-job-hooks' -export type { - CreateJobHooksOptions, - JobHooks as SingleJobHooks, -} from './client/create-job-hooks' +export type { CreateJobHooksOptions, JobHooks } from './client/create-job-hooks' // Direct hooks export { useJob } from './client/use-job' From 2cfba97be1b7a70ea759ddfb2a2775abc209454e Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 22:23:29 +0900 Subject: [PATCH 03/15] fix: stale closure in triggerAndWait, parallelize autoResume, deduplicate subscribe - Fix triggerAndWait stale closure bug: use ref to track latest subscription state instead of capturing React state in closure - Parallelize autoResume fetches: running and pending status checks now run concurrently with Promise.all (halves latency) - Deduplicate subscribe() event listeners: replace 11 near-identical blocks with data-driven loop over event types Co-Authored-By: Claude Opus 4.6 --- packages/durably-react/src/client/use-job.ts | 55 +++++---- packages/durably/src/durably.ts | 114 ++++--------------- 2 files changed, 50 insertions(+), 119 deletions(-) diff --git a/packages/durably-react/src/client/use-job.ts b/packages/durably-react/src/client/use-job.ts index 03b0fba5..199f41b9 100644 --- a/packages/durably-react/src/client/use-job.ts +++ b/packages/durably-react/src/client/use-job.ts @@ -116,6 +116,10 @@ export function useJob< const subscription = useSSESubscription(api, currentRunId) + // Keep a ref to the latest subscription state for use in triggerAndWait + const subscriptionRef = useRef(subscription) + subscriptionRef.current = subscription + // Auto-resume: fetch running/pending job on mount useEffect(() => { if (!autoResume) return @@ -124,39 +128,33 @@ export function useJob< const abortController = new AbortController() const findActiveRun = async () => { - // Try running first - const runningParams = new URLSearchParams({ - jobName, - status: 'running', - limit: '1', - }) - const runningRes = await fetch(`${api}/runs?${runningParams}`, { - signal: abortController.signal, - }) + // Fetch running and pending in parallel + const signal = abortController.signal + const [runningRes, pendingRes] = await Promise.all([ + fetch( + `${api}/runs?${new URLSearchParams({ jobName, status: 'running', limit: '1' })}`, + { signal }, + ), + fetch( + `${api}/runs?${new URLSearchParams({ jobName, status: 'pending', limit: '1' })}`, + { signal }, + ), + ]) + + if (hasUserTriggered.current) return + + // Prefer running over pending if (runningRes.ok) { const runs = (await runningRes.json()) as Array<{ id: string }> if (runs.length > 0) { - // Don't overwrite if user already triggered a run - if (hasUserTriggered.current) return setCurrentRunId(runs[0].id) return } } - // Try pending - const pendingParams = new URLSearchParams({ - jobName, - status: 'pending', - limit: '1', - }) - const pendingRes = await fetch(`${api}/runs?${pendingParams}`, { - signal: abortController.signal, - }) if (pendingRes.ok) { const runs = (await pendingRes.json()) as Array<{ id: string }> if (runs.length > 0) { - // Don't overwrite if user already triggered a run - if (hasUserTriggered.current) return setCurrentRunId(runs[0].id) } } @@ -245,20 +243,21 @@ export function useJob< return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { - if (subscription.status === 'completed' && subscription.output) { + const sub = subscriptionRef.current + if (sub.status === 'completed' && sub.output) { clearInterval(checkInterval) - resolve({ runId, output: subscription.output }) - } else if (subscription.status === 'failed') { + resolve({ runId, output: sub.output }) + } else if (sub.status === 'failed') { clearInterval(checkInterval) - reject(new Error(subscription.error ?? 'Job failed')) - } else if (subscription.status === 'cancelled') { + reject(new Error(sub.error ?? 'Job failed')) + } else if (sub.status === 'cancelled') { clearInterval(checkInterval) reject(new Error('Job cancelled')) } }, 50) }) }, - [trigger, subscription.status, subscription.output, subscription.error], + [trigger], ) const reset = useCallback(() => { diff --git a/packages/durably/src/durably.ts b/packages/durably/src/durably.ts index aff4934d..3d697b3c 100644 --- a/packages/durably/src/durably.ts +++ b/packages/durably/src/durably.ts @@ -347,107 +347,39 @@ function createDurablyInstance< let closed = false let cleanup: (() => void) | null = null + // Events that close the stream after enqueuing + const closeEvents = new Set(['run:complete', 'run:delete']) + // All event types to subscribe to for a run + const subscribedEvents: EventType[] = [ + 'run:start', + 'run:complete', + 'run:fail', + 'run:cancel', + 'run:delete', + 'run:retry', + 'run:progress', + 'step:start', + 'step:complete', + 'step:fail', + 'log:write', + ] + return new ReadableStream({ start: (controller) => { - const unsubscribeStart = eventEmitter.on('run:start', (event) => { - if (!closed && event.runId === runId) { + const unsubscribes = subscribedEvents.map((type) => + eventEmitter.on(type, (event) => { + if (closed || event.runId !== runId) return controller.enqueue(event) - } - }) - - const unsubscribeComplete = eventEmitter.on( - 'run:complete', - (event) => { - if (!closed && event.runId === runId) { - controller.enqueue(event) + if (closeEvents.has(type)) { 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 unsubscribeDelete = eventEmitter.on('run:delete', (event) => { - if (!closed && event.runId === runId) { - controller.enqueue(event) - closed = true - cleanup?.() - controller.close() - } - }) - - 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() - unsubscribeDelete() - unsubscribeRetry() - unsubscribeProgress() - unsubscribeStepStart() - unsubscribeStepComplete() - unsubscribeStepFail() - unsubscribeLog() + for (const unsub of unsubscribes) unsub() } }, cancel: () => { From 7f9b0c18c5fea511994d3695f3852b8c222ae2fc Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 22:51:04 +0900 Subject: [PATCH 04/15] fix: remove backward compat aliases, fix triggerAndWait leak, update docs - Remove deprecated createDurablyClient/DurablyClient/JobClient aliases - Fix triggerAndWait interval leak on unmount via waitIntervalRef - Rename test file to match source (create-durably-hooks.test.tsx) - Add jobs option to create-durably.md signature and options table - Fix SPA trigger example with incorrect labels parameter in llms.md - Add UseRunsClientOptions (realtime option) to fullstack useRuns docs - Fix stale "Browser-Complete/Server-Connected" terminology in types.md Co-Authored-By: Claude Opus 4.6 --- packages/durably-react/docs/llms.md | 24 +++++++++++-------- .../src/client/create-durably-hooks.ts | 11 --------- packages/durably-react/src/client/use-job.ts | 20 ++++++++++++++++ ...test.tsx => create-durably-hooks.test.tsx} | 0 website/api/create-durably.md | 21 ++++++++++++++++ website/api/durably-react/types.md | 2 +- website/public/llms.txt | 24 +++++++++++-------- 7 files changed, 70 insertions(+), 32 deletions(-) rename packages/durably-react/tests/client/{create-durably-client.test.tsx => create-durably-hooks.test.tsx} (100%) diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index 11e4be8a..c2ce4c93 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -181,7 +181,20 @@ function Component({ runId }: { runId: string }) { ### Fullstack useRuns -List runs with pagination and real-time updates: +List runs with pagination and real-time updates. + +**Options:** + +```ts +interface UseRunsClientOptions { + api: string // API endpoint URL (e.g., '/api/durably') + jobName?: string | string[] // Filter by job name(s) + status?: RunStatus // Filter by status + labels?: Record // Filter by labels (all must match) + pageSize?: number // Runs per page (default: 10) + realtime?: boolean // Subscribe to real-time updates via SSE (default: true) +} +``` ```tsx import { useRuns, TypedClientRun } from '@coji/durably-react' @@ -388,15 +401,6 @@ function Component() { console.log('Started:', runId) } - // Trigger with labels (for filtering) - const handleClickWithLabels = async () => { - const { runId } = await trigger( - { value: 'test' }, - { labels: { source: 'browser' } }, - ) - console.log('Started:', runId) - } - // Or trigger and wait for result const handleSync = async () => { const { runId, output } = await triggerAndWait({ value: 'test' }) diff --git a/packages/durably-react/src/client/create-durably-hooks.ts b/packages/durably-react/src/client/create-durably-hooks.ts index 4256f46d..787ac0e3 100644 --- a/packages/durably-react/src/client/create-durably-hooks.ts +++ b/packages/durably-react/src/client/create-durably-hooks.ts @@ -70,14 +70,3 @@ export function createDurablyHooks>( }, }) } - -// Backward compatibility re-exports -/** @deprecated Use `createDurablyHooks` instead */ -export const createDurablyClient = createDurablyHooks -/** @deprecated Use `CreateDurablyHooksOptions` instead */ -export type CreateDurablyClientOptions = CreateDurablyHooksOptions -/** @deprecated Use `DurablyHooks` instead */ -export type DurablyClient> = - DurablyHooks -/** @deprecated Use `JobHooks` instead */ -export type JobClient = JobHooks diff --git a/packages/durably-react/src/client/use-job.ts b/packages/durably-react/src/client/use-job.ts index 199f41b9..38dc3cfa 100644 --- a/packages/durably-react/src/client/use-job.ts +++ b/packages/durably-react/src/client/use-job.ts @@ -113,6 +113,7 @@ export function useJob< // Track if user has triggered a run (to prevent autoResume from overwriting) const hasUserTriggered = useRef(false) + const waitIntervalRef = useRef | null>(null) const subscription = useSSESubscription(api, currentRunId) @@ -242,24 +243,43 @@ export function useJob< const { runId } = await trigger(input) return new Promise((resolve, reject) => { + // Clear any previous wait interval + if (waitIntervalRef.current) { + clearInterval(waitIntervalRef.current) + } + const checkInterval = setInterval(() => { const sub = subscriptionRef.current if (sub.status === 'completed' && sub.output) { clearInterval(checkInterval) + waitIntervalRef.current = null resolve({ runId, output: sub.output }) } else if (sub.status === 'failed') { clearInterval(checkInterval) + waitIntervalRef.current = null reject(new Error(sub.error ?? 'Job failed')) } else if (sub.status === 'cancelled') { clearInterval(checkInterval) + waitIntervalRef.current = null reject(new Error('Job cancelled')) } }, 50) + + waitIntervalRef.current = checkInterval }) }, [trigger], ) + // Clean up wait interval on unmount + useEffect(() => { + return () => { + if (waitIntervalRef.current) { + clearInterval(waitIntervalRef.current) + } + } + }, []) + const reset = useCallback(() => { subscription.reset() setCurrentRunId(null) diff --git a/packages/durably-react/tests/client/create-durably-client.test.tsx b/packages/durably-react/tests/client/create-durably-hooks.test.tsx similarity index 100% rename from packages/durably-react/tests/client/create-durably-client.test.tsx rename to packages/durably-react/tests/client/create-durably-hooks.test.tsx diff --git a/website/api/create-durably.md b/website/api/create-durably.md index 6440a360..80b5c4c4 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -5,9 +5,15 @@ Creates a new Durably instance. ## Signature ```ts +// Without jobs (use .register() later) function createDurably( options: DurablyOptions, ): Durably<{}, TLabels> + +// With jobs (1-step initialization) +function createDurably( + options: DurablyOptions & { jobs: TJobs }, +): Durably, TLabels> ``` ## Options @@ -15,12 +21,14 @@ function createDurably( ```ts interface DurablyOptions< TLabels extends Record = Record, + TJobs extends Record = Record, > { dialect: Dialect pollingInterval?: number heartbeatInterval?: number staleThreshold?: number labels?: z.ZodType + jobs?: TJobs } ``` @@ -31,6 +39,7 @@ interface DurablyOptions< | `heartbeatInterval` | `number` | `5000` | How often to update heartbeat (ms) | | `staleThreshold` | `number` | `30000` | Time until a job is considered stale (ms) | | `labels` | `z.ZodType` | — | Zod schema for labels. Enables type-safe labels and runtime validation on `trigger()` | +| `jobs` | `TJobs` | — | Job definitions to register. Shorthand for calling `.register()` after creation | ## Returns @@ -78,6 +87,18 @@ durably.register>( 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. +::: tip +You can also pass `jobs` directly to `createDurably()` as a shorthand: + +```ts +const durably = createDurably({ + dialect, + jobs: { syncUsers: syncUsersJob, processImage: processImageJob }, +}) +``` + +::: + ```ts const { syncUsers, processImage } = durably.register({ syncUsers: syncUsersJob, diff --git a/website/api/durably-react/types.md b/website/api/durably-react/types.md index 7fbe9bbf..b5258d8d 100644 --- a/website/api/durably-react/types.md +++ b/website/api/durably-react/types.md @@ -1,6 +1,6 @@ # Type Definitions -Common types used across both Browser-Complete and Server-Connected modes. +Common types used across both SPA and Fullstack modes. ## RunStatus diff --git a/website/public/llms.txt b/website/public/llms.txt index a6ce1d44..093cab10 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -801,7 +801,20 @@ function Component({ runId }: { runId: string }) { ### Fullstack useRuns -List runs with pagination and real-time updates: +List runs with pagination and real-time updates. + +**Options:** + +```ts +interface UseRunsClientOptions { + api: string // API endpoint URL (e.g., '/api/durably') + jobName?: string | string[] // Filter by job name(s) + status?: RunStatus // Filter by status + labels?: Record // Filter by labels (all must match) + pageSize?: number // Runs per page (default: 10) + realtime?: boolean // Subscribe to real-time updates via SSE (default: true) +} +``` ```tsx import { useRuns, TypedClientRun } from '@coji/durably-react' @@ -1008,15 +1021,6 @@ function Component() { console.log('Started:', runId) } - // Trigger with labels (for filtering) - const handleClickWithLabels = async () => { - const { runId } = await trigger( - { value: 'test' }, - { labels: { source: 'browser' } }, - ) - console.log('Started:', runId) - } - // Or trigger and wait for result const handleSync = async () => { const { runId, output } = await triggerAndWait({ value: 'test' }) From 410cdc016218a0098febcc73c0796fada282ff18 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:26:38 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor:=20rename=20createDurablyHooks?= =?UTF-8?q?=20=E2=86=92=20createDurably,=20add=20built-in=20useRuns/useRun?= =?UTF-8?q?Actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `createDurablyHooks` to `createDurably` (mirrors server-side name, tRPC-inspired) - Add `useRuns` and `useRunActions` as built-in cross-job methods on DurablyClient Proxy - Add `ExtractJobs` type to support `createDurably()` - Convert fullstack example to use `createDurably` pattern throughout - Rename website docs: browser.md → spa.md, client.md → fullstack.md - Use framework-agnostic `durably.ts` in doc examples - Regenerate llms.txt Co-Authored-By: Claude Opus 4.6 --- .claude/skills/doc-check/SKILL.md | 36 ++--- .claude/skills/release-check/SKILL.md | 4 +- .../app/lib/durably.hooks.ts | 13 ++ .../app/routes/_index/dashboard.tsx | 15 +-- .../app/routes/_index/data-sync-progress.tsx | 9 +- .../_index/image-processing-progress.tsx | 9 +- packages/durably-react/README.md | 8 +- packages/durably-react/docs/llms.md | 10 +- .../src/client/create-durably-hooks.ts | 72 ---------- .../src/client/create-durably.ts | 126 ++++++++++++++++++ packages/durably-react/src/client/index.ts | 7 +- packages/durably-react/src/index.ts | 10 +- ...hooks.test.tsx => create-durably.test.tsx} | 16 +-- website/.vitepress/config.ts | 33 ++--- .../durably-react/{client.md => fullstack.md} | 14 +- website/api/durably-react/index.md | 10 +- .../api/durably-react/{browser.md => spa.md} | 0 website/api/index.md | 6 +- website/guide/csv-import.md | 8 +- website/guide/getting-started.md | 8 +- website/public/llms.txt | 10 +- 21 files changed, 238 insertions(+), 186 deletions(-) create mode 100644 examples/fullstack-react-router/app/lib/durably.hooks.ts delete mode 100644 packages/durably-react/src/client/create-durably-hooks.ts create mode 100644 packages/durably-react/src/client/create-durably.ts rename packages/durably-react/tests/client/{create-durably-hooks.test.tsx => create-durably.test.tsx} (89%) rename website/api/durably-react/{client.md => fullstack.md} (97%) rename website/api/durably-react/{browser.md => spa.md} (100%) diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md index 7fa7672e..34788630 100644 --- a/.claude/skills/doc-check/SKILL.md +++ b/.claude/skills/doc-check/SKILL.md @@ -43,12 +43,12 @@ These are the primary references for AI coding agents. ### React API -| File | Content | -| -------------------------------------- | ---------------------------------------------- | -| `website/api/durably-react/index.md` | React hooks overview | -| `website/api/durably-react/browser.md` | Browser-mode hooks (`useJob`, `useRuns`, etc.) | -| `website/api/durably-react/client.md` | Client-mode hooks (server-connected) | -| `website/api/durably-react/types.md` | Shared type definitions | +| File | Content | +| ---------------------------------------- | ------------------------------------- | +| `website/api/durably-react/index.md` | React hooks overview | +| `website/api/durably-react/spa.md` | SPA hooks (`useJob`, `useRuns`, etc.) | +| `website/api/durably-react/fullstack.md` | Fullstack hooks (server-connected) | +| `website/api/durably-react/types.md` | Shared type definitions | ### Guides (check if examples use changed API) @@ -85,18 +85,18 @@ pnpm --filter durably-website generate:llms Use this table to quickly determine which docs to check based on what changed: -| Change Type | Docs to Check | -| -------------------------------- | ------------------------------------------------------------------------------------------ | -| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react browser.md + client.md | -| New event field | llms.md (core), events.md, index.md | -| New step method | llms.md (core), step.md, index.md | -| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | -| React hook change | llms.md (react), browser.md, client.md, index.md (react section) | -| HTTP handler change | llms.md (core), http-handler.md, client.md | -| New config option | llms.md (core), create-durably.md, index.md | -| Job/step API change | All example apps (`examples/`) | -| Event type change | `examples/fullstack-react-router` (SSE), `examples/spa-*` (direct events) | -| React hook change | `examples/spa-vite-react`, `examples/spa-react-router`, `examples/fullstack-react-router` | +| Change Type | Docs to Check | +| -------------------------------- | ----------------------------------------------------------------------------------------- | +| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react spa.md + fullstack.md | +| New event field | llms.md (core), events.md, index.md | +| New step method | llms.md (core), step.md, index.md | +| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | +| React hook change | llms.md (react), spa.md, fullstack.md, index.md (react section) | +| HTTP handler change | llms.md (core), http-handler.md, fullstack.md | +| New config option | llms.md (core), create-durably.md, index.md | +| Job/step API change | All example apps (`examples/`) | +| Event type change | `examples/fullstack-react-router` (SSE), `examples/spa-*` (direct events) | +| React hook change | `examples/spa-vite-react`, `examples/spa-react-router`, `examples/fullstack-react-router` | ## 5. Common Oversights diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md index d32c23ac..3417f853 100644 --- a/.claude/skills/release-check/SKILL.md +++ b/.claude/skills/release-check/SKILL.md @@ -37,8 +37,8 @@ Verify package integrity for API changes and spec updates. - [ ] `packages/durably-react/docs/llms.md` - LLM docs (bundled in npm) - [ ] `website/api/durably-react/index.md` - Overview -- [ ] `website/api/durably-react/browser.md` - Browser hooks -- [ ] `website/api/durably-react/client.md` - Client hooks +- [ ] `website/api/durably-react/spa.md` - SPA hooks +- [ ] `website/api/durably-react/fullstack.md` - Fullstack hooks - [ ] `website/api/durably-react/types.md` - Type definitions ### Website diff --git a/examples/fullstack-react-router/app/lib/durably.hooks.ts b/examples/fullstack-react-router/app/lib/durably.hooks.ts new file mode 100644 index 00000000..2856297e --- /dev/null +++ b/examples/fullstack-react-router/app/lib/durably.hooks.ts @@ -0,0 +1,13 @@ +/** + * Durably Client Configuration + * + * Creates a type-safe Durably client for React components. + * Uses type-only import from the server — no server code is bundled. + */ + +import { createDurably } from '@coji/durably-react' +import type { durably as serverDurably } from './durably.server' + +export const durably = createDurably({ + api: '/api/durably', +}) diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index c20c846e..c57b7975 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -7,12 +7,7 @@ * Demonstrates typed useRuns with generic type parameter for multi-job dashboards. */ -import type { ClientRun, StepRecord } from '@coji/durably-react' -import { - type TypedClientRun, - useRunActions, - useRuns, -} from '@coji/durably-react' +import type { ClientRun, StepRecord, TypedClientRun } from '@coji/durably-react' import { useState } from 'react' import type { DataSyncInput, @@ -22,6 +17,7 @@ import type { ProcessImageInput, ProcessImageOutput, } from '~/jobs' +import { durably } from '~/lib/durably.hooks' /** Union type for all job runs in this dashboard */ type DashboardRun = @@ -48,8 +44,7 @@ function LabelChips({ labels }: { labels: Record }) { export function Dashboard() { const { runs, isLoading, error, page, hasMore, nextPage, prevPage, refresh } = - useRuns({ - api: '/api/durably', + durably.useRuns({ pageSize: 6, }) @@ -60,9 +55,7 @@ export function Dashboard() { getRun, getSteps, isLoading: isActioning, - } = useRunActions({ - api: '/api/durably', - }) + } = durably.useRunActions() const [selectedRun, setSelectedRun] = useState(null) const [steps, setSteps] = useState([]) 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 index 2ece5dce..d498f551 100644 --- a/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx @@ -1,11 +1,11 @@ /** * Data Sync Progress Component * - * Displays progress for the data sync job using useJobRun. + * Displays progress for the data sync job using the typed Durably client. */ -import { useJobRun } from '@coji/durably-react' import { useActionData } from 'react-router' +import { durably } from '~/lib/durably.hooks' import type { action } from '../_index' import { RunProgress } from './run-progress' @@ -23,10 +23,7 @@ export function DataSyncProgress() { isCompleted, isFailed, isCancelled, - } = useJobRun({ - api: '/api/durably', - runId, - }) + } = durably.dataSync.useRun(runId) return ( ({ +export const durably = createDurably({ api: '/api/durably', }) @@ -92,8 +92,8 @@ function MyComponent() { For full documentation, visit [coji.github.io/durably](https://coji.github.io/durably/). -- [SPA Hooks](https://coji.github.io/durably/api/durably-react/browser) - Browser mode with OPFS -- [Fullstack Hooks](https://coji.github.io/durably/api/durably-react/client) - Server-connected mode +- [SPA Hooks](https://coji.github.io/durably/api/durably-react/spa) - Browser mode with OPFS +- [Fullstack Hooks](https://coji.github.io/durably/api/durably-react/fullstack) - Server-connected mode ## License diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index c2ce4c93..3a1b7879 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -27,7 +27,7 @@ pnpm add @coji/durably-react @coji/durably kysely zod sqlocal Import from `@coji/durably-react` (root) for server-connected mode. -### createDurablyHooks +### createDurably Create a type-safe hooks factory for all registered jobs. @@ -49,11 +49,11 @@ export const durablyHandler = createDurablyHandler(durably, { await durably.init() -// Client: create typed hooks (app/lib/durably.client.ts) +// Client: create typed hooks (app/lib/durably.ts) import type { durably } from '~/lib/durably.server' -import { createDurablyHooks } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' -export const durably = createDurablyHooks({ +export const durably = createDurably({ api: '/api/durably', }) @@ -83,7 +83,7 @@ function LogViewer({ runId }: { runId: string }) { ### Fullstack useJob -Direct hook when not using `createDurablyHooks`: +Direct hook when not using `createDurably`: ```tsx import { useJob } from '@coji/durably-react' diff --git a/packages/durably-react/src/client/create-durably-hooks.ts b/packages/durably-react/src/client/create-durably-hooks.ts deleted file mode 100644 index 787ac0e3..00000000 --- a/packages/durably-react/src/client/create-durably-hooks.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { InferInput, InferOutput } from '../types' -import { createJobHooks, type JobHooks } from './create-job-hooks' - -/** - * Options for createDurablyHooks - */ -export interface CreateDurablyHooksOptions { - /** - * API endpoint URL (e.g., '/api/durably') - */ - api: string -} - -/** - * A type-safe hooks collection for each registered job - */ -export type DurablyHooks> = { - [K in keyof TJobs]: JobHooks, InferOutput> -} - -/** - * Create type-safe hooks for all registered jobs. - * - * @example - * ```tsx - * // Server: register jobs - * // app/lib/durably.server.ts - * export const durably = createDurably({ - * dialect, - * jobs: { importCsv: importCsvJob, syncUsers: syncUsersJob }, - * }) - * - * // Client: create typed hooks - * // app/lib/durably.hooks.ts - * import type { durably } from '~/lib/durably.server' - * import { createDurablyHooks } from '@coji/durably-react' - * - * export const durably = createDurablyHooks({ - * api: '/api/durably', - * }) - * - * // In your component - fully type-safe with autocomplete - * function CsvImporter() { - * const { trigger, output, isRunning } = durably.importCsv.useJob() - * - * return ( - * - * ) - * } - * ``` - */ -export function createDurablyHooks>( - options: CreateDurablyHooksOptions, -): DurablyHooks { - const { api } = options - const cache = new Map>() - - // Create a proxy that generates and caches job hooks on demand - return new Proxy({} as DurablyHooks, { - get(_target, jobKey) { - if (typeof jobKey !== 'string') return undefined - let hooks = cache.get(jobKey) - if (!hooks) { - hooks = createJobHooks({ api, jobName: jobKey }) - cache.set(jobKey, hooks) - } - return hooks - }, - }) -} diff --git a/packages/durably-react/src/client/create-durably.ts b/packages/durably-react/src/client/create-durably.ts new file mode 100644 index 00000000..2d9bc39e --- /dev/null +++ b/packages/durably-react/src/client/create-durably.ts @@ -0,0 +1,126 @@ +import type { InferInput, InferOutput } from '../types' +import { createJobHooks, type JobHooks } from './create-job-hooks' +import { + useRunActions, + type UseRunActionsClientResult, +} from './use-run-actions' +import { + useRuns, + type UseRunsClientOptions, + type UseRunsClientResult, +} from './use-runs' + +/** + * Options for createDurably + */ +export interface CreateDurablyOptions { + /** + * API endpoint URL (e.g., '/api/durably') + */ + api: string +} + +/** + * Extract the jobs record from a Durably instance type. + * Allows `createDurably()` to infer job types. + */ +type ExtractJobs = T extends { readonly jobs: infer TJobs } ? TJobs : T + +/** + * A type-safe Durably client with per-job hooks and cross-job utilities. + */ +export type DurablyClient = { + [K in keyof ExtractJobs]: JobHooks< + InferInput[K]>, + InferOutput[K]> + > +} & { + /** + * List runs with pagination and real-time updates (cross-job). + * The `api` option is pre-configured. + */ + useRuns: < + TInput extends Record = Record, + TOutput extends Record | undefined = + | Record + | undefined, + >( + options?: Omit, + ) => UseRunsClientResult + + /** + * Run actions: retry, cancel, delete, getRun, getSteps (cross-job). + * The `api` option is pre-configured. + */ + useRunActions: () => UseRunActionsClientResult +} + +/** + * Create a type-safe Durably client for React. + * + * Uses the same name as the server-side `createDurably` — the API endpoint + * option distinguishes it from the server constructor. + * + * @example + * ```tsx + * // Server: create Durably instance + * // app/lib/durably.server.ts + * import { createDurably } from '@coji/durably' + * export const durably = createDurably({ + * dialect, + * jobs: { importCsv: importCsvJob, syncUsers: syncUsersJob }, + * }) + * + * // Client: create typed hooks + * // app/lib/durably.ts + * import type { durably as serverDurably } from '~/lib/durably.server' + * import { createDurably } from '@coji/durably-react' + * + * export const durably = createDurably({ + * api: '/api/durably', + * }) + * + * // In your component — fully type-safe with autocomplete + * function CsvImporter() { + * const { trigger, output, isRunning } = durably.importCsv.useJob() + * return + * } + * + * // Cross-job hooks + * function Dashboard() { + * const { runs, nextPage } = durably.useRuns({ pageSize: 10 }) + * const { retry, cancel } = durably.useRunActions() + * } + * ``` + */ +export function createDurably( + options: CreateDurablyOptions, +): DurablyClient { + const { api } = options + const cache = new Map() + + // Built-in cross-job hooks (keyed by reserved names) + const builtins: Record = { + useRuns: (opts?: Omit) => + useRuns({ api, ...opts }), + useRunActions: () => useRunActions({ api }), + } + + // Create a proxy that generates and caches job hooks on demand + return new Proxy({} as DurablyClient, { + get(_target, key) { + if (typeof key !== 'string') return undefined + + // Return built-in hooks first + if (key in builtins) return builtins[key] + + // Return cached or create new job hooks + let hooks = cache.get(key) + if (!hooks) { + hooks = createJobHooks({ api, jobName: key }) + cache.set(key, hooks) + } + return hooks + }, + }) +} diff --git a/packages/durably-react/src/client/index.ts b/packages/durably-react/src/client/index.ts index ce385eec..46f7a7c8 100644 --- a/packages/durably-react/src/client/index.ts +++ b/packages/durably-react/src/client/index.ts @@ -3,11 +3,8 @@ * Public API is exported via root (index.ts) for fullstack mode */ -export { createDurablyHooks } from './create-durably-hooks' -export type { - CreateDurablyHooksOptions, - DurablyHooks, -} from './create-durably-hooks' +export { createDurably } from './create-durably' +export type { CreateDurablyOptions, DurablyClient } from './create-durably' export { createJobHooks } from './create-job-hooks' export type { CreateJobHooksOptions, JobHooks } from './create-job-hooks' diff --git a/packages/durably-react/src/index.ts b/packages/durably-react/src/index.ts index 0aae7196..c7e17916 100644 --- a/packages/durably-react/src/index.ts +++ b/packages/durably-react/src/index.ts @@ -4,12 +4,12 @@ // For SPA/offline mode (browser-only with OPFS), use: // @coji/durably-react/spa -// Type-safe hooks factory -export { createDurablyHooks } from './client/create-durably-hooks' +// Type-safe client factory +export { createDurably } from './client/create-durably' export type { - CreateDurablyHooksOptions, - DurablyHooks, -} from './client/create-durably-hooks' + CreateDurablyOptions, + DurablyClient, +} from './client/create-durably' export { createJobHooks } from './client/create-job-hooks' export type { CreateJobHooksOptions, JobHooks } from './client/create-job-hooks' diff --git a/packages/durably-react/tests/client/create-durably-hooks.test.tsx b/packages/durably-react/tests/client/create-durably.test.tsx similarity index 89% rename from packages/durably-react/tests/client/create-durably-hooks.test.tsx rename to packages/durably-react/tests/client/create-durably.test.tsx index 654cfc73..12f693c5 100644 --- a/packages/durably-react/tests/client/create-durably-hooks.test.tsx +++ b/packages/durably-react/tests/client/create-durably.test.tsx @@ -1,5 +1,5 @@ /** - * createDurablyHooks Tests + * createDurably Tests * * Test the type-safe client factory. * Note: Hook behavior (SSE subscription, logs, etc.) is tested in the individual hook tests. @@ -8,7 +8,7 @@ import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createDurablyHooks } from '../../src/client/create-durably-hooks' +import { createDurably } from '../../src/client/create-durably' import { createMockEventSource, type MockEventSourceConstructor, @@ -29,7 +29,7 @@ type MockJobs = { } } -describe('createDurablyHooks', () => { +describe('createDurably', () => { let mockEventSource: MockEventSourceConstructor let originalEventSource: typeof EventSource let originalFetch: typeof fetch @@ -48,7 +48,7 @@ describe('createDurablyHooks', () => { }) it('creates a client with job accessors via proxy', () => { - const client = createDurablyHooks({ api: '/api/durably' }) + const client = createDurably({ api: '/api/durably' }) // Verify the proxy creates job clients on access expect(client.importCsv).toBeDefined() @@ -75,7 +75,7 @@ describe('createDurablyHooks', () => { }) globalThis.fetch = fetchMock - const client = createDurablyHooks({ api: '/api/durably' }) + const client = createDurably({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useJob()) await result.current.trigger({ filename: 'data.csv' }) @@ -109,7 +109,7 @@ describe('createDurablyHooks', () => { }) globalThis.fetch = fetchMock - const client = createDurablyHooks({ api: '/api/durably' }) + const client = createDurably({ api: '/api/durably' }) // Trigger via importCsv const { result: importResult } = renderHook(() => client.importCsv.useJob()) @@ -135,7 +135,7 @@ describe('createDurablyHooks', () => { }) it('useRun returns a hook function', () => { - const client = createDurablyHooks({ api: '/api/durably' }) + const client = createDurably({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useRun(null)) @@ -144,7 +144,7 @@ describe('createDurablyHooks', () => { }) it('useLogs returns a hook function', () => { - const client = createDurablyHooks({ api: '/api/durably' }) + const client = createDurably({ api: '/api/durably' }) const { result } = renderHook(() => client.importCsv.useLogs(null)) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index a41915d9..6b50a9e1 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -177,51 +177,54 @@ export default defineConfig({ { text: 'Overview', link: '/api/durably-react/' }, { text: 'SPA Hooks', - link: '/api/durably-react/browser', + link: '/api/durably-react/spa', collapsed: false, items: [ { text: 'DurablyProvider', - link: '/api/durably-react/browser#durablyprovider', + link: '/api/durably-react/spa#durablyprovider', }, { text: 'useDurably', - link: '/api/durably-react/browser#usedurably', + link: '/api/durably-react/spa#usedurably', }, - { text: 'useJob', link: '/api/durably-react/browser#usejob' }, + { text: 'useJob', link: '/api/durably-react/spa#usejob' }, { text: 'useJobRun', - link: '/api/durably-react/browser#usejobrun', + link: '/api/durably-react/spa#usejobrun', }, { text: 'useJobLogs', - link: '/api/durably-react/browser#usejoblogs', + link: '/api/durably-react/spa#usejoblogs', }, - { text: 'useRuns', link: '/api/durably-react/browser#useruns' }, + { text: 'useRuns', link: '/api/durably-react/spa#useruns' }, ], }, { text: 'Fullstack Hooks', - link: '/api/durably-react/client', + link: '/api/durably-react/fullstack', collapsed: false, items: [ { - text: 'createDurablyHooks', - link: '/api/durably-react/client#createdurablyhooks', + text: 'createDurably', + link: '/api/durably-react/fullstack#createdurably', }, - { text: 'useJob', link: '/api/durably-react/client#usejob' }, + { text: 'useJob', link: '/api/durably-react/fullstack#usejob' }, { text: 'useJobRun', - link: '/api/durably-react/client#usejobrun', + link: '/api/durably-react/fullstack#usejobrun', }, { text: 'useJobLogs', - link: '/api/durably-react/client#usejoblogs', + link: '/api/durably-react/fullstack#usejoblogs', + }, + { + text: 'useRuns', + link: '/api/durably-react/fullstack#useruns', }, - { text: 'useRuns', link: '/api/durably-react/client#useruns' }, { text: 'useRunActions', - link: '/api/durably-react/client#userunactions', + link: '/api/durably-react/fullstack#userunactions', }, ], }, diff --git a/website/api/durably-react/client.md b/website/api/durably-react/fullstack.md similarity index 97% rename from website/api/durably-react/client.md rename to website/api/durably-react/fullstack.md index f8bee8f9..b9501bed 100644 --- a/website/api/durably-react/client.md +++ b/website/api/durably-react/fullstack.md @@ -47,11 +47,9 @@ export async function action({ request }: Route.ActionArgs) { } ``` -## Two Ways to Use +## Hooks directly -### 1. Hooks directly (works everywhere, including SSR) - -Pass `api` and `jobName` to each hook. Works with any setup. +Pass `api` and `jobName` to each hook. Works with any setup, including SSR. ```tsx import { useJob } from '@coji/durably-react' @@ -76,16 +74,16 @@ function CsvImporter() { } ``` -### 2. createDurablyHooks (type-safe) +## createDurably Wraps hooks with a type-safe Proxy — autocomplete for job names, inferred input/output types. ```ts -// app/lib/durably.client.ts -import { createDurablyHooks } from '@coji/durably-react' +// app/lib/durably.ts +import { createDurably } from '@coji/durably-react' import type { durably } from './durably.server' -export const durably = createDurablyHooks({ +export const durably = createDurably({ api: '/api/durably', }) ``` diff --git a/website/api/durably-react/index.md b/website/api/durably-react/index.md index c436283f..9e0083f0 100644 --- a/website/api/durably-react/index.md +++ b/website/api/durably-react/index.md @@ -26,10 +26,10 @@ Durably React provides two modes for different architectures: - Need persistent storage across devices ```tsx -import { createDurablyHooks } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' ``` -[Fullstack Hooks Reference →](./client) +[Fullstack Hooks Reference →](./fullstack) ### Choose SPA Hooks when: @@ -42,7 +42,7 @@ import { createDurablyHooks } from '@coji/durably-react' import { DurablyProvider, useJob } from '@coji/durably-react/spa' ``` -[SPA Hooks Reference →](./browser) +[SPA Hooks Reference →](./spa) ## Installation @@ -62,10 +62,10 @@ Jobs run on the server, with real-time updates via SSE. ```tsx // 1. Create type-safe hooks (client-side file) -import { createDurablyHooks } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' import type { durably } from './durably.server' -export const durably = createDurablyHooks({ +export const durably = createDurably({ api: '/api/durably', }) diff --git a/website/api/durably-react/browser.md b/website/api/durably-react/spa.md similarity index 100% rename from website/api/durably-react/browser.md rename to website/api/durably-react/spa.md diff --git a/website/api/index.md b/website/api/index.md index 12c98c75..08ca5d49 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -141,10 +141,10 @@ Connect to a Durably server via HTTP/SSE. ```tsx // 1. Create type-safe hooks -import { createDurablyHooks } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' import type { durably } from './durably.server' -const durably = createDurablyHooks({ +const durably = createDurably({ api: '/api/durably', }) @@ -195,7 +195,7 @@ function ImportButton() { } ``` -**See:** [React Hooks Overview](/api/durably-react/) | [SPA Hooks](/api/durably-react/browser) | [Fullstack Hooks](/api/durably-react/client) +**See:** [React Hooks Overview](/api/durably-react/) | [SPA Hooks](/api/durably-react/spa) | [Fullstack Hooks](/api/durably-react/fullstack) ## API at a Glance diff --git a/website/guide/csv-import.md b/website/guide/csv-import.md index d56790b0..8a748b57 100644 --- a/website/guide/csv-import.md +++ b/website/guide/csv-import.md @@ -23,7 +23,7 @@ app/ │ └── import-csv.ts # Job definition ├── lib/ │ ├── durably.server.ts # Durably instance -│ └── durably.client.ts # Type-safe hooks +│ └── durably.ts # Type-safe hooks ├── routes/ │ ├── api.durably.$.ts # Splat route for all API │ └── _index.tsx # UI @@ -170,11 +170,11 @@ export async function action({ request }: Route.ActionArgs) { 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 { createDurablyHooks } from '@coji/durably-react' +// app/lib/durably.ts +import { createDurably } from '@coji/durably-react' import type { durably } from './durably.server' -export const durablyClient = createDurablyHooks({ +export const durablyClient = createDurably({ api: '/api/durably', }) ``` diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index dbf59a27..22e02489 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -109,12 +109,12 @@ export async function action({ request }: Route.ActionArgs) { Create a type-safe client using the server's Durably type. This gives you full type inference for job inputs and outputs. ```ts -// app/lib/durably.client.ts -import { createDurablyHooks } from '@coji/durably-react' +// app/lib/durably.ts +import { createDurably } from '@coji/durably-react' // Type-only import: no server code is bundled, just TypeScript types import type { durably } from './durably.server' -export const durablyClient = createDurablyHooks({ +export const durablyClient = createDurably({ api: '/api/durably', }) ``` @@ -130,7 +130,7 @@ Build the UI with real-time progress updates. // app/routes/_index.tsx import { Form } from 'react-router' import { durably } from '~/lib/durably.server' -import { durablyClient } from '~/lib/durably.client' +import { durablyClient } from '~/lib/durably' import type { Route } from './+types/_index' // Server: trigger job on form submit diff --git a/website/public/llms.txt b/website/public/llms.txt index 093cab10..67441aae 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -647,7 +647,7 @@ pnpm add @coji/durably-react @coji/durably kysely zod sqlocal Import from `@coji/durably-react` (root) for server-connected mode. -### createDurablyHooks +### createDurably Create a type-safe hooks factory for all registered jobs. @@ -669,11 +669,11 @@ export const durablyHandler = createDurablyHandler(durably, { await durably.init() -// Client: create typed hooks (app/lib/durably.client.ts) +// Client: create typed hooks (app/lib/durably.ts) import type { durably } from '~/lib/durably.server' -import { createDurablyHooks } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' -export const durably = createDurablyHooks({ +export const durably = createDurably({ api: '/api/durably', }) @@ -703,7 +703,7 @@ function LogViewer({ runId }: { runId: string }) { ### Fullstack useJob -Direct hook when not using `createDurablyHooks`: +Direct hook when not using `createDurably`: ```tsx import { useJob } from '@coji/durably-react' From 102d5e9a3bb6062f06a240d295c73b7758cfceb8 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:29:10 +0900 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20rename=20durably.hooks.ts=20?= =?UTF-8?q?=E2=86=92=20durably.ts=20in=20fullstack=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the framework-agnostic file name used in documentation. The `.hooks.ts` suffix was unnecessary — only `.client.ts` triggers React Router's client-only behavior. Co-Authored-By: Claude Opus 4.6 --- .../app/lib/{durably.hooks.ts => durably.ts} | 0 examples/fullstack-react-router/app/routes/_index/dashboard.tsx | 2 +- .../app/routes/_index/data-sync-progress.tsx | 2 +- .../app/routes/_index/image-processing-progress.tsx | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename examples/fullstack-react-router/app/lib/{durably.hooks.ts => durably.ts} (100%) diff --git a/examples/fullstack-react-router/app/lib/durably.hooks.ts b/examples/fullstack-react-router/app/lib/durably.ts similarity index 100% rename from examples/fullstack-react-router/app/lib/durably.hooks.ts rename to examples/fullstack-react-router/app/lib/durably.ts diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index c57b7975..ed8dcbe9 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -17,7 +17,7 @@ import type { ProcessImageInput, ProcessImageOutput, } from '~/jobs' -import { durably } from '~/lib/durably.hooks' +import { durably } from '~/lib/durably' /** Union type for all job runs in this dashboard */ type DashboardRun = 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 index d498f551..fbfbab22 100644 --- a/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/data-sync-progress.tsx @@ -5,7 +5,7 @@ */ import { useActionData } from 'react-router' -import { durably } from '~/lib/durably.hooks' +import { durably } from '~/lib/durably' import type { action } from '../_index' import { RunProgress } from './run-progress' 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 index 2bf5664e..2d18c8b4 100644 --- a/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx +++ b/examples/fullstack-react-router/app/routes/_index/image-processing-progress.tsx @@ -5,7 +5,7 @@ */ import { useActionData } from 'react-router' -import { durably } from '~/lib/durably.hooks' +import { durably } from '~/lib/durably' import type { action } from '../_index' import { RunProgress } from './run-progress' From 289c6d905848cc7cad743d379b5113820d5e6964 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:39:03 +0900 Subject: [PATCH 07/15] =?UTF-8?q?docs:=20full=20website=20update=20?= =?UTF-8?q?=E2=80=94=20init(),=20jobs=20option,=20createDurably=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `jobs: {}` option instead of `.register()` chain in all guides - Use `await durably.init()` instead of `migrate()` + `start()` - Rewrite fullstack.md: createDurably section first with per-job and cross-job hook examples (durably.useRuns(), durably.useRunActions()) - Update spa.md: jobs option, init() - Update index.md: Dashboard example uses durably.useRuns() - Update sidebar: "CSV Import (Fullstack)", "Offline App (SPA)" - Update concepts.md, background-sync.md to use jobs option Co-Authored-By: Claude Opus 4.6 --- website/.vitepress/config.ts | 8 +- website/api/durably-react/fullstack.md | 101 +++++++++++++++++-------- website/api/durably-react/index.md | 5 +- website/api/durably-react/spa.md | 7 +- website/api/index.md | 3 +- website/guide/background-sync.md | 3 +- website/guide/concepts.md | 7 +- website/guide/csv-import.md | 5 +- website/guide/getting-started.md | 5 +- website/guide/offline-app.md | 3 +- 10 files changed, 96 insertions(+), 51 deletions(-) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 6b50a9e1..3518424d 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -65,8 +65,8 @@ export default defineConfig({ { text: 'Use Cases', items: [ - { text: 'CSV Import (Full-Stack)', link: '/guide/csv-import' }, - { text: 'Offline App (Browser)', link: '/guide/offline-app' }, + { text: 'CSV Import (Fullstack)', link: '/guide/csv-import' }, + { text: 'Offline App (SPA)', link: '/guide/offline-app' }, { text: 'Background Sync (Server)', link: '/guide/background-sync', @@ -209,6 +209,10 @@ export default defineConfig({ text: 'createDurably', link: '/api/durably-react/fullstack#createdurably', }, + { + text: 'Hooks directly', + link: '/api/durably-react/fullstack#hooks-directly', + }, { text: 'useJob', link: '/api/durably-react/fullstack#usejob' }, { text: 'useJobRun', diff --git a/website/api/durably-react/fullstack.md b/website/api/durably-react/fullstack.md index b9501bed..dce796c3 100644 --- a/website/api/durably-react/fullstack.md +++ b/website/api/durably-react/fullstack.md @@ -3,7 +3,7 @@ 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 { useJob, useRuns, useRunActions } from '@coji/durably-react' +import { createDurably } from '@coji/durably-react' ``` ## Server Setup @@ -29,8 +29,7 @@ export const durably = createDurably({ export const durablyHandler = createDurablyHandler(durably) -await durably.migrate() -durably.start() +await durably.init() ``` ```ts @@ -47,36 +46,9 @@ export async function action({ request }: Route.ActionArgs) { } ``` -## Hooks directly - -Pass `api` and `jobName` to each hook. Works with any setup, including SSR. - -```tsx -import { useJob } from '@coji/durably-react' - -function CsvImporter() { - const { trigger, status, output, isRunning } = useJob< - { filename: string }, - { count: number } - >({ - api: '/api/durably', - jobName: 'import-csv', - }) - - return ( - - ) -} -``` - ## createDurably -Wraps hooks with a type-safe Proxy — autocomplete for job names, inferred input/output types. +Create a type-safe client with a Proxy — autocomplete for job names, inferred input/output types, and built-in cross-job hooks. ```ts // app/lib/durably.ts @@ -88,7 +60,12 @@ export const durably = createDurably({ }) ``` +### Per-job hooks + +Each registered job gets `useJob`, `useRun`, and `useLogs` hooks with full type inference: + ```tsx +// Trigger and monitor a job function CsvImporter() { const { trigger, status, output, isRunning } = durably.importCsv.useJob() @@ -112,6 +89,68 @@ function LogViewer({ runId }: { runId: string }) { } ``` +### Cross-job hooks + +`useRuns` and `useRunActions` are built into the client — no need to import separately: + +```tsx +function Dashboard() { + const { runs, nextPage, hasMore } = durably.useRuns({ pageSize: 10 }) + const { retry, cancel, deleteRun } = durably.useRunActions() + + return ( +
    JobStatusActions
    JobStatusActions
    {run.jobName} {run.status} - {run.status === 'failed' && } - {run.status === 'running' && } + {run.status === 'failed' && ( + + )} + {run.status === 'running' && ( + + )}
    + + {runs.map((run) => ( + + + + + + ))} + +
    {run.jobName}{run.status} + {run.status === 'failed' && ( + + )} + {run.status === 'running' && ( + + )} + +
    + ) +} +``` + +--- + +## Hooks directly + +Alternatively, pass `api` and `jobName` to each hook. Works with any setup, including SSR. + +```tsx +import { useJob } from '@coji/durably-react' + +function CsvImporter() { + const { trigger, status, output, isRunning } = useJob< + { filename: string }, + { count: number } + >({ + api: '/api/durably', + jobName: 'import-csv', + }) + + return ( + + ) +} +``` + --- ## useJob diff --git a/website/api/durably-react/index.md b/website/api/durably-react/index.md index 9e0083f0..657f1368 100644 --- a/website/api/durably-react/index.md +++ b/website/api/durably-react/index.md @@ -202,11 +202,10 @@ function JobRunner() { ```tsx function Dashboard() { - const { runs, page, hasMore, nextPage, prevPage } = useRuns({ - api: '/api/durably', + const { runs, page, hasMore, nextPage, prevPage } = durably.useRuns({ pageSize: 10, }) - const { retry, cancel, deleteRun } = useRunActions({ api: '/api/durably' }) + const { retry, cancel, deleteRun } = durably.useRunActions() return ( diff --git a/website/api/durably-react/spa.md b/website/api/durably-react/spa.md index 68def04b..94348a80 100644 --- a/website/api/durably-react/spa.md +++ b/website/api/durably-react/spa.md @@ -27,11 +27,12 @@ const sqlocal = new SQLocalKysely('app.sqlite3') const durably = createDurably({ dialect: sqlocal.dialect, pollingInterval: 100, -}).register({ - myJob: myJobDef, + jobs: { + myJob: myJobDef, + }, }) -await durably.migrate() +await durably.init() function App() { return ( diff --git a/website/api/index.md b/website/api/index.md index 08ca5d49..60372eb6 100644 --- a/website/api/index.md +++ b/website/api/index.md @@ -66,8 +66,7 @@ const durably = createDurably({ heartbeatInterval: 5000, // Heartbeat every 5s staleThreshold: 30000, // Stale after 30s // labels: z.object({ ... }), // Optional: type-safe labels -}).register({ - importCsv: importCsvJob, + jobs: { importCsv: importCsvJob }, }) await durably.init() // Migrate DB + start worker diff --git a/website/guide/background-sync.md b/website/guide/background-sync.md index dbd280e3..e195569a 100644 --- a/website/guide/background-sync.md +++ b/website/guide/background-sync.md @@ -98,8 +98,7 @@ export const durably = createDurably({ pollingInterval: 100, heartbeatInterval: 500, staleThreshold: 3000, -}).register({ - processImage: processImageJob, + jobs: { processImage: processImageJob }, }) ``` diff --git a/website/guide/concepts.md b/website/guide/concepts.md index b0353a5d..855475d5 100644 --- a/website/guide/concepts.md +++ b/website/guide/concepts.md @@ -4,7 +4,7 @@ Deep dive into Durably's architecture and behavior. ## Jobs -Jobs are defined with `defineJob()` and registered with `durably.register()`: +Jobs are defined with `defineJob()` and registered via the `jobs` option: ```ts const myJob = defineJob({ @@ -17,7 +17,10 @@ const myJob = defineJob({ }, }) -const { myJob: job } = durably.register({ myJob }) +const durably = createDurably({ + dialect, + jobs: { myJob }, +}) ``` | Option | Required | Description | diff --git a/website/guide/csv-import.md b/website/guide/csv-import.md index 8a748b57..e8049c59 100644 --- a/website/guide/csv-import.md +++ b/website/guide/csv-import.md @@ -138,8 +138,9 @@ 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 durably = createDurably({ + dialect, + jobs: { importCsv: importCsvJob }, }) export const durablyHandler = createDurablyHandler(durably) diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 22e02489..01bf0b66 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -73,8 +73,9 @@ 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 durably = createDurably({ + dialect, + jobs: { importCsv: importCsvJob }, }) export const durablyHandler = createDurablyHandler(durably) diff --git a/website/guide/offline-app.md b/website/guide/offline-app.md index 11eecc9e..6e089547 100644 --- a/website/guide/offline-app.md +++ b/website/guide/offline-app.md @@ -131,8 +131,7 @@ const durably = createDurably({ 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, + jobs: { dataSync: dataSyncJob }, }) await durably.init() From d5216b62842502de8d283cbaea70f1e3fac541c3 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:42:00 +0900 Subject: [PATCH 08/15] =?UTF-8?q?docs:=20reorder=20sidebar=20=E2=80=94=20F?= =?UTF-8?q?ullstack=20before=20SPA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fullstack is the primary use case; SPA (OPFS) is specialized. Reorder both Guide and API sidebars accordingly. Co-Authored-By: Claude Opus 4.6 --- website/.vitepress/config.ts | 52 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 3518424d..859f32b9 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -66,11 +66,11 @@ export default defineConfig({ text: 'Use Cases', items: [ { text: 'CSV Import (Fullstack)', link: '/guide/csv-import' }, - { text: 'Offline App (SPA)', link: '/guide/offline-app' }, { text: 'Background Sync (Server)', link: '/guide/background-sync', }, + { text: 'Offline App (SPA)', link: '/guide/offline-app' }, ], }, ], @@ -175,31 +175,6 @@ export default defineConfig({ text: 'React Hooks', items: [ { text: 'Overview', link: '/api/durably-react/' }, - { - text: 'SPA Hooks', - link: '/api/durably-react/spa', - collapsed: false, - items: [ - { - text: 'DurablyProvider', - link: '/api/durably-react/spa#durablyprovider', - }, - { - text: 'useDurably', - link: '/api/durably-react/spa#usedurably', - }, - { text: 'useJob', link: '/api/durably-react/spa#usejob' }, - { - text: 'useJobRun', - link: '/api/durably-react/spa#usejobrun', - }, - { - text: 'useJobLogs', - link: '/api/durably-react/spa#usejoblogs', - }, - { text: 'useRuns', link: '/api/durably-react/spa#useruns' }, - ], - }, { text: 'Fullstack Hooks', link: '/api/durably-react/fullstack', @@ -232,6 +207,31 @@ export default defineConfig({ }, ], }, + { + text: 'SPA Hooks', + link: '/api/durably-react/spa', + collapsed: false, + items: [ + { + text: 'DurablyProvider', + link: '/api/durably-react/spa#durablyprovider', + }, + { + text: 'useDurably', + link: '/api/durably-react/spa#usedurably', + }, + { text: 'useJob', link: '/api/durably-react/spa#usejob' }, + { + text: 'useJobRun', + link: '/api/durably-react/spa#usejobrun', + }, + { + text: 'useJobLogs', + link: '/api/durably-react/spa#usejoblogs', + }, + { text: 'useRuns', link: '/api/durably-react/spa#useruns' }, + ], + }, { text: 'Type Definitions', link: '/api/durably-react/types' }, ], }, From 599029ba6fb8d4cafb8ef85ddc8baeb2a33a91c3 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:44:33 +0900 Subject: [PATCH 09/15] =?UTF-8?q?docs:=20add=20missing=20sidebar=20entries?= =?UTF-8?q?=20=E2=80=94=20Auth=20Middleware,=20deleteRun,=20subscribe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important sections that existed in page content but were missing from sidebar navigation: - HTTP Handler: Auth Middleware (replaces generic "Security") - createDurably: deleteRun, subscribe Co-Authored-By: Claude Opus 4.6 --- website/.vitepress/config.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 859f32b9..7fbcd93a 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -123,10 +123,18 @@ export default defineConfig({ { text: 'on (events)', link: '/api/create-durably#on' }, { text: 'stop', link: '/api/create-durably#stop' }, { text: 'retry / cancel', link: '/api/create-durably#retry' }, + { + text: 'deleteRun', + link: '/api/create-durably#deleterun', + }, { text: 'getRun / getRuns', link: '/api/create-durably#getrun', }, + { + text: 'subscribe', + link: '/api/create-durably#subscribe', + }, ], }, { @@ -164,8 +172,8 @@ export default defineConfig({ link: '/api/http-handler#sse-event-stream', }, { - text: 'Security', - link: '/api/http-handler#security-considerations', + text: 'Auth Middleware', + link: '/api/http-handler#auth-middleware', }, ], }, From 017f3a15aa1903762ef7e918dba33ba5f149c7bc Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:46:33 +0900 Subject: [PATCH 10/15] =?UTF-8?q?docs:=20update=20core=20llms.md=20?= =?UTF-8?q?=E2=80=94=20use=20jobs=20option=20in=20browser=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/durably/docs/llms.md | 20 +++++++++----------- website/public/llms.txt | 20 +++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md index 41665c8e..46f5cbcf 100644 --- a/packages/durably/docs/llms.md +++ b/packages/durably/docs/llms.md @@ -461,17 +461,15 @@ const durably = createDurably({ 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) => { - /* ... */ - }, - }), + jobs: { + myJob: defineJob({ + name: 'my-job', + input: z.object({}), + run: async (step) => { + /* ... */ + }, + }), + }, }) // Initialize (same as Node.js) diff --git a/website/public/llms.txt b/website/public/llms.txt index 67441aae..64b1313b 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -461,17 +461,15 @@ const durably = createDurably({ 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) => { - /* ... */ - }, - }), + jobs: { + myJob: defineJob({ + name: 'my-job', + input: z.object({}), + run: async (step) => { + /* ... */ + }, + }), + }, }) // Initialize (same as Node.js) From 5646d5dc1b20c908fa15ddcbc344314bd01f50a5 Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:47:44 +0900 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20update=20core=20README=20?= =?UTF-8?q?=E2=80=94=20use=20jobs=20option=20instead=20of=20.register()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- packages/durably/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/durably/README.md b/packages/durably/README.md index f6f6a674..c628b159 100644 --- a/packages/durably/README.md +++ b/packages/durably/README.md @@ -30,7 +30,7 @@ const myJob = defineJob({ }, }) -const durably = createDurably({ dialect }).register({ myJob }) +const durably = createDurably({ dialect, jobs: { myJob } }) await durably.init() // migrate + start await durably.jobs.myJob.trigger({ id: '123' }) From ff9a7e7e782c5a6ce590ffb9be0f98e757a3d14a Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:48:33 +0900 Subject: [PATCH 12/15] =?UTF-8?q?docs:=20update=20CLAUDE.md=20=E2=80=94=20?= =?UTF-8?q?jobs=20option=20is=20primary=20registration=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 788e8df7..7a0756ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ When API changes are made, update `packages/durably/docs/llms.md` to keep it in ## Core Concepts -- **Job**: Defined via `defineJob()` and registered with `durably.register()`, receives a step context and payload +- **Job**: Defined via `defineJob()` and registered via `jobs` option (or `.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 From 9579325fd77286b1197757c066fc2fdeb49e6cac Mon Sep 17 00:00:00 2001 From: coji Date: Fri, 6 Mar 2026 23:50:53 +0900 Subject: [PATCH 13/15] chore: overhaul doc-check and release-check skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key improvements: - Add "grep for old patterns" as Step 2 (most effective at catching stale docs) - Add missing file tiers: README (root), CLAUDE.md, skills, sidebar config - Add API pattern consistency table (jobs option, init(), createDurably) - Add "final grep" step to confirm nothing was missed - Reorganize release-check into sequential workflow (diff → grep → check → validate → grep) Co-Authored-By: Claude Opus 4.6 --- .claude/skills/doc-check/SKILL.md | 170 +++++++++++++---------- .claude/skills/release-check/SKILL.md | 192 +++++++++++++++++--------- 2 files changed, 221 insertions(+), 141 deletions(-) diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md index 34788630..b49a2290 100644 --- a/.claude/skills/doc-check/SKILL.md +++ b/.claude/skills/doc-check/SKILL.md @@ -12,106 +12,128 @@ allowed-tools: # Documentation Update Checklist -After any API change, verify all documentation is in sync. This checklist is ordered by priority. +After any API change, verify ALL documentation is in sync. -## How to Use +## Step 1: Identify What Changed -1. Identify what changed (new field, new method, changed signature, etc.) -2. Walk through each section below -3. Check only items relevant to the change scope (core, react, or both) -4. Mark items as done or N/A +Run `git diff --name-only main` (or check the current branch's changes) to understand the scope. -## 1. Package LLM Docs (bundled in npm) +## Step 2: Grep for Old Patterns -These are the primary references for AI coding agents. +This is the most important step. Run grep across the ENTIRE repo for old API patterns that need updating: -- [ ] `packages/durably/docs/llms.md` — Core API docs -- [ ] `packages/durably-react/docs/llms.md` — React hooks docs +```bash +# Example patterns to search for (adapt to the specific change): +grep -rn 'oldMethodName\|oldImportPath\|oldOptionName' \ + --include='*.md' --include='*.ts' --include='*.tsx' \ + packages/ website/ examples/ README.md CLAUDE.md .claude/ +``` -## 2. Website API Reference +**Every hit must be reviewed.** This catches documentation, examples, skills, and config files. -### Core API +Common patterns to check: -| File | Content | -| ------------------------------- | ------------------------------------------------------ | -| `website/api/index.md` | Quick reference / cheat sheet (covers ALL APIs) | -| `website/api/create-durably.md` | `createDurably()`, instance methods, types | -| `website/api/define-job.md` | `defineJob()`, job config | -| `website/api/step.md` | Step context (`step.run`, `step.progress`, `step.log`) | -| `website/api/events.md` | Event types and their fields | -| `website/api/http-handler.md` | `createDurablyHandler()`, request/response types | +- Old method/function names +- Old import paths (`@coji/durably-react/client` → `@coji/durably-react`) +- Old API patterns (`.register()` chain vs `jobs:` option) +- Old file paths in examples (`durably.hooks.ts` → `durably.ts`) +- Old directory names (`browser-*` → `spa-*`) -### React API +## Step 3: Check All Documentation Files -| File | Content | -| ---------------------------------------- | ------------------------------------- | -| `website/api/durably-react/index.md` | React hooks overview | -| `website/api/durably-react/spa.md` | SPA hooks (`useJob`, `useRuns`, etc.) | -| `website/api/durably-react/fullstack.md` | Fullstack hooks (server-connected) | -| `website/api/durably-react/types.md` | Shared type definitions | +### Tier 1: Package Docs (bundled in npm — highest priority) -### Guides (check if examples use changed API) +- [ ] `packages/durably/docs/llms.md` +- [ ] `packages/durably-react/docs/llms.md` -| File | Content | -| ---------------------------------- | ------------------------- | -| `website/guide/concepts.md` | Core concepts explanation | -| `website/guide/getting-started.md` | Getting started tutorial | -| `website/guide/csv-import.md` | CSV import example | -| `website/guide/background-sync.md` | Background sync example | -| `website/guide/offline-app.md` | Offline app example | +### Tier 2: README files -### Example Apps +- [ ] `README.md` (root) +- [ ] `packages/durably/README.md` +- [ ] `packages/durably-react/README.md` -Grep for the changed symbol name in `examples/` to find usage. Each example demonstrates a different deployment pattern: +### Tier 3: Agent/AI config -| Directory | Pattern | Key files | -| --------------------------------- | ------------------------------ | ------------------------------------------------------------------- | -| `examples/server-node` | Node.js server (core API only) | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | -| `examples/spa-vite-react` | SPA mode (Vite + React) | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | -| `examples/spa-react-router` | SPA mode (React Router) | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | -| `examples/fullstack-react-router` | Fullstack (React Router + SSE) | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | +- [ ] `CLAUDE.md` +- [ ] `.claude/skills/doc-check/SKILL.md` (this file — update tables if files change) +- [ ] `.claude/skills/release-check/SKILL.md` -## 3. Generated Files +### Tier 4: Website API Reference -These are derived from package docs and must be regenerated: +| File | Content | +| ---------------------------------------- | ------------------------------------------------ | +| `website/api/index.md` | Quick reference / cheat sheet (covers ALL APIs) | +| `website/api/create-durably.md` | `createDurably()`, instance methods, types | +| `website/api/define-job.md` | `defineJob()`, job config, trigger methods | +| `website/api/step.md` | Step context (`step.run`, `step.progress`, etc.) | +| `website/api/events.md` | Event types and their fields | +| `website/api/http-handler.md` | `createDurablyHandler()`, auth middleware | +| `website/api/durably-react/index.md` | React hooks overview + quick examples | +| `website/api/durably-react/fullstack.md` | Fullstack hooks (server-connected) | +| `website/api/durably-react/spa.md` | SPA hooks (`useJob`, `useRuns`, etc.) | +| `website/api/durably-react/types.md` | Shared type definitions | -```bash -pnpm --filter durably-website generate:llms -``` +### Tier 5: Website Guides -- [ ] `website/public/llms.txt` — Concatenation of core + react `llms.md` +| File | Content | +| ---------------------------------- | ------------------------ | +| `website/guide/concepts.md` | Core concepts | +| `website/guide/getting-started.md` | Getting started tutorial | +| `website/guide/csv-import.md` | CSV import example | +| `website/guide/background-sync.md` | Background sync example | +| `website/guide/offline-app.md` | Offline app example | -## 4. Scope Guide +### Tier 6: Website Config -Use this table to quickly determine which docs to check based on what changed: +- [ ] `website/.vitepress/config.ts` — Sidebar links, menu text, anchor targets -| Change Type | Docs to Check | -| -------------------------------- | ----------------------------------------------------------------------------------------- | -| New field on `Run` / `RunFilter` | llms.md (core), create-durably.md, index.md, http-handler.md, react spa.md + fullstack.md | -| New event field | llms.md (core), events.md, index.md | -| New step method | llms.md (core), step.md, index.md | -| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | -| React hook change | llms.md (react), spa.md, fullstack.md, index.md (react section) | -| HTTP handler change | llms.md (core), http-handler.md, fullstack.md | -| New config option | llms.md (core), create-durably.md, index.md | -| Job/step API change | All example apps (`examples/`) | -| Event type change | `examples/fullstack-react-router` (SSE), `examples/spa-*` (direct events) | -| React hook change | `examples/spa-vite-react`, `examples/spa-react-router`, `examples/fullstack-react-router` | +### Tier 7: Example Apps -## 5. Common Oversights +Grep for the changed symbol in all examples: -- **`website/api/index.md`** is a cheat sheet — it duplicates key info from other pages and is easy to forget -- **Event field additions** must be added to every event type comment block in `events.md` -- **Browser and Client mode** hooks often have parallel options tables — update both -- **Type definitions** in `website/api/durably-react/types.md` may need new type exports -- **`website/public/llms.txt`** is generated — don't edit directly, regenerate instead -- **Code examples** in guides may use the changed API — grep for the symbol name in `website/guide/` -- **Example apps** in `examples/` are working apps that use the public API — grep for the changed symbol in all 4 examples +| Directory | Pattern | Key files | +| --------------------------------- | -------------- | ------------------------------------------------------------------- | +| `examples/server-node` | Server mode | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | +| `examples/spa-vite-react` | SPA mode | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | +| `examples/spa-react-router` | SPA mode | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | +| `examples/fullstack-react-router` | Fullstack mode | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | -## 6. Verification +## Step 4: Regenerate & Validate ```bash pnpm format:fix -pnpm --filter durably-website generate:llms -pnpm validate +pnpm --filter durably-website generate:llms # Regenerate website/public/llms.txt +pnpm validate # format, lint, typecheck, test ``` + +## Step 5: Final Grep + +Run the same grep from Step 2 again to confirm nothing was missed. + +## Scope Guide + +Quick lookup for which docs to check based on change type: + +| Change Type | Docs to Check | +| ------------------- | ----------------------------------------------------------------------------------------- | +| New field on `Run` | llms.md (core), create-durably.md, index.md, http-handler.md, react fullstack.md + spa.md | +| New event field | llms.md (core), events.md, index.md | +| New step method | llms.md (core), step.md, index.md | +| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | +| React hook change | llms.md (react), fullstack.md, spa.md, react index.md | +| HTTP handler change | llms.md (core), http-handler.md, fullstack.md | +| New config option | llms.md (core), create-durably.md, index.md, CLAUDE.md | +| Import path change | ALL files (grep is the only reliable way) | +| API naming change | ALL files (grep is the only reliable way) | +| Example dir rename | skills, doc-check, release-check, website config | +| Sidebar structure | `website/.vitepress/config.ts` | + +## Common Oversights + +- **`website/api/index.md`** is a cheat sheet — duplicates key info, easy to forget +- **`CLAUDE.md`** describes core concepts — update when APIs change +- **Skills files** reference file paths and patterns — update when structure changes +- **Sidebar anchors** must match actual heading text (VitePress slugifies headings) +- **`website/public/llms.txt`** is generated — never edit directly, always regenerate +- **`init()` is the recommended method** — don't use `migrate()` + `start()` in examples +- **`jobs: {}` option is preferred** over `.register()` chain in examples and guides diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md index 3417f853..d8c4f693 100644 --- a/.claude/skills/release-check/SKILL.md +++ b/.claude/skills/release-check/SKILL.md @@ -11,120 +11,178 @@ allowed-tools: # Release Check -Verify package integrity for API changes and spec updates. +Pre-release integrity check. Run through each section in order. -## 1. Implementation +## 1. Diff Review + +```bash +git diff main --stat +git diff main --name-only +``` + +Understand the full scope of changes before checking anything. + +## 2. Grep for Stale Patterns + +Search the entire repo for patterns that should have been updated but might have been missed: + +```bash +# Adapt patterns to the specific release: +grep -rn 'OLD_PATTERN\|OLD_NAME\|OLD_PATH' \ + --include='*.md' --include='*.ts' --include='*.tsx' \ + packages/ website/ examples/ README.md CLAUDE.md .claude/ +``` + +Every hit must be reviewed and fixed or confirmed intentional. + +## 3. Implementation - [ ] **@coji/durably** (`packages/durably/src/`) - [ ] **@coji/durably-react** (`packages/durably-react/src/`) - - [ ] Browser hooks (`hooks/`) - - [ ] Client hooks (`client/`) + - [ ] SPA hooks (`hooks/`) + - [ ] Fullstack hooks (`client/`) - [ ] Shared utilities (`shared/`) - [ ] Type definitions (`types.ts`) + - [ ] Exports (`index.ts`, `spa.ts`) -## 2. Version Update +## 4. Version Update -- [ ] `packages/durably/package.json` - version -- [ ] `packages/durably-react/package.json` - version +- [ ] `packages/durably/package.json` — version +- [ ] `packages/durably-react/package.json` — version -## 3. Documentation +## 5. Documentation -### Core +### Package Docs (bundled in npm) -- [ ] `packages/durably/docs/llms.md` - LLM docs (bundled in npm) +- [ ] `packages/durably/docs/llms.md` +- [ ] `packages/durably-react/docs/llms.md` -### React +### README -- [ ] `packages/durably-react/docs/llms.md` - LLM docs (bundled in npm) -- [ ] `website/api/durably-react/index.md` - Overview -- [ ] `website/api/durably-react/spa.md` - SPA hooks -- [ ] `website/api/durably-react/fullstack.md` - Fullstack hooks -- [ ] `website/api/durably-react/types.md` - Type definitions +- [ ] `README.md` (root) +- [ ] `packages/durably/README.md` +- [ ] `packages/durably-react/README.md` -### Website +### Agent/AI Config -- [ ] `website/public/llms.txt` - Core + React llms.md concatenated (`pnpm --filter durably-website generate:llms`) +- [ ] `CLAUDE.md` +- [ ] `.claude/skills/doc-check/SKILL.md` +- [ ] `.claude/skills/release-check/SKILL.md` -## 4. README +### Website API Reference -- [ ] `packages/durably/README.md` -- [ ] `packages/durably-react/README.md` +- [ ] `website/api/index.md` — Quick reference (covers ALL APIs) +- [ ] `website/api/create-durably.md` — Instance, options, methods +- [ ] `website/api/define-job.md` — Job definition, trigger methods +- [ ] `website/api/step.md` — Step context +- [ ] `website/api/events.md` — Event types +- [ ] `website/api/http-handler.md` — HTTP handler, auth middleware +- [ ] `website/api/durably-react/index.md` — React overview +- [ ] `website/api/durably-react/fullstack.md` — Fullstack hooks +- [ ] `website/api/durably-react/spa.md` — SPA hooks +- [ ] `website/api/durably-react/types.md` — Type definitions + +### Website Guides + +- [ ] `website/guide/concepts.md` +- [ ] `website/guide/getting-started.md` +- [ ] `website/guide/csv-import.md` +- [ ] `website/guide/background-sync.md` +- [ ] `website/guide/offline-app.md` + +### Website Config -## 5. Examples +- [ ] `website/.vitepress/config.ts` — Sidebar links, menu text, anchors -- [ ] `examples/spa-vite-react/` - SPA mode example -- [ ] `examples/spa-react-router/` - SPA mode with React Router -- [ ] `examples/fullstack-react-router/` - Fullstack mode (server-connected) -- [ ] `examples/server-node/` - Node.js server example +### Generated Files -## 6. Tests +- [ ] `website/public/llms.txt` — Regenerate: `pnpm --filter durably-website generate:llms` -### Core (`packages/durably/tests/`) +## 6. Examples -- [ ] `node/` - Node.js tests -- [ ] `browser/` - Browser tests +All examples must compile and use current API patterns: -### React (`packages/durably-react/tests/`) +- [ ] `examples/server-node/` +- [ ] `examples/spa-vite-react/` +- [ ] `examples/spa-react-router/` +- [ ] `examples/fullstack-react-router/` -- [ ] `types.test.ts` - Type tests +Check for: + +- Old import paths +- Old API patterns (`.register()` chain → `jobs:` option) +- Old file names +- `init()` usage (not `migrate()` + `start()`) + +## 7. Tests + +- [ ] `packages/durably/tests/` — Core tests +- [ ] `packages/durably-react/tests/` — React tests + - [ ] `browser/` — SPA hook tests + - [ ] `client/` — Fullstack hook tests Verify new features/changes are covered by tests. -## 7. Changelog +## 8. Changelog -- [ ] `CHANGELOG.md` - Add version section +- [ ] `CHANGELOG.md` — Add version section with summary of changes -## 8. Validation +## 9. Validation ```bash -pnpm format:fix # Fix formatting first (Claude-written code often has format errors) -pnpm lint:fix # Fix lint issues -pnpm --filter durably-website generate:llms # Regenerate website/public/llms.txt -pnpm validate # Run format, lint, typecheck, test +pnpm format:fix +pnpm lint:fix +pnpm --filter durably-website generate:llms +pnpm validate # format, lint, typecheck, test ``` -Check `git status` for uncommitted changes. +Check `git status` for uncommitted changes after validation. + +## 10. Final Grep + +Re-run the grep from Step 2 to confirm all stale patterns are gone. --- ## Common Oversights -### SPA/Fullstack Mode Consistency +### Files People Forget + +- `README.md` (root) — often has quick-start code examples +- `CLAUDE.md` — describes core concepts, referenced by AI agents +- `.claude/skills/*.md` — reference file paths, directory names, API patterns +- `website/.vitepress/config.ts` — sidebar links must match actual headings +- `website/api/index.md` — cheat sheet that duplicates info from other pages + +### API Pattern Consistency + +Preferred patterns in all docs and examples: -When React hooks should provide the same API in both SPA and Fullstack modes: +| Pattern | Preferred | Avoid | +| ------------------ | ---------------------------------- | ---------------------------------- | +| Job registration | `createDurably({ jobs: {} })` | `.register()` chain | +| Initialization | `await durably.init()` | `migrate()` + `start()` separately | +| Fullstack client | `createDurably({})` | Raw `useJob({ api, jobName })` | +| Cross-job hooks | `durably.useRuns()` | `useRuns({ api })` | +| Import (fullstack) | `from '@coji/durably-react'` | N/A | +| Import (SPA) | `from '@coji/durably-react/spa'` | N/A | -| File | Mode | -| ------------------- | -------------- | -| `hooks/use-job.ts` | SPA mode | -| `client/use-job.ts` | Fullstack mode | +### SPA/Fullstack Mode Consistency -Ensure consistency in: +When hooks exist in both modes, ensure consistent: - Interface definitions - Return values - Options -### Code Examples in Documentation - -Verify code examples in docs match actual API: - -- Return value properties -- Option parameters -- Type definitions +| SPA | Fullstack | +| ------------------- | -------------------- | +| `hooks/use-job.ts` | `client/use-job.ts` | +| `hooks/use-runs.ts` | `client/use-runs.ts` | ### Type Exports -Check if new types are exported in `index.ts` / `spa.ts`. - -### Examples Consistency - -If examples use new API, verify no type errors or runtime errors: - -```bash -pnpm typecheck # Includes all examples -``` - -Key components to check: +Check new types are exported from: -- `dashboard.tsx` - useRuns, useRunActions -- `*-progress.tsx` - useJob return values (status booleans) +- `src/index.ts` (fullstack) +- `src/spa.ts` (SPA) From c6ed61cd519640e77332a2e2bea7a2892cee7db7 Mon Sep 17 00:00:00 2001 From: coji Date: Sat, 7 Mar 2026 00:07:07 +0900 Subject: [PATCH 14/15] chore: overhaul skills with orchestrator pattern and automated detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by Anthropic's skill-creator design patterns: - Add `scripts/find-stale.sh` — automated grep for old API patterns, import paths, directory names, and terminology across docs/examples - Rewrite doc-check as 6-phase orchestrator: detect → understand → walk docs → regenerate → validate → final check - Rewrite release-check as 10-phase orchestrator sharing the same script - Why-driven: every Tier/file explains WHY it needs checking - Description optimized for trigger accuracy ("prevents follow-up PRs") - Fix "Browser mode" → "SPA mode" in durably-react README (caught by script) Co-Authored-By: Claude Opus 4.6 --- .claude/skills/doc-check/SKILL.md | 191 ++++++++++-------- .../skills/doc-check/scripts/find-stale.sh | 157 ++++++++++++++ .claude/skills/release-check/SKILL.md | 187 +++++++++-------- packages/durably-react/README.md | 2 +- 4 files changed, 353 insertions(+), 184 deletions(-) create mode 100755 .claude/skills/doc-check/scripts/find-stale.sh diff --git a/.claude/skills/doc-check/SKILL.md b/.claude/skills/doc-check/SKILL.md index b49a2290..18e3ec57 100644 --- a/.claude/skills/doc-check/SKILL.md +++ b/.claude/skills/doc-check/SKILL.md @@ -1,6 +1,6 @@ --- name: doc-check -description: Documentation update checklist. Run after API changes to find documentation that needs updating. Use for doc check, documentation review, docs update, API change docs. +description: Catch stale docs after API changes. Runs automated pattern detection, then walks through every file that could be out of sync. Prevents the "forgot to update docs" follow-up PRs that always happen after API changes. Use when making API changes, renaming, restructuring, or before any release. allowed-tools: - Read - Grep @@ -8,132 +8,149 @@ allowed-tools: - Bash(pnpm:*) - Bash(node:*) - Bash(git:*) + - Bash(chmod:*) + - Bash(./*) --- -# Documentation Update Checklist +# Doc Check -After any API change, verify ALL documentation is in sync. +Catch documentation and example drift after API/type changes. +Most follow-up PRs after a release are "forgot to update docs" — this skill prevents that. -## Step 1: Identify What Changed +## Phase 1: Automated Detection (most important) -Run `git diff --name-only main` (or check the current branch's changes) to understand the scope. +Run the stale pattern detection script first. It catches more than manual review. -## Step 2: Grep for Old Patterns +```bash +.claude/skills/doc-check/scripts/find-stale.sh +``` + +**Fix every `[STALE]` hit.** Each result includes a "Why" explaining the issue. + +For change-specific patterns (e.g., a rename you just did): + +```bash +.claude/skills/doc-check/scripts/find-stale.sh 'oldMethodName\|oldImportPath' +``` -This is the most important step. Run grep across the ENTIRE repo for old API patterns that need updating: +## Phase 2: Understand the Change ```bash -# Example patterns to search for (adapt to the specific change): -grep -rn 'oldMethodName\|oldImportPath\|oldOptionName' \ - --include='*.md' --include='*.ts' --include='*.tsx' \ - packages/ website/ examples/ README.md CLAUDE.md .claude/ +git diff --name-only main ``` -**Every hit must be reviewed.** This catches documentation, examples, skills, and config files. +Know what changed before walking through docs. -Common patterns to check: +## Phase 3: Walk Through Documentation -- Old method/function names -- Old import paths (`@coji/durably-react/client` → `@coji/durably-react`) -- Old API patterns (`.register()` chain vs `jobs:` option) -- Old file paths in examples (`durably.hooks.ts` → `durably.ts`) -- Old directory names (`browser-*` → `spa-*`) +Check files in priority order. Only review files relevant to the change scope. -## Step 3: Check All Documentation Files +### Tier 1: Package docs (bundled in npm — AI agents read these) -### Tier 1: Package Docs (bundled in npm — highest priority) +Highest reach. If an API changed, these almost certainly need updating. - [ ] `packages/durably/docs/llms.md` - [ ] `packages/durably-react/docs/llms.md` -### Tier 2: README files +### Tier 2: READMEs + +First thing users see on GitHub/npm. Stale quick-start code = bad first impression. - [ ] `README.md` (root) - [ ] `packages/durably/README.md` - [ ] `packages/durably-react/README.md` -### Tier 3: Agent/AI config +### Tier 3: AI agent config + +Claude Code and other AI tools read these to generate code. Stale info = wrong code suggestions. - [ ] `CLAUDE.md` -- [ ] `.claude/skills/doc-check/SKILL.md` (this file — update tables if files change) +- [ ] `.claude/skills/doc-check/SKILL.md` (this file) - [ ] `.claude/skills/release-check/SKILL.md` ### Tier 4: Website API Reference -| File | Content | -| ---------------------------------------- | ------------------------------------------------ | -| `website/api/index.md` | Quick reference / cheat sheet (covers ALL APIs) | -| `website/api/create-durably.md` | `createDurably()`, instance methods, types | -| `website/api/define-job.md` | `defineJob()`, job config, trigger methods | -| `website/api/step.md` | Step context (`step.run`, `step.progress`, etc.) | -| `website/api/events.md` | Event types and their fields | -| `website/api/http-handler.md` | `createDurablyHandler()`, auth middleware | -| `website/api/durably-react/index.md` | React hooks overview + quick examples | -| `website/api/durably-react/fullstack.md` | Fullstack hooks (server-connected) | -| `website/api/durably-react/spa.md` | SPA hooks (`useJob`, `useRuns`, etc.) | -| `website/api/durably-react/types.md` | Shared type definitions | +Users look these up when coding. Stale examples cause confusion. + +| File | Why it needs checking | +| ---------------------------------------- | --------------------------------------------------------- | +| `website/api/index.md` | Cheat sheet — duplicates info from other pages, easy miss | +| `website/api/create-durably.md` | Options, methods, types | +| `website/api/define-job.md` | trigger/triggerAndWait signatures | +| `website/api/step.md` | step.run, step.progress code examples | +| `website/api/events.md` | Event type fields — must update every block on additions | +| `website/api/http-handler.md` | Endpoints, auth middleware | +| `website/api/durably-react/index.md` | Overview + Quick Examples for both modes | +| `website/api/durably-react/fullstack.md` | createDurably, useJob, useRuns, etc. | +| `website/api/durably-react/spa.md` | DurablyProvider, useJob, useRuns, etc. | +| `website/api/durably-react/types.md` | Type definitions — add new exports here | + +### Tier 5: Guides + +Code examples embedded in prose. API changes silently break copy-paste. + +| File | Why it needs checking | +| ---------------------------------- | ----------------------------------- | +| `website/guide/concepts.md` | Core concept explanations with code | +| `website/guide/getting-started.md` | First code users copy-paste | +| `website/guide/csv-import.md` | Complete fullstack example | +| `website/guide/background-sync.md` | Server mode example | +| `website/guide/offline-app.md` | SPA mode example | -### Tier 5: Website Guides +### Tier 6: Sidebar config -| File | Content | -| ---------------------------------- | ------------------------ | -| `website/guide/concepts.md` | Core concepts | -| `website/guide/getting-started.md` | Getting started tutorial | -| `website/guide/csv-import.md` | CSV import example | -| `website/guide/background-sync.md` | Background sync example | -| `website/guide/offline-app.md` | Offline app example | +Menu links and anchors must match actual headings. Mismatches cause 404s. -### Tier 6: Website Config +- [ ] `website/.vitepress/config.ts` — VitePress slugifies headings for anchors -- [ ] `website/.vitepress/config.ts` — Sidebar links, menu text, anchor targets +### Tier 7: Example apps -### Tier 7: Example Apps +Working code that uses the public API. `pnpm typecheck` catches breakage. -Grep for the changed symbol in all examples: +| Directory | Mode | +| --------------------------------- | -------------- | +| `examples/server-node` | Server mode | +| `examples/spa-vite-react` | SPA mode | +| `examples/spa-react-router` | SPA mode | +| `examples/fullstack-react-router` | Fullstack mode | -| Directory | Pattern | Key files | -| --------------------------------- | -------------- | ------------------------------------------------------------------- | -| `examples/server-node` | Server mode | `jobs/*.ts`, `lib/durably.ts`, `basic.ts` | -| `examples/spa-vite-react` | SPA mode | `src/jobs/*.ts`, `src/lib/durably.ts`, `src/components/*.tsx` | -| `examples/spa-react-router` | SPA mode | `app/jobs/*.ts`, `app/lib/durably.ts`, `app/routes/**/*.tsx` | -| `examples/fullstack-react-router` | Fullstack mode | `app/jobs/*.ts`, `app/lib/durably.server.ts`, `app/routes/**/*.tsx` | +## Phase 4: Regenerate -## Step 4: Regenerate & Validate +```bash +pnpm --filter durably-website generate:llms +``` + +`website/public/llms.txt` is generated from `packages/*/docs/llms.md`. Never edit directly. + +## Phase 5: Validate ```bash pnpm format:fix -pnpm --filter durably-website generate:llms # Regenerate website/public/llms.txt -pnpm validate # format, lint, typecheck, test +pnpm validate ``` -## Step 5: Final Grep - -Run the same grep from Step 2 again to confirm nothing was missed. - -## Scope Guide - -Quick lookup for which docs to check based on change type: - -| Change Type | Docs to Check | -| ------------------- | ----------------------------------------------------------------------------------------- | -| New field on `Run` | llms.md (core), create-durably.md, index.md, http-handler.md, react fullstack.md + spa.md | -| New event field | llms.md (core), events.md, index.md | -| New step method | llms.md (core), step.md, index.md | -| New trigger option | llms.md (core), index.md, http-handler.md, create-durably.md | -| React hook change | llms.md (react), fullstack.md, spa.md, react index.md | -| HTTP handler change | llms.md (core), http-handler.md, fullstack.md | -| New config option | llms.md (core), create-durably.md, index.md, CLAUDE.md | -| Import path change | ALL files (grep is the only reliable way) | -| API naming change | ALL files (grep is the only reliable way) | -| Example dir rename | skills, doc-check, release-check, website config | -| Sidebar structure | `website/.vitepress/config.ts` | - -## Common Oversights - -- **`website/api/index.md`** is a cheat sheet — duplicates key info, easy to forget -- **`CLAUDE.md`** describes core concepts — update when APIs change -- **Skills files** reference file paths and patterns — update when structure changes -- **Sidebar anchors** must match actual heading text (VitePress slugifies headings) -- **`website/public/llms.txt`** is generated — never edit directly, always regenerate -- **`init()` is the recommended method** — don't use `migrate()` + `start()` in examples -- **`jobs: {}` option is preferred** over `.register()` chain in examples and guides +## Phase 6: Final Check + +Run the script again to confirm nothing was missed: + +```bash +.claude/skills/doc-check/scripts/find-stale.sh +``` + +## Preferred Patterns + +Use these in docs and examples. API reference may document alternatives. + +| Pattern | Preferred | Avoid | +| ------------------ | ---------------------------------- | -------------------------------- | +| Job registration | `createDurably({ jobs: {} })` | `.register()` chain | +| Initialization | `await durably.init()` | `migrate()` + `start()` separate | +| Fullstack client | `createDurably({})` | raw `useJob({ api, jobName })` | +| Cross-job hooks | `durably.useRuns()` | `useRuns({ api })` | +| Import (fullstack) | `from '@coji/durably-react'` | | +| Import (SPA) | `from '@coji/durably-react/spa'` | | + +## Updating the Script + +When you make a new rename or API change, add a `check_pattern` call to `scripts/find-stale.sh`. +This way it's automatically caught in future runs. diff --git a/.claude/skills/doc-check/scripts/find-stale.sh b/.claude/skills/doc-check/scripts/find-stale.sh new file mode 100755 index 00000000..ecf85306 --- /dev/null +++ b/.claude/skills/doc-check/scripts/find-stale.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Find stale patterns across docs, examples, skills, and config. +# +# Usage: +# ./find-stale.sh # Check all known patterns +# ./find-stale.sh 'oldName|oldPath' # Check custom patterns +# +# Exit code: 0 = clean, 1 = stale patterns found +# +# Why this exists: +# API/type changes almost always leave stale docs and examples behind. +# Running this script catches them before they become follow-up PRs. + +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +DIM='\033[2m' +NC='\033[0m' + +# Only check docs, examples, skills, config — NOT source code or tests +DOC_DIRS=( + website/ + examples/ + README.md + CLAUDE.md + .claude/skills/ + packages/durably/README.md + packages/durably/docs/ + packages/durably-react/README.md + packages/durably-react/docs/ +) + +EXCLUDE="--exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.vitepress/cache --exclude=pnpm-lock.yaml --exclude=llms.txt" + +found=0 + +check_pattern() { + local label="$1" + local pattern="$2" + local why="${3:-}" + local exceptions="${4:-}" + + # shellcheck disable=SC2086 + results=$(grep -rn $EXCLUDE --include='*.md' --include='*.ts' --include='*.tsx' "$pattern" "${DOC_DIRS[@]}" 2>/dev/null || true) + + # Filter out known exceptions + if [ -n "$exceptions" ] && [ -n "$results" ]; then + results=$(echo "$results" | grep -v "$exceptions" || true) + fi + + if [ -n "$results" ]; then + echo -e "${RED}[STALE]${NC} $label" + if [ -n "$why" ]; then + echo -e " ${YELLOW}Why:${NC} $why" + fi + echo "$results" | sed 's/^/ /' + echo "" + found=1 + fi +} + +if [ $# -gt 0 ]; then + # Custom pattern mode — search broadly + check_pattern "Custom pattern" "$1" "User-specified pattern check" +else + # ── Renamed APIs ── + + check_pattern \ + "createDurablyClient (renamed to createDurably)" \ + "createDurablyClient" \ + "Renamed in v0.10. Use createDurably from @coji/durably-react" + + check_pattern \ + "createDurablyHooks (renamed to createDurably)" \ + "createDurablyHooks" \ + "Renamed in v0.10. Use createDurably from @coji/durably-react" + + # ── Old import paths ── + + check_pattern \ + "Old import: @coji/durably-react/client" \ + "durably-react/client" \ + "Fullstack hooks are now the root import: @coji/durably-react" \ + "SKILL.md" # Skills mention old paths as examples of what to check + + check_pattern \ + "Old import: @coji/durably-react/browser" \ + "durably-react/browser" \ + "SPA hooks moved to @coji/durably-react/spa" \ + "SKILL.md" + + # ── Old directory/file names ── + + check_pattern \ + "Old dir: browser-vite-react" \ + "browser-vite-react" \ + "Renamed to spa-vite-react" + + check_pattern \ + "Old dir: browser-react-router" \ + "browser-react-router" \ + "Renamed to spa-react-router" + + check_pattern \ + "Old file: durably.hooks" \ + "durably\.hooks" \ + "Use durably.ts (framework-agnostic)" \ + "SKILL.md" + + # ── Preferred patterns in guides/examples ── + # (API ref pages like create-durably.md, define-job.md document these as valid API — excluded) + + API_REF_EXCLUDE="create-durably\.md\|define-job\.md\|step\.md\|http-handler\.md\|events\.md\|llms\.md\|CLAUDE\.md" + + check_pattern \ + ".register() chain in guides/examples" \ + '\.register({' \ + "Prefer createDurably({ jobs: {} }) in guides and examples" \ + "$API_REF_EXCLUDE" + + check_pattern \ + "migrate() in guides/examples" \ + 'await durably\.migrate()' \ + "Prefer await durably.init() in guides and examples" \ + "$API_REF_EXCLUDE" + + check_pattern \ + "durably.start() in guides/examples" \ + 'durably\.start()' \ + "Prefer await durably.init() in guides and examples" \ + "$API_REF_EXCLUDE" + + # ── Old terminology ── + + check_pattern \ + "Old terminology: Browser Hooks/Browser Mode" \ + 'Browser Hooks\|Browser Mode\|Browser mode' \ + "Renamed to SPA Hooks / SPA Mode" \ + "SKILL.md" + + check_pattern \ + "Old terminology: Server Hooks/Client Hooks" \ + 'Server Hooks\|Client Hooks' \ + "Renamed to Fullstack Hooks" \ + "SKILL.md" +fi + +if [ $found -eq 0 ]; then + echo -e "${GREEN}All clean. No stale patterns found.${NC}" + exit 0 +else + echo -e "${RED}Review each [STALE] hit above and fix or confirm intentional.${NC}" + exit 1 +fi diff --git a/.claude/skills/release-check/SKILL.md b/.claude/skills/release-check/SKILL.md index d8c4f693..b8c1c137 100644 --- a/.claude/skills/release-check/SKILL.md +++ b/.claude/skills/release-check/SKILL.md @@ -1,88 +1,103 @@ --- name: release-check -description: Pre-release integrity check. Verify package consistency for API changes and spec updates. Use for release check, version update, documentation consistency, pre-release verification. +description: Pre-release integrity check. Catches stale docs, broken examples, missing exports, and version mismatches before publishing. Run before bumping versions or creating release PRs. Use for release check, version bump, pre-release, publish prep. allowed-tools: - Read - Grep - Glob - Bash(pnpm:*) - Bash(git:*) + - Bash(./*) --- # Release Check -Pre-release integrity check. Run through each section in order. +Pre-release integrity check. Doc drift after API changes is the #1 source of follow-up PRs. +This skill catches everything before it ships. -## 1. Diff Review +## Phase 1: Automated Detection + +Run doc-check's stale pattern script first: ```bash -git diff main --stat -git diff main --name-only +.claude/skills/doc-check/scripts/find-stale.sh ``` -Understand the full scope of changes before checking anything. - -## 2. Grep for Stale Patterns +Fix every `[STALE]` hit before proceeding. -Search the entire repo for patterns that should have been updated but might have been missed: +## Phase 2: Understand the Scope ```bash -# Adapt patterns to the specific release: -grep -rn 'OLD_PATTERN\|OLD_NAME\|OLD_PATH' \ - --include='*.md' --include='*.ts' --include='*.tsx' \ - packages/ website/ examples/ README.md CLAUDE.md .claude/ +git diff main --stat +git log --oneline main..HEAD ``` -Every hit must be reviewed and fixed or confirmed intentional. +## Phase 3: Implementation -## 3. Implementation +### Packages - [ ] **@coji/durably** (`packages/durably/src/`) - [ ] **@coji/durably-react** (`packages/durably-react/src/`) - - [ ] SPA hooks (`hooks/`) - - [ ] Fullstack hooks (`client/`) - - [ ] Shared utilities (`shared/`) - - [ ] Type definitions (`types.ts`) - - [ ] Exports (`index.ts`, `spa.ts`) + - [ ] SPA hooks (`hooks/`) — runs directly in browser + - [ ] Fullstack hooks (`client/`) — connects to server via HTTP/SSE + - [ ] Shared (`shared/`) — logic used by both modes + - [ ] Types (`types.ts`) — public type definitions + - [ ] Exports (`index.ts`, `spa.ts`) — are new types/hooks exported? + +### Export completeness + +New types/hooks must be exported or users can't import them. -## 4. Version Update +- `packages/durably-react/src/index.ts` (fullstack) +- `packages/durably-react/src/spa.ts` (SPA) +- `packages/durably/src/index.ts` (core) -- [ ] `packages/durably/package.json` — version -- [ ] `packages/durably-react/package.json` — version +## Phase 4: Version -## 5. Documentation +- [ ] `packages/durably/package.json` +- [ ] `packages/durably-react/package.json` -### Package Docs (bundled in npm) +Check peer dependency ranges too. + +## Phase 5: Documentation + +Doc drift is the most common post-release issue. Check everything. + +### Package docs (bundled in npm) + +AI agents read these from `node_modules`. Stale info = wrong generated code. - [ ] `packages/durably/docs/llms.md` - [ ] `packages/durably-react/docs/llms.md` -### README +### READMEs - [ ] `README.md` (root) - [ ] `packages/durably/README.md` - [ ] `packages/durably-react/README.md` -### Agent/AI Config +### AI agent config -- [ ] `CLAUDE.md` -- [ ] `.claude/skills/doc-check/SKILL.md` -- [ ] `.claude/skills/release-check/SKILL.md` +- [ ] `CLAUDE.md` — core concepts, defaults, design decisions +- [ ] `.claude/skills/doc-check/SKILL.md` — file paths, pattern tables +- [ ] `.claude/skills/release-check/SKILL.md` — this file ### Website API Reference -- [ ] `website/api/index.md` — Quick reference (covers ALL APIs) -- [ ] `website/api/create-durably.md` — Instance, options, methods -- [ ] `website/api/define-job.md` — Job definition, trigger methods -- [ ] `website/api/step.md` — Step context -- [ ] `website/api/events.md` — Event types -- [ ] `website/api/http-handler.md` — HTTP handler, auth middleware -- [ ] `website/api/durably-react/index.md` — React overview -- [ ] `website/api/durably-react/fullstack.md` — Fullstack hooks -- [ ] `website/api/durably-react/spa.md` — SPA hooks -- [ ] `website/api/durably-react/types.md` — Type definitions +- [ ] `website/api/index.md` — cheat sheet (duplicates info, easy to miss) +- [ ] `website/api/create-durably.md` +- [ ] `website/api/define-job.md` +- [ ] `website/api/step.md` +- [ ] `website/api/events.md` +- [ ] `website/api/http-handler.md` +- [ ] `website/api/durably-react/index.md` +- [ ] `website/api/durably-react/fullstack.md` +- [ ] `website/api/durably-react/spa.md` +- [ ] `website/api/durably-react/types.md` + +### Guides -### Website Guides +Code examples embedded in prose. API changes silently break copy-paste. - [ ] `website/guide/concepts.md` - [ ] `website/guide/getting-started.md` @@ -90,17 +105,17 @@ Every hit must be reviewed and fixed or confirmed intentional. - [ ] `website/guide/background-sync.md` - [ ] `website/guide/offline-app.md` -### Website Config +### Sidebar config -- [ ] `website/.vitepress/config.ts` — Sidebar links, menu text, anchors +- [ ] `website/.vitepress/config.ts` — links, menu text, anchors -### Generated Files +### Generated files -- [ ] `website/public/llms.txt` — Regenerate: `pnpm --filter durably-website generate:llms` +- [ ] `website/public/llms.txt` — regenerate: `pnpm --filter durably-website generate:llms` -## 6. Examples +## Phase 6: Examples -All examples must compile and use current API patterns: +All examples must compile and use current API patterns. - [ ] `examples/server-node/` - [ ] `examples/spa-vite-react/` @@ -109,80 +124,60 @@ All examples must compile and use current API patterns: Check for: -- Old import paths -- Old API patterns (`.register()` chain → `jobs:` option) -- Old file names -- `init()` usage (not `migrate()` + `start()`) +- `jobs: {}` option (not `.register()` chain) +- `await durably.init()` (not `migrate()` + `start()`) +- Current import paths -## 7. Tests +## Phase 7: Tests -- [ ] `packages/durably/tests/` — Core tests -- [ ] `packages/durably-react/tests/` — React tests +- [ ] `packages/durably/tests/` +- [ ] `packages/durably-react/tests/` - [ ] `browser/` — SPA hook tests - [ ] `client/` — Fullstack hook tests -Verify new features/changes are covered by tests. +Verify new features/changes have test coverage. -## 8. Changelog +## Phase 8: Changelog -- [ ] `CHANGELOG.md` — Add version section with summary of changes +- [ ] `CHANGELOG.md` — add version section -## 9. Validation +## Phase 9: Validate ```bash pnpm format:fix pnpm lint:fix pnpm --filter durably-website generate:llms -pnpm validate # format, lint, typecheck, test +pnpm validate ``` -Check `git status` for uncommitted changes after validation. +Check `git status` for uncommitted changes. -## 10. Final Grep +## Phase 10: Final Check -Re-run the grep from Step 2 to confirm all stale patterns are gone. - ---- - -## Common Oversights - -### Files People Forget - -- `README.md` (root) — often has quick-start code examples -- `CLAUDE.md` — describes core concepts, referenced by AI agents -- `.claude/skills/*.md` — reference file paths, directory names, API patterns -- `website/.vitepress/config.ts` — sidebar links must match actual headings -- `website/api/index.md` — cheat sheet that duplicates info from other pages - -### API Pattern Consistency - -Preferred patterns in all docs and examples: +```bash +.claude/skills/doc-check/scripts/find-stale.sh +``` -| Pattern | Preferred | Avoid | -| ------------------ | ---------------------------------- | ---------------------------------- | -| Job registration | `createDurably({ jobs: {} })` | `.register()` chain | -| Initialization | `await durably.init()` | `migrate()` + `start()` separately | -| Fullstack client | `createDurably({})` | Raw `useJob({ api, jobName })` | -| Cross-job hooks | `durably.useRuns()` | `useRuns({ api })` | -| Import (fullstack) | `from '@coji/durably-react'` | N/A | -| Import (SPA) | `from '@coji/durably-react/spa'` | N/A | +Must be clean before release. -### SPA/Fullstack Mode Consistency +--- -When hooks exist in both modes, ensure consistent: +## SPA/Fullstack Consistency -- Interface definitions -- Return values -- Options +Hooks in both modes should have consistent interfaces, return values, and options: | SPA | Fullstack | | ------------------- | -------------------- | | `hooks/use-job.ts` | `client/use-job.ts` | | `hooks/use-runs.ts` | `client/use-runs.ts` | -### Type Exports - -Check new types are exported from: +## Preferred Patterns -- `src/index.ts` (fullstack) -- `src/spa.ts` (SPA) +| Pattern | Preferred | Avoid | +| ------------------ | ---------------------------------- | -------------------------------- | +| Job registration | `createDurably({ jobs: {} })` | `.register()` chain | +| Initialization | `await durably.init()` | `migrate()` + `start()` separate | +| Fullstack client | `createDurably({})` | raw `useJob({ api, jobName })` | +| Cross-job hooks | `durably.useRuns()` | `useRuns({ api })` | +| Import (fullstack) | `from '@coji/durably-react'` | | +| Import (SPA) | `from '@coji/durably-react/spa'` | | diff --git a/packages/durably-react/README.md b/packages/durably-react/README.md index 1cdb8096..9a05430f 100644 --- a/packages/durably-react/README.md +++ b/packages/durably-react/README.md @@ -92,7 +92,7 @@ function MyComponent() { For full documentation, visit [coji.github.io/durably](https://coji.github.io/durably/). -- [SPA Hooks](https://coji.github.io/durably/api/durably-react/spa) - Browser mode with OPFS +- [SPA Hooks](https://coji.github.io/durably/api/durably-react/spa) - SPA mode with OPFS - [Fullstack Hooks](https://coji.github.io/durably/api/durably-react/fullstack) - Server-connected mode ## License From 87ae9eaf5f9d7f8c0a4395db4331b02c31dcd080 Mon Sep 17 00:00:00 2001 From: coji Date: Sat, 7 Mar 2026 00:20:03 +0900 Subject: [PATCH 15/15] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20zod=20imports,=20reserved=20keys=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing `import { z } from 'zod'` to useRuns examples in llms.md - Document `useRuns`/`useRunActions` as reserved proxy keys in: - source code comment - fullstack.md (tip box) - llms.md (cross-job hooks example + note) - Regenerate llms.txt Co-Authored-By: Claude Opus 4.6 --- packages/durably-react/docs/llms.md | 10 ++++++++++ packages/durably-react/src/client/create-durably.ts | 3 ++- website/api/durably-react/fullstack.md | 4 ++++ website/public/llms.txt | 10 ++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md index 3a1b7879..1cbdc0f3 100644 --- a/packages/durably-react/docs/llms.md +++ b/packages/durably-react/docs/llms.md @@ -79,8 +79,16 @@ function LogViewer({ runId }: { runId: string }) { const { logs } = durably.importCsv.useLogs(runId) return
    {logs.map(l => l.message).join('\n')}
    } + +// Cross-job hooks (built into the proxy) +function Dashboard() { + const { runs } = durably.useRuns({ pageSize: 10 }) + const { retry, cancel } = durably.useRunActions() +} ``` +Note: `useRuns` and `useRunActions` are reserved keys on the proxy. Do not register jobs with these names. + ### Fullstack useJob Direct hook when not using `createDurably`: @@ -199,6 +207,7 @@ interface UseRunsClientOptions { ```tsx import { useRuns, TypedClientRun } from '@coji/durably-react' import { defineJob } from '@coji/durably' +import { z } from 'zod' // Option 1: Generic type parameter (dashboard with multiple job types) type ImportRun = TypedClientRun<{ file: string }, { count: number }> @@ -523,6 +532,7 @@ List runs with filtering, pagination, and real-time updates: ```tsx import { useRuns, TypedRun } from '@coji/durably-react/spa' import { defineJob } from '@coji/durably' +import { z } from 'zod' // Option 1: Generic type parameter (dashboard with multiple job types) type ImportRun = TypedRun<{ file: string }, { count: number }> diff --git a/packages/durably-react/src/client/create-durably.ts b/packages/durably-react/src/client/create-durably.ts index 2d9bc39e..592f5179 100644 --- a/packages/durably-react/src/client/create-durably.ts +++ b/packages/durably-react/src/client/create-durably.ts @@ -99,7 +99,8 @@ export function createDurably( const { api } = options const cache = new Map() - // Built-in cross-job hooks (keyed by reserved names) + // Built-in cross-job hooks. These names are reserved and cannot be used as job names. + // If a job is registered with one of these names, the built-in hook takes precedence. const builtins: Record = { useRuns: (opts?: Omit) => useRuns({ api, ...opts }), diff --git a/website/api/durably-react/fullstack.md b/website/api/durably-react/fullstack.md index dce796c3..ec52620d 100644 --- a/website/api/durably-react/fullstack.md +++ b/website/api/durably-react/fullstack.md @@ -122,6 +122,10 @@ function Dashboard() { } ``` +::: tip Reserved keys +`useRuns` and `useRunActions` are reserved names on the proxy. Do not register jobs with these names — the built-in hooks take precedence. +::: + --- ## Hooks directly diff --git a/website/public/llms.txt b/website/public/llms.txt index 64b1313b..46732e89 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -697,8 +697,16 @@ function LogViewer({ runId }: { runId: string }) { const { logs } = durably.importCsv.useLogs(runId) return
    {logs.map(l => l.message).join('\n')}
    } + +// Cross-job hooks (built into the proxy) +function Dashboard() { + const { runs } = durably.useRuns({ pageSize: 10 }) + const { retry, cancel } = durably.useRunActions() +} ``` +Note: `useRuns` and `useRunActions` are reserved keys on the proxy. Do not register jobs with these names. + ### Fullstack useJob Direct hook when not using `createDurably`: @@ -817,6 +825,7 @@ interface UseRunsClientOptions { ```tsx import { useRuns, TypedClientRun } from '@coji/durably-react' import { defineJob } from '@coji/durably' +import { z } from 'zod' // Option 1: Generic type parameter (dashboard with multiple job types) type ImportRun = TypedClientRun<{ file: string }, { count: number }> @@ -1141,6 +1150,7 @@ List runs with filtering, pagination, and real-time updates: ```tsx import { useRuns, TypedRun } from '@coji/durably-react/spa' import { defineJob } from '@coji/durably' +import { z } from 'zod' // Option 1: Generic type parameter (dashboard with multiple job types) type ImportRun = TypedRun<{ file: string }, { count: number }>