Skip to content
← Public packages

@kentcdodds/package-storage-migrations

Ordered idempotent packageStorage schema migrations + isolate-memoized runner. Caller always passes storage.

src/index.ts

153 lines · 5.1 KB · TypeScript
/**
 * Ordered, idempotent `packageStorage` schema migrations.
 *
 * Pattern: store an integer schema version under `versionKey`, then run each
 * migration with `version > current` exactly once (in ascending order). Safe to
 * call on every Worker request — memoize with `createMigrationRunner` so a
 * single isolate only runs the work once.
 *
 * **Critical:** callers must always pass their own `storage` (typically
 * `packageStorage()` from the calling package). This module never calls
 * `packageStorage()` itself — product writes must land in the caller's bucket.
 */

/** Minimal storage surface used by migrations (matches `packageStorage()`). */
export type PackageStorageLike = {
	get(key: string): Promise<unknown>
	set(key: string, value: unknown): Promise<void>
	delete?(key: string): Promise<void>
}

export type StorageMigration = {
	/** Monotonic integer version this step advances the schema to. */
	version: number
	/** Short label for logs / smoke results. */
	name?: string
	up: (storage: PackageStorageLike) => Promise<void> | void
}

export type RunPackageStorageMigrationsInput = {
	/** Caller's storage bucket — never omit; this package does not create one. */
	storage: PackageStorageLike
	migrations: Array<StorageMigration>
	/** Key that stores the current schema version integer (default `pak:schema-version`). */
	versionKey?: string
}

export type AppliedMigration = {
	version: number
	name?: string
}

export type RunPackageStorageMigrationsResult = {
	fromVersion: number
	toVersion: number
	applied: Array<AppliedMigration>
}

function readVersion(raw: unknown): number {
	if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.floor(raw)
	if (typeof raw === 'string' && raw.trim() !== '') {
		const n = Number(raw)
		if (Number.isFinite(n) && n >= 0) return Math.floor(n)
	}
	return 0
}

/**
 * Run pending migrations in ascending version order. Idempotent: already-applied
 * versions are skipped; re-running after success is a no-op.
 *
 * @param input.storage - Caller's `packageStorage()` (or compatible). Required.
 * @param input.migrations - Ordered or unordered steps; sorted by `version` ascending.
 * @param input.versionKey - Schema version key (default `pak:schema-version`).
 * @returns `{ fromVersion, toVersion, applied }` describing what ran.
 *
 * @example
 * import { runPackageStorageMigrations } from 'kody:@kentcdodds/package-storage-migrations'
 * import { packageStorage } from 'kody:runtime'
 *
 * await runPackageStorageMigrations({
 *   storage: packageStorage(),
 *   versionKey: 'my-app:schema-version',
 *   migrations: [
 *     {
 *       version: 1,
 *       name: 'notes-to-document',
 *       async up(storage) {
 *         const legacy = await storage.get('notes-v1')
 *         if (Array.isArray(legacy)) {
 *           await storage.set('notes', { items: legacy })
 *           await storage.delete?.('notes-v1')
 *         }
 *       },
 *     },
 *   ],
 * })
 */
export async function runPackageStorageMigrations(
	input: RunPackageStorageMigrationsInput,
): Promise<RunPackageStorageMigrationsResult> {
	const versionKey = input.versionKey ?? 'pak:schema-version'
	const sorted = [...input.migrations].sort((a, b) => a.version - b.version)

	for (const step of sorted) {
		if (!Number.isInteger(step.version) || step.version < 1) {
			throw new Error(`Migration version must be an integer >= 1 (got ${String(step.version)})`)
		}
	}
	for (let i = 1; i < sorted.length; i++) {
		if (sorted[i]!.version === sorted[i - 1]!.version) {
			throw new Error(`Duplicate migration version ${sorted[i]!.version}`)
		}
	}

	const fromVersion = readVersion(await input.storage.get(versionKey))
	let current = fromVersion
	const applied: Array<AppliedMigration> = []

	for (const step of sorted) {
		if (step.version <= current) continue
		await step.up(input.storage)
		current = step.version
		await input.storage.set(versionKey, current)
		applied.push({ version: step.version, name: step.name })
	}

	return { fromVersion, toVersion: current, applied }
}

/**
 * Memoize migrations for one Worker isolate — call `ensure()` at the start of
 * handlers (or once from middleware) so schema work runs at most once per boot.
 *
 * @param input - Same shape as `runPackageStorageMigrations` (caller must pass `storage`).
 * @returns `ensureMigrated()` that shares one in-flight / completed promise per isolate.
 *
 * @example
 * import { createMigrationRunner } from 'kody:@kentcdodds/package-storage-migrations'
 * import { packageStorage } from 'kody:runtime'
 *
 * const ensureSchema = createMigrationRunner({
 *   storage: packageStorage(),
 *   versionKey: 'my-app:schema-version',
 *   migrations: [ ... ],
 * })
 * // in a loader/action:
 * await ensureSchema()
 */
export function createMigrationRunner(input: RunPackageStorageMigrationsInput) {
	let pending: Promise<RunPackageStorageMigrationsResult> | null = null
	return function ensureMigrated() {
		if (!pending) {
			pending = runPackageStorageMigrations(input).catch((error) => {
				pending = null
				throw error
			})
		}
		return pending
	}
}

/** Primary callable export for this package. */
export default runPackageStorageMigrations