OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / migrate / runner.js

66 lines · javascript ref:
/**
 * Generic, reusable schema-migration runner — the standard upgrade path.
 *
 * Modeled on Keet (keet-core: @keet/system-digest.abi.version gate +
 * numbered migrations/{N}): store a monotonically-increasing schema
 * version, and on boot run every registered migration whose target
 * version is newer than the store's current version, in order, bumping
 * the version AFTER each succeeds. In-place (the migration reads legacy
 * state and writes new state in the SAME store) — never copy the store
 * (hypercore-storage's device-file guard rejects a moved corestore).
 *
 * Properties:
 *   - **version-gated**: a migration with version <= current is skipped.
 *   - **idempotent**: re-running at the latest version does nothing.
 *   - **resumable**: the version is bumped only after a migration's `up`
 *     resolves, so a crash mid-chain resumes from the last completed one.
 *   - **fail-stop**: a throwing `up` aborts the chain (the store is left
 *     at the last successfully-applied version, not a half-newer one).
 *
 * A migration is `{ version:number, name?:string, up(ctx):Promise }`
 * where `version` is the version it brings the store TO. Each `up` should
 * itself be idempotent (it may be retried after a crash before setVersion).
 *
 * opts:
 *   getVersion()  -> Promise<number|null>   current store schema version
 *   setVersion(v) -> Promise                 persist the new version
 *   migrations    -> [{ version, name, up }]
 *   ctx           -> opaque, passed to each up() (e.g. { corestore, orgs })
 *   log(msg)      -> optional progress
 *
 * Returns { startVersion, version, ran:[versions] }.
 */
export async function runMigrations (opts = {}) {
  const { getVersion, setVersion, migrations = [], ctx, log } = opts
  if (typeof getVersion !== 'function' || typeof setVersion !== 'function') {
    throw new Error('runMigrations requires { getVersion, setVersion }')
  }

  for (const m of migrations) {
    if (!m || typeof m.version !== 'number' || typeof m.up !== 'function') {
      throw new Error('each migration needs { version:number, up:function }')
    }
  }
  // Reject duplicate target versions — ambiguous ordering.
  const versions = migrations.map(m => m.version)
  if (new Set(versions).size !== versions.length) {
    throw new Error('duplicate migration versions: ' + versions.join(','))
  }

  const sorted = [...migrations].sort((a, b) => a.version - b.version)
  let current = (await getVersion()) || 0
  const startVersion = current
  const ran = []

  for (const m of sorted) {
    if (m.version <= current) continue
    if (log) log('migrate ' + current + ' -> ' + m.version + (m.name ? ' (' + m.name + ')' : ''))
    await m.up(ctx)              // in-place; a throw aborts the chain here
    await setVersion(m.version)  // bump only after success → resumable
    current = m.version
    ran.push(m.version)
  }

  return { startVersion, version: current, ran }
}