Skip to content
← Public packages

@kentcdodds/codemod-runner

Run codemods over your own saved packages: scan, dry-run with real publish checks and diffs, apply, and revert.

src/ledger.ts

238 lines · 6.0 KB · TypeScript
import { packageStorage } from 'kody:runtime'
import { type CodemodFinding, type CodemodRef } from './contract.ts'

export type RunMode = 'scan' | 'dry-run' | 'apply' | 'revert'

export type ItemStatus =
	| 'detected'
	| 'clean'
	| 'dry_run_ok'
	| 'checks_failed'
	| 'needs_manual'
	| 'skipped_drift'
	| 'skipped_unpublished'
	| 'applied'
	| 'reverted'
	| 'failed'

export type RunItem = {
	packageId: string
	kodyId: string
	status: ItemStatus
	changedPaths: Array<string>
	findings: Array<CodemodFinding>
	failedChecks: Array<string>
	beforeCommit: string | null
	afterCommit: string | null
	diff: string | null
	error: string | null
}

export type StoredCodemodRef = {
	kodyId: string
	/** Missing only on runs created before scoped specifiers were required. */
	specifier?: string
	exportName: string
}

export type RunRecord = {
	id: string
	mode: RunMode
	codemod: StoredCodemodRef | null
	revertOfRunId: string | null
	status: 'running' | 'completed'
	createdAt: string
	updatedAt: string
	cursor: string | null
	items: Array<RunItem>
}

export type RunSummary = {
	id: string
	mode: RunMode
	codemod: StoredCodemodRef | null
	status: 'running' | 'completed'
	createdAt: string
}

const runsIndexKey = 'runs-index'
const maxIndexEntries = 100
const maxDiffChars = 16_000
const maxErrorChars = 2_000

function storage() {
	return packageStorage()
}

/**
 * Storage writes trigger a platform-side entitlement estimate that fans out
 * to every bucket the user owns and occasionally fails transiently on large
 * accounts ("bucket estimate could not be read"). Writes are retried with a
 * short backoff before giving up.
 */
async function setWithRetry(key: string, value: unknown): Promise<void> {
	let lastError: unknown = null
	for (let attempt = 1; attempt <= 3; attempt += 1) {
		try {
			await storage().set(key, value)
			return
		} catch (error) {
			lastError = error
			await new Promise<void>((resolve) => {
				setTimeout(resolve, 300 * attempt)
			})
		}
	}
	throw lastError instanceof Error ? lastError : new Error(String(lastError))
}

export function boundDiff(diff: string | null): string | null {
	if (diff == null) return null
	if (diff.length <= maxDiffChars) return diff
	return `${diff.slice(0, maxDiffChars)}\n… diff truncated (${diff.length} chars total)`
}

export function boundError(message: string): string {
	return message.length <= maxErrorChars
		? message
		: `${message.slice(0, maxErrorChars)}…`
}

export async function listRunSummaries(): Promise<Array<RunSummary>> {
	const index = (await storage().get(runsIndexKey)) as Array<RunSummary> | null
	return Array.isArray(index) ? index : []
}

export async function getRun(runId: string): Promise<RunRecord | null> {
	const record = (await storage().get(`run:${runId}`)) as RunRecord | null
	return record ?? null
}

export async function saveRun(record: RunRecord): Promise<void> {
	record.updatedAt = new Date().toISOString()
	await setWithRetry(`run:${record.id}`, record)
	const index = await listRunSummaries()
	const summary: RunSummary = {
		id: record.id,
		mode: record.mode,
		codemod: record.codemod,
		status: record.status,
		createdAt: record.createdAt,
	}
	const next = [
		summary,
		...index.filter((entry) => entry.id !== record.id),
	].slice(0, maxIndexEntries)
	await setWithRetry(runsIndexKey, next)
}

export async function createRun(input: {
	mode: RunMode
	codemod: CodemodRef | null
	revertOfRunId?: string | null
}): Promise<RunRecord> {
	const now = new Date().toISOString()
	const record: RunRecord = {
		id: crypto.randomUUID(),
		mode: input.mode,
		codemod: input.codemod
			? {
					kodyId: input.codemod.kodyId,
					specifier: input.codemod.specifier,
					exportName: input.codemod.exportName ?? 'codemod',
				}
			: null,
		revertOfRunId: input.revertOfRunId ?? null,
		status: 'running',
		createdAt: now,
		updatedAt: now,
		cursor: null,
		items: [],
	}
	await saveRun(record)
	return record
}

export async function resumeRun(input: {
	runId: string
	mode: RunMode
	codemod: CodemodRef | null
	revertOfRunId?: string | null
}): Promise<RunRecord> {
	const existing = await getRun(input.runId)
	if (!existing) {
		throw new Error(`Run "${input.runId}" was not found.`)
	}
	if (existing.mode !== input.mode) {
		throw new Error(
			`Run "${input.runId}" is mode "${existing.mode}", not "${input.mode}".`,
		)
	}
	if (input.codemod) {
		const exportName = input.codemod.exportName ?? 'codemod'
		if (
			existing.codemod?.kodyId !== input.codemod.kodyId ||
			existing.codemod?.specifier !== input.codemod.specifier ||
			existing.codemod?.exportName !== exportName
		) {
			throw new Error(
				`Run "${input.runId}" was created for a different codemod.`,
			)
		}
	}
	if (
		input.revertOfRunId != null &&
		existing.revertOfRunId !== input.revertOfRunId
	) {
		throw new Error(
			`Run "${input.runId}" reverts a different run than requested.`,
		)
	}
	return existing
}

export async function saveSnapshot(input: {
	runId: string
	packageId: string
	beforeCommit: string
	files: Record<string, string>
}): Promise<string> {
	const key = `snapshot:${input.runId}:${input.packageId}`
	await setWithRetry(key, {
		beforeCommit: input.beforeCommit,
		files: input.files,
	})
	return key
}

export async function getSnapshot(input: {
	runId: string
	packageId: string
}): Promise<{ beforeCommit: string; files: Record<string, string> } | null> {
	const snapshot = (await storage().get(
		`snapshot:${input.runId}:${input.packageId}`,
	)) as { beforeCommit: string; files: Record<string, string> } | null
	return snapshot ?? null
}

export async function markSourceItemReverted(input: {
	runId: string
	packageId: string
}): Promise<void> {
	const run = await getRun(input.runId)
	if (!run) return
	const item = run.items.find((entry) => entry.packageId === input.packageId)
	if (!item || item.status !== 'applied') return
	item.status = 'reverted'
	await saveRun(run)
}

export function summarizeItems(
	items: Array<RunItem>,
): Partial<Record<ItemStatus, number>> {
	const summary: Partial<Record<ItemStatus, number>> = {}
	for (const item of items) {
		summary[item.status] = (summary[item.status] ?? 0) + 1
	}
	return summary
}