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
|