doba

Debugging

Tools for understanding what your registry is doing and why.

Debugging

When something goes wrong, you want the tools to tell you what happened, not just "no". doba gives you several ways to see what's going on.

debug mode

The fastest way to see what's happening. Pass debug: true to createRegistry and it logs everything to the console with a [doba] prefix.

const registry = createRegistry({
  schemas: {
    /* ... */
  },
  migrations: {
    /* ... */
  },
  debug: true,
})

Then just run your code:

[doba] warn v1->v2: defaulted email: generated from name
[doba]   step 1/2 v1->v2 "add-email" [ok] 0.5ms
[doba]   step 2/2 v2->v3 "rename-fields" [ok] 0.0ms
[doba] transform v1->v3 [ok] 4.6ms (v1 -> v2 -> v3)

That's it. Warnings, per-step timing, the full path it took, and whether it worked. If you have your own hooks defined, they still fire too. Debug mode just adds the console output on top.

Turn it off when you're done. Or leave it on during development, whatever works for you.

explain()

The explain method gives you a full breakdown of what path the registry would take between two schemas, without actually running any migrations.

const info = .('v1', 'v3')
const info: ExplainResult<"v1" | "v2" | "v3", "v1", "v3">
.(.) // Path: v1 -> v2 -> v3 (2 steps, total cost: 0) // 1. v1 -> v2 (cost: 0) [v1-to-v2-upgrade] // 2. v2 -> v3 (cost: 0) [v2-to-v3-upgrade]

What you get back

FieldTypeDescription
fromFromSource schema key (literal type).
toToTarget schema key (literal type).
pathreadonly Keys[] | nullResolved path, or null if none exists.
totalCostnumber | nullSum of edge costs along the path.
stepsreadonly ExplainStep[]Per-step breakdown with narrowed from/to.
summarystringHuman-readable summary.

Each step includes from, to, cost, and optional label and deprecated fields. See ExplainResult and ExplainStep for full type definitions.

When there's no path

Instead of just "no path found", it tells you what IS reachable and what's missing:

const info = registry.explain('v3', 'v1')

console.log(info.summary)
// No migration path from "v3" to "v1".
// Reachable from "v3": flat.
// No schema has a path to "v1".

That immediately tells you: v3 can only go to flat, and nothing points at v1. You know exactly which migration to add.

Same schema

registry.explain('v2', 'v2')
// { summary: '"v2" is already the target schema.', totalCost: 0, steps: [] }

Better error messages

When a transform fails because no path exists, the error now includes the same reachability info that explain provides.

const result = await registry.transform(data, 'v3', 'v1')

if (!result.ok) {
  console.log(result.issues[0].message)
  // no migration path from "v3" to "v1".
  // schemas reachable from "v3": flat;
  // no schema has a migration path to "v1"

  console.log(result.issues[0].meta)
  // {
  //   reachableFromSource: ['flat'],
  //   reachableToTarget: []
  // }
}

The meta field gives you the raw arrays if you want to do something programmatic with them. The message has it all in a readable format.

The reachability info is computed on demand when an error occurs, not upfront. It adds no overhead to successful transforms.

Lifecycle hooks

Hooks let you observe what the registry is doing without touching migration code. They're good for logging, metrics, or just figuring out why something is slow.

onTransform

Called after every transform completes, whether it succeeded or failed.

const registry = createRegistry({
  schemas: {
    /* ... */
  },
  migrations: {
    /* ... */
  },
  hooks: {
    onTransform: (info) => {
      console.log(
        `${info.from} -> ${info.to} [${info.ok ? 'ok' : 'fail'}] ${info.durationMs.toFixed(1)}ms`,
      )
    },
  },
})
FieldTypeDescription
fromKeysSource schema key.
toKeysTarget schema key.
pathreadonly Keys[] | nullResolved path (null on early errors).
durationMsnumberWall clock time for the whole transform.
okbooleanWhether the transform succeeded.

See TransformHookInfo for the full type.

onStep

Called after each individual migration step. Useful for finding which step in a long chain is slow or failing.

hooks: {
  onStep: (info) => {
    const tag = info.label ? ` (${info.label})` : ''
    console.log(`  step ${info.index + 1}/${info.total}: ${info.from} -> ${info.to}${tag} ${info.durationMs.toFixed(1)}ms`)
  },
}
FieldTypeDescription
fromKeysSource schema key for this step.
toKeysTarget schema key for this step.
indexnumberZero-based step index.
totalnumberTotal number of steps.
labelstringFrom migration metadata, if set.
durationMsnumberWall clock time for this step.
okbooleanWhether this step succeeded.

See StepHookInfo for the full type.

onWarning

Called when warnings are emitted from ctx.warn(), ctx.defaulted(), deprecated migration usage, or migration conflicts at construction time.

hooks: {
  onWarning: (message, from, to) => {
    console.log(`[${from}->${to}] ${message}`)
  },
}

Putting it together

Here's a minimal logging setup that gives you good visibility:

const registry = createRegistry({
  schemas: {
    /* ... */
  },
  migrations: {
    /* ... */
  },
  hooks: {
    onTransform: ({ from, to, ok, durationMs, path }) => {
      const status = ok ? 'ok' : 'FAIL'
      const route = path ? path.join(' -> ') : 'no path'
      console.log(`[transform] ${from} -> ${to} [${status}] ${durationMs.toFixed(1)}ms (${route})`)
    },
    onStep: ({ from, to, index, total, label, durationMs }) => {
      const tag = label ? ` "${label}"` : ''
      console.log(
        `  [step ${index + 1}/${total}] ${from} -> ${to}${tag} ${durationMs.toFixed(1)}ms`,
      )
    },
    onWarning: (msg, from, to) => {
      console.log(`  [warn] ${from} -> ${to}: ${msg}`)
    },
  },
})

Output looks something like:

  [warn] v1 -> v2: defaulted email: generated from name
  [step 1/2] v1 -> v2 "add-email" 0.5ms
  [step 2/2] v2 -> v3 "rename-fields" 0.0ms
[transform] v1 -> v3 [ok] 4.6ms (v1 -> v2 -> v3)

On this page