← 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