Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/skills/doc-check/scripts/find-stale.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ export function Dashboard() {
<LabelChips labels={run.labels} />
</td>
<td className="px-2 py-2">
{run.stepCount > 0 ? (
{run.completedStepCount > 0 ? (
<span className="text-xs text-gray-600">
{run.stepCount}
{run.completedStepCount}
</span>
) : (
<span className="text-gray-400">-</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ export function Dashboard() {
<LabelChips labels={run.labels} />
</td>
<td className="px-2 py-2">
{run.stepCount > 0 ? (
{run.completedStepCount > 0 ? (
<span className="text-xs text-gray-600">
{run.stepCount}
{run.completedStepCount}
</span>
) : (
<span className="text-gray-400">-</span>
Expand Down
4 changes: 2 additions & 2 deletions examples/spa-react-router/app/routes/_index/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ export function Dashboard() {
<LabelChips labels={run.labels} />
</td>
<td className="px-2 py-2">
{run.stepCount > 0 ? (
{run.completedStepCount > 0 ? (
<span className="text-xs text-gray-600">
{run.stepCount}
{run.completedStepCount}
</span>
) : (
<span className="text-gray-400">-</span>
Expand Down
4 changes: 2 additions & 2 deletions examples/spa-vite-react/src/components/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ export function Dashboard() {
<LabelChips labels={run.labels} />
</td>
<td className="px-2 py-2">
{run.stepCount > 0 ? (
{run.completedStepCount > 0 ? (
<span className="text-xs text-gray-600">
{run.stepCount}
{run.completedStepCount}
</span>
) : (
<span className="text-gray-400">-</span>
Expand Down
2 changes: 1 addition & 1 deletion packages/durably-react/tests/client/use-runs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const createMockRun = (overrides: Partial<ClientRun> = {}): ClientRun => ({
output: null,
error: null,
currentStepIndex: 0,
stepCount: 0,
completedStepCount: 0,
labels: {},
progress: null,
createdAt: '2024-01-01T00:00:00.000Z',
Expand Down
2 changes: 1 addition & 1 deletion packages/durably-react/tests/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ describe('Type inference', () => {
'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
>()
expectTypeOf<TestRun['currentStepIndex']>().toEqualTypeOf<number>()
expectTypeOf<TestRun['stepCount']>().toEqualTypeOf<number>()
expectTypeOf<TestRun['completedStepCount']>().toEqualTypeOf<number>()
})

it('UseRunsClientResult with generic type has typed runs', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/durably/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions packages/durably/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 25 additions & 45 deletions packages/durably/src/storage.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -271,9 +271,7 @@ function validateLabels(labels: Record<string, string> | 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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -554,68 +554,47 @@ export function createKyselyStore(
async getRun<T extends Run = Run>(runId: string): Promise<T | null> {
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<number>('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<T extends Run = Run>(filter?: RunFilter): Promise<T[]> {
let query = db
.selectFrom('durably_runs')
.leftJoin('durably_steps', 'durably_runs.id', 'durably_steps.run_id')
.selectAll('durably_runs')
.select((eb) =>
eb.fn.count<number>('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) {
const labels = filter.labels as Record<string, string>
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<SqlBool>`durably_runs.labels ->> ${key} = ${value}`
: sql<SqlBool>`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)
Expand Down Expand Up @@ -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)
}
})
}
Expand Down Expand Up @@ -833,7 +812,7 @@ export function createKyselyStore(
.executeTakeFirst()

if (!row) return null
return rowToRun({ ...row, step_count: 0 })
return rowToRun(row)
},

async renewLease(
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/durably/tests/node/migration-consolidated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>`
Expand Down
16 changes: 8 additions & 8 deletions packages/durably/tests/shared/storage.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand All @@ -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: {},
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
Expand Down
Loading