Adopting a table into a plugin's

voltro db adopt — move an app's grown table into a plugin-owned one, with a snapshot, a count verify and the drop last.

An app that did not start on a green field already has a table for half the plugins it installs. voltro db adopt is the way into the plugin's table, so using the plugin does not mean running a second source of truth beside it.

voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts
voltro db adopt --from ai_flows --into _voltro_ai_flows --map ./ai-flows.map.ts --apply

Dry run unless you pass --apply. The interesting failure here is irreversible and the interesting output is the refusal, so nothing is written until you say so. A refused plan prints no steps at all, rather than a preview of something that will not happen.

The map file is yours

// ai-flows.map.ts
export default {
  map: {
    name:         'name',
    mode:         { expr: `CASE WHEN "allowDeviation" THEN 'agentic' ELSE 'deterministic' END` },
    costMicroUsd: { expr: '"totalCostCents" * 10000' },
  },
  leaveUnset: ['id'],
}

Read target: source — fill the plugin's column FROM this expression of mine, which is the direction the SQL runs. A string is a source column; { expr } is raw SQL against the source row, for the unit conversions and merged fields no tool can infer. Those are domain knowledge, and a tool inventing them silently corrupts data.

leaveUnset is how "the target's own default fills this" stops looking like "I forgot it".

What it refuses

  • a NOT NULL target column nobody maps to — the alternative is a silent zero that reads as real data forever after;
  • a target table that already holds rows — adopt MOVES rows into a table, it does not merge into one somebody else already wrote;
  • a typo on either side of the map.

A source column nobody carries across is reported but not fatal: dropping a dead column is deliberate often enough, and "I forgot this" and "I decided" look identical in a map file.

The order, and why the drop is last

  1. snapshot<table>__adopt_snapshot, a real table in the same database, so restoring is a statement rather than an operational procedure at 2am. It keeps the columns the adopt left behind.
  2. copy
  3. verify by count — this catches the one failure that is otherwise invisible: a WHERE inside a raw expression silently dropping rows.
  4. drop the source — last, and only if the counts match.

On a mismatch both tables stay and the command says so. The snapshot is never removed after a failed verify — it exists for exactly the run that goes wrong. --keep-source copies and verifies without dropping at all.

Ids, if the typeid prefixes differ

The dry run says so before anything runs, because discovering it after the copy is discovering it too late: every row gets a new id, so every reference to the old table has to be rewritten — including ids embedded in JSON columns.

Rewriting them is not automatic. Those ids live in your columns and inside your JSON, and only you know where. The translation table is what the command owes you; the rewrite is what you owe yourself. Doing it automatically is the one place here where being wrong would be silent.

After the move

Your rows are now in a table whose shape the framework evolves — and nothing special happens to them. They migrate exactly like every other row, through the same declarative differ. A narrowing can fail on your data, loudly, the same way it would on anybody's.