diff --git a/.claude/skills/doc-check/scripts/find-stale.sh b/.claude/skills/doc-check/scripts/find-stale.sh index 7d2a8fc..fffeb83 100755 --- a/.claude/skills/doc-check/scripts/find-stale.sh +++ b/.claude/skills/doc-check/scripts/find-stale.sh @@ -145,6 +145,14 @@ else 'No Redis' \ "Outdated positioning. Update og:description and twitter:description" + # ── Renamed fields ── + + check_pattern \ + "Old field name: stepCount (renamed to completedStepCount)" \ + 'stepCount' \ + "Renamed in v0.14. Use completedStepCount" \ + "CHANGELOG.md" + # ── Old terminology ── check_pattern \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 16578e1..9d36d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -352,7 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ``` - **Retry from cancelled state**: `retry()` now works on both `failed` and `cancelled` runs - **New events**: `run:trigger`, `run:cancel`, `run:retry` for complete run lifecycle tracking -- **`stepCount` on `Run` type**: Number of completed steps, available in `getRun()`, `getRuns()` +- **`completedStepCount` on `Run` type**: Number of completed steps, available in `getRun()`, `getRuns()` #### @coji/durably/server @@ -373,7 +373,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). #### @coji/durably-react/client - **`useRunActions` enhancements**: `deleteRun()`, `getRun()`, `getSteps()` -- **Step progress**: `stepCount` and `currentStepIndex` on `ClientRun` and `RunRecord` types +- **Step progress**: `completedStepCount` and `currentStepIndex` on `ClientRun` and `RunRecord` types - **New type exports**: `RunRecord`, `StepRecord` ### Changed diff --git a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx index 413584e..df14f27 100644 --- a/examples/fullstack-react-router/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-react-router/app/routes/_index/dashboard.tsx @@ -167,9 +167,9 @@ export function Dashboard() { - {run.stepCount > 0 ? ( + {run.completedStepCount > 0 ? ( - {run.stepCount} + {run.completedStepCount} ) : ( - diff --git a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx index 4534e26..9295603 100644 --- a/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx +++ b/examples/fullstack-vercel-turso/app/routes/_index/dashboard.tsx @@ -169,9 +169,9 @@ export function Dashboard() { - {run.stepCount > 0 ? ( + {run.completedStepCount > 0 ? ( - {run.stepCount} + {run.completedStepCount} ) : ( - diff --git a/examples/spa-react-router/app/routes/_index/dashboard.tsx b/examples/spa-react-router/app/routes/_index/dashboard.tsx index 52b060c..3ebedfa 100644 --- a/examples/spa-react-router/app/routes/_index/dashboard.tsx +++ b/examples/spa-react-router/app/routes/_index/dashboard.tsx @@ -164,9 +164,9 @@ export function Dashboard() { - {run.stepCount > 0 ? ( + {run.completedStepCount > 0 ? ( - {run.stepCount} + {run.completedStepCount} ) : ( - diff --git a/examples/spa-vite-react/src/components/dashboard.tsx b/examples/spa-vite-react/src/components/dashboard.tsx index a356a0a..7ac3f93 100644 --- a/examples/spa-vite-react/src/components/dashboard.tsx +++ b/examples/spa-vite-react/src/components/dashboard.tsx @@ -161,9 +161,9 @@ export function Dashboard() { - {run.stepCount > 0 ? ( + {run.completedStepCount > 0 ? ( - {run.stepCount} + {run.completedStepCount} ) : ( - diff --git a/packages/durably-react/tests/client/use-runs.test.tsx b/packages/durably-react/tests/client/use-runs.test.tsx index 0bdc95c..db9267d 100644 --- a/packages/durably-react/tests/client/use-runs.test.tsx +++ b/packages/durably-react/tests/client/use-runs.test.tsx @@ -20,7 +20,7 @@ const createMockRun = (overrides: Partial = {}): ClientRun => ({ output: null, error: null, currentStepIndex: 0, - stepCount: 0, + completedStepCount: 0, labels: {}, progress: null, createdAt: '2024-01-01T00:00:00.000Z', diff --git a/packages/durably-react/tests/types.test.ts b/packages/durably-react/tests/types.test.ts index cbcdcbd..a4898e5 100644 --- a/packages/durably-react/tests/types.test.ts +++ b/packages/durably-react/tests/types.test.ts @@ -209,7 +209,7 @@ describe('Type inference', () => { 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled' >() expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() }) it('UseRunsClientResult with generic type has typed runs', () => { diff --git a/packages/durably/src/migrations.ts b/packages/durably/src/migrations.ts index 4c394ac..fec85f8 100644 --- a/packages/durably/src/migrations.ts +++ b/packages/durably/src/migrations.ts @@ -29,6 +29,9 @@ const migrations: Migration[] = [ .addColumn('current_step_index', 'integer', (col) => col.notNull().defaultTo(0), ) + .addColumn('completed_step_count', 'integer', (col) => + col.notNull().defaultTo(0), + ) .addColumn('progress', 'text') .addColumn('output', 'text') .addColumn('error', 'text') diff --git a/packages/durably/src/schema.ts b/packages/durably/src/schema.ts index f38e41b..6ce9ebf 100644 --- a/packages/durably/src/schema.ts +++ b/packages/durably/src/schema.ts @@ -10,6 +10,7 @@ export interface RunsTable { idempotency_key: string | null concurrency_key: string | null current_step_index: number + completed_step_count: number progress: string | null // JSON: { current, total, message } output: string | null // JSON error: string | null diff --git a/packages/durably/src/storage.ts b/packages/durably/src/storage.ts index 1c12eae..eb24b40 100644 --- a/packages/durably/src/storage.ts +++ b/packages/durably/src/storage.ts @@ -1,4 +1,4 @@ -import { type Kysely, type SqlBool, sql } from 'kysely' +import { type Kysely, sql } from 'kysely' import { monotonicFactory } from 'ulidx' import type { Database } from './schema' @@ -40,7 +40,7 @@ export interface Run< idempotencyKey: string | null concurrencyKey: string | null currentStepIndex: number - stepCount: number + completedStepCount: number progress: { current: number; total?: number; message?: string } | null output: unknown | null error: string | null @@ -271,9 +271,7 @@ function validateLabels(labels: Record | undefined): void { } } -function rowToRun( - row: Database['durably_runs'] & { step_count?: number | bigint | null }, -): Run { +function rowToRun(row: Database['durably_runs']): Run { return { id: row.id, jobName: row.job_name, @@ -282,7 +280,7 @@ function rowToRun( idempotencyKey: row.idempotency_key, concurrencyKey: row.concurrency_key, currentStepIndex: row.current_step_index, - stepCount: Number(row.step_count ?? 0), + completedStepCount: row.completed_step_count, progress: row.progress ? JSON.parse(row.progress) : null, output: row.output ? JSON.parse(row.output) : null, error: row.error, @@ -447,6 +445,7 @@ export function createKyselyStore( idempotency_key: input.idempotencyKey ?? null, concurrency_key: input.concurrencyKey ?? null, current_step_index: 0, + completed_step_count: 0, progress: null, output: null, error: null, @@ -519,6 +518,7 @@ export function createKyselyStore( idempotency_key: input.idempotencyKey ?? null, concurrency_key: input.concurrencyKey ?? null, current_step_index: 0, + completed_step_count: 0, progress: null, output: null, error: null, @@ -554,38 +554,26 @@ export function createKyselyStore( async getRun(runId: string): Promise { const row = await db .selectFrom('durably_runs') - .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') - .selectAll('durably_runs') - .select((eb) => - eb.fn.count('durably_steps.id').as('step_count'), - ) - .where('durably_runs.id', '=', runId) - .groupBy('durably_runs.id') + .selectAll() + .where('id', '=', runId) .executeTakeFirst() return row ? (rowToRun(row) as T) : null }, async getRuns(filter?: RunFilter): Promise { - let query = db - .selectFrom('durably_runs') - .leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id') - .selectAll('durably_runs') - .select((eb) => - eb.fn.count('durably_steps.id').as('step_count'), - ) - .groupBy('durably_runs.id') + let query = db.selectFrom('durably_runs').selectAll() if (filter?.status) { - query = query.where('durably_runs.status', '=', filter.status) + query = query.where('status', '=', filter.status) } if (filter?.jobName) { if (Array.isArray(filter.jobName)) { if (filter.jobName.length > 0) { - query = query.where('durably_runs.job_name', 'in', filter.jobName) + query = query.where('job_name', 'in', filter.jobName) } } else { - query = query.where('durably_runs.job_name', '=', filter.jobName) + query = query.where('job_name', '=', filter.jobName) } } if (filter?.labels) { @@ -593,29 +581,20 @@ export function createKyselyStore( validateLabels(labels) for (const [key, value] of Object.entries(labels)) { if (value === undefined) continue - // Use indexed label table with JSON fallback for atomicity safety: - // if label rows haven't been written yet, fall back to JSON column - const jsonFallback = - backend === 'postgres' - ? sql`durably_runs.labels ->> ${key} = ${value}` - : sql`json_extract(durably_runs.labels, ${`$.${key}`}) = ${value}` query = query.where((eb) => - eb.or([ - eb.exists( - eb - .selectFrom('durably_run_labels') - .select(sql.lit(1).as('one')) - .whereRef('durably_run_labels.run_id', '=', 'durably_runs.id') - .where('durably_run_labels.key', '=', key) - .where('durably_run_labels.value', '=', value), - ), - jsonFallback, - ]), + eb.exists( + eb + .selectFrom('durably_run_labels') + .select(sql.lit(1).as('one')) + .whereRef('durably_run_labels.run_id', '=', 'durably_runs.id') + .where('durably_run_labels.key', '=', key) + .where('durably_run_labels.value', '=', value), + ), ) } } - query = query.orderBy('durably_runs.created_at', 'desc') + query = query.orderBy('created_at', 'desc') if (filter?.limit !== undefined) { query = query.limit(filter.limit) @@ -793,7 +772,7 @@ export function createKyselyStore( const row = result.rows[0] if (!row) return null - return rowToRun({ ...row, step_count: 0 }) + return rowToRun(row) } }) } @@ -833,7 +812,7 @@ export function createKyselyStore( .executeTakeFirst() if (!row) return null - return rowToRun({ ...row, step_count: 0 }) + return rowToRun(row) }, async renewLease( @@ -941,12 +920,13 @@ export function createKyselyStore( if (Number(insertResult.numAffectedRows) === 0) return null - // Advance step index for completed steps only + // Advance step index and increment completed_step_count for completed steps if (input.status === 'completed') { await trx .updateTable('durably_runs') .set({ current_step_index: input.index + 1, + completed_step_count: sql`completed_step_count + 1`, updated_at: completedAt, }) .where('id', '=', runId) diff --git a/packages/durably/tests/node/migration-consolidated.test.ts b/packages/durably/tests/node/migration-consolidated.test.ts index b1288c8..691fe29 100644 --- a/packages/durably/tests/node/migration-consolidated.test.ts +++ b/packages/durably/tests/node/migration-consolidated.test.ts @@ -32,6 +32,7 @@ describe('migration consolidated schema', () => { expect(columnNames).toContain('lease_owner') expect(columnNames).toContain('lease_expires_at') expect(columnNames).toContain('lease_generation') + expect(columnNames).toContain('completed_step_count') // Verify schema version const versions = await sql<{ version: number }>` diff --git a/packages/durably/tests/shared/storage.shared.ts b/packages/durably/tests/shared/storage.shared.ts index 7272870..07dc5bf 100644 --- a/packages/durably/tests/shared/storage.shared.ts +++ b/packages/durably/tests/shared/storage.shared.ts @@ -59,7 +59,7 @@ export function createStorageTests(createDialect: () => Dialect) { expect(run!.jobName).toBe('test-job') }) - it('returns stepCount as 0 for new run', async () => { + it('returns completedStepCount as 0 for new run', async () => { const created = await durably.storage.enqueue({ jobName: 'test-job', input: {}, @@ -68,10 +68,10 @@ export function createStorageTests(createDialect: () => Dialect) { const run = await durably.storage.getRun(created.id) expect(run).not.toBeNull() - expect(run!.stepCount).toBe(0) + expect(run!.completedStepCount).toBe(0) }) - it('returns stepCount reflecting completed steps', async () => { + it('returns completedStepCount reflecting completed steps', async () => { const created = await durably.storage.enqueue({ jobName: 'test-job', input: {}, @@ -108,10 +108,10 @@ export function createStorageTests(createDialect: () => Dialect) { const run = await durably.storage.getRun(created.id) expect(run).not.toBeNull() - expect(run!.stepCount).toBe(3) + expect(run!.completedStepCount).toBe(3) }) - it('returns stepCount in getRuns', async () => { + it('returns completedStepCount in getRuns', async () => { const run1 = await durably.storage.enqueue({ jobName: 'job-a', input: {}, @@ -163,8 +163,8 @@ export function createStorageTests(createDialect: () => Dialect) { const foundRun1 = runs.find((r) => r.id === run1.id) const foundRun2 = runs.find((r) => r.id === run2.id) - expect(foundRun1!.stepCount).toBe(2) - expect(foundRun2!.stepCount).toBe(1) + expect(foundRun1!.completedStepCount).toBe(2) + expect(foundRun2!.completedStepCount).toBe(1) }) it('returns null for non-existent run', async () => { @@ -387,7 +387,7 @@ export function createStorageTests(createDialect: () => Dialect) { expect(claimed!.id).toBe(created.id) expect(claimed!.status).toBe('leased') expect(claimed!.startedAt).not.toBeNull() - expect(claimed!.stepCount).toBe(0) + expect(claimed!.completedStepCount).toBe(0) // Verify run is now leased in DB const run = await durably.storage.getRun(created.id) diff --git a/website/api/create-durably.md b/website/api/create-durably.md index fd985d6..4787642 100644 --- a/website/api/create-durably.md +++ b/website/api/create-durably.md @@ -227,7 +227,7 @@ interface Run = Record> { idempotencyKey: string | null concurrencyKey: string | null currentStepIndex: number - stepCount: number + completedStepCount: number progress: { current: number; total?: number; message?: string } | null output: unknown | null error: string | null @@ -241,26 +241,26 @@ interface Run = Record> { } ``` -| Field | Type | Description | -| ------------------ | ----------------------------------------------------------------- | --------------------------------------------------------------- | -| `id` | `string` | Unique run ID | -| `jobName` | `string` | Name of the job | -| `input` | `unknown` | Input payload passed to the job | -| `status` | `'pending' \| 'leased' \| 'completed' \| 'failed' \| 'cancelled'` | Current run status | -| `idempotencyKey` | `string \| null` | Deduplication key | -| `concurrencyKey` | `string \| null` | Concurrency group key | -| `currentStepIndex` | `number` | Index of the current step being executed | -| `stepCount` | `number` | Total number of completed steps | -| `progress` | `{ current: number; total?: number; message?: string } \| null` | Latest progress report | -| `output` | `unknown \| null` | Return value of the job (when completed) | -| `error` | `string \| null` | Error message (when failed) | -| `labels` | `TLabels` (defaults to `Record`) | Key/value labels for filtering (type-safe when schema provided) | -| `leaseOwner` | `string \| null` | Worker ID that holds the lease (`null` when not leased) | -| `leaseExpiresAt` | `string \| null` | ISO timestamp when the lease expires (`null` when not leased) | -| `startedAt` | `string \| null` | ISO timestamp when the run started | -| `completedAt` | `string \| null` | ISO timestamp when the run completed or failed | -| `createdAt` | `string` | ISO timestamp when the run was created | -| `updatedAt` | `string` | ISO timestamp of the last update | +| Field | Type | Description | +| -------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------- | +| `id` | `string` | Unique run ID | +| `jobName` | `string` | Name of the job | +| `input` | `unknown` | Input payload passed to the job | +| `status` | `'pending' \| 'leased' \| 'completed' \| 'failed' \| 'cancelled'` | Current run status | +| `idempotencyKey` | `string \| null` | Deduplication key | +| `concurrencyKey` | `string \| null` | Concurrency group key | +| `currentStepIndex` | `number` | Index of the current step being executed | +| `completedStepCount` | `number` | Total number of completed steps | +| `progress` | `{ current: number; total?: number; message?: string } \| null` | Latest progress report | +| `output` | `unknown \| null` | Return value of the job (when completed) | +| `error` | `string \| null` | Error message (when failed) | +| `labels` | `TLabels` (defaults to `Record`) | Key/value labels for filtering (type-safe when schema provided) | +| `leaseOwner` | `string \| null` | Worker ID that holds the lease (`null` when not leased) | +| `leaseExpiresAt` | `string \| null` | ISO timestamp when the lease expires (`null` when not leased) | +| `startedAt` | `string \| null` | ISO timestamp when the run started | +| `completedAt` | `string \| null` | ISO timestamp when the run completed or failed | +| `createdAt` | `string` | ISO timestamp when the run was created | +| `updatedAt` | `string` | ISO timestamp of the last update | ### `getJob()` diff --git a/website/api/durably-react/types.md b/website/api/durably-react/types.md index 3e3ce78..9ed3834 100644 --- a/website/api/durably-react/types.md +++ b/website/api/durably-react/types.md @@ -69,7 +69,7 @@ interface ClientRun { output: unknown error: string | null currentStepIndex: number - stepCount: number + completedStepCount: number progress: Progress | null labels: Record startedAt: string | null @@ -78,21 +78,21 @@ interface ClientRun { } ``` -| Property | Type | Description | -| ------------------ | ------------------------ | ------------------------------- | -| `id` | `string` | Unique run ID | -| `jobName` | `string` | Name of the job | -| `status` | `RunStatus` | Current status | -| `input` | `unknown` | Input data | -| `output` | `unknown` | Job output (when completed) | -| `error` | `string \| null` | Error message (when failed) | -| `currentStepIndex` | `number` | Index of the current step | -| `stepCount` | `number` | Total number of completed steps | -| `progress` | `Progress \| null` | Current progress | -| `labels` | `Record` | Labels set at trigger time | -| `startedAt` | `string \| null` | ISO timestamp of start | -| `completedAt` | `string \| null` | ISO timestamp of completion | -| `createdAt` | `string` | ISO timestamp of creation | +| Property | Type | Description | +| -------------------- | ------------------------ | ------------------------------- | +| `id` | `string` | Unique run ID | +| `jobName` | `string` | Name of the job | +| `status` | `RunStatus` | Current status | +| `input` | `unknown` | Input data | +| `output` | `unknown` | Job output (when completed) | +| `error` | `string \| null` | Error message (when failed) | +| `currentStepIndex` | `number` | Index of the current step | +| `completedStepCount` | `number` | Total number of completed steps | +| `progress` | `Progress \| null` | Current progress | +| `labels` | `Record` | Labels set at trigger time | +| `startedAt` | `string \| null` | ISO timestamp of start | +| `completedAt` | `string \| null` | ISO timestamp of completion | +| `createdAt` | `string` | ISO timestamp of creation | ## StepRecord