Skip to content

migrate: move an app and its data between platforms, either direction - #1019

Merged
ralyodio merged 8 commits into
masterfrom
worktree-sh1pt-migrate
Sep 25, 2026
Merged

ralyodio merged 8 commits into
masterfrom
worktree-sh1pt-migrate

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

sh1pt can already provision a machine — packages/cloud/* does connect, quote, provision, destroy. Provisioning is the easy half of a migration. The half that goes wrong is the data, and nothing moved any.

This adds @profullstack/sh1pt-migrate and sh1pt migrate.

Why it is bidirectional without twice the code

Two layers:

  • Platforms answer what have I got and what are the credentials — Railway, Supabase, Turso, Neon, PlanetScale, Fly, Render, Heroku, Vercel, a box over ssh. They never move a byte.
  • Engines move bytes — postgres, sqlite, redis, object-storage, files. They don't know which vendor is on either end.

supabase → ssh and ssh → supabase are therefore the same code path, and a new platform costs one inventory() rather than one adapter per existing platform. Direction is not a property of the system; it's which platform you named first.

compatibleKinds('turso', 'neon') returns [] — sqlite against postgres — in a millisecond, rather than at a cutover.

The shape comes from a migration that happened

crawlproof.com off Railway and Supabase cloud onto dev2: 4.7 GB of Postgres, 8,410 storage objects, ten pg_cron jobs, a realtime publication — and 2,928 rows holding absolute storage URLs that would have 404'd weeks later when the old project was deleted, long after everyone had called the migration a success.

So the absolute-URL rewrite is a first-class step, not a footnote. Every text-ish column is scanned (broad on purpose: a column called notes holding a pasted URL breaks exactly as badly as one called image_url), rewritten in one transaction, then asserted to be zero. The Supabase platform emits the exact --rewrite-host flag for its own hostname, so it's a line to copy rather than a thing to remember.

Phase order is enforced, not documented

check → bulk → freeze → delta → cutover → enable → verify

Scheduled jobs stop on the source before they start on the target, because cron firing on both sides is how a migration sends every customer a duplicate email. --until freeze runs the entire bulk copy and stops before anything goes down, which is how you rehearse against production. Dropping the source is not a phase — that's a decision a person makes days later, and this doesn't offer it.

The DNS cutover and cron steps are printed, not performed. Automating those would put back the exact decision the phase ordering exists to protect.

Safety, stated plainly

  • Nothing deletes. rclone copy never sync; no rsync --delete; no pg_restore --clean. A non-empty Postgres target is refused rather than overwritten.
  • Credentials never reach a plan file — describe()/reveal() pair, with a test asserting a rendered plan contains no password.
  • Credentials never reach argv — libpq env vars, RCLONE_CONFIG_*, TURSO_API_TOKEN. ps is world-readable and a dump runs for hours.
  • No shell, ever. Engine args come from a config file a person edits.
  • Resumable — append-only JSONL ledger, so an interrupted run can only truncate its last line.

Three bugs my own tests caught

  • A trailing having count(*) > 0 after a UNION ALL binds to the last SELECT only, so every other column would have reported regardless of count and the one real offender could sit unnoticed. Filter now sits outside a subquery, with a test pinning it.
  • '$PGURI' passed as a literal argument would never expand, because exec runs without a shell. Connection now goes through libpq's own environment variables — which is also the more secure answer.
  • rsync cannot copy remote to remote at all. It's a protocol limitation, not a missing flag. Now fails up front with an explanation instead of surfacing mid-cutover.

Verified end to end

$ sh1pt migrate platforms --from supabase
from Supabase:
  → dedicated / VPS over ssh   postgres, object-storage
  → Railway                    postgres
  → Turso                      nothing in common
  → Neon                       postgres

migrate plan --from supabase --to ssh produces a correct ordered plan with the five Supabase quirks as warnings, the URL step, and accurate blockers — pg_dump and rclone really are missing on this box, which is the preflight working as designed. A --rewrite-host without a scheme is rejected before anything connects.

156 tests, no network, no database. tsc clean across migrate and cli; biome clean.

Known limits, in the README rather than implied away

  • The Postgres delta covers inserts into tables with a timestamp column — not updates or deletes.
  • Redis has no delta; stop the writers first.
  • rsync needs one local side.
  • A Railway volume is only reachable from inside its service.

🤖 Generated with Claude Code

ralyodio and others added 6 commits September 25, 2026 07:44
sh1pt can already provision a machine (packages/cloud/*: connect, quote,
provision, destroy). Provisioning is the easy half of a migration. The
half that goes wrong is the data, and nothing here moved any.

The shape comes from a migration that actually happened rather than a
whiteboard: crawlproof.com off Railway and Supabase cloud onto a
dedicated box, which moved 4.7 GB of Postgres, 8,410 storage objects,
ten pg_cron jobs and a realtime publication, and which would have
quietly broken the site months later over 2,928 rows holding absolute
storage URLs.

The split that avoids N x M adapters, and makes the thing bidirectional
for free: PLATFORMS answer "what have I got and what are the
credentials" and never move a byte; ENGINES move bytes and neither know
nor care which vendor is on either end. Railway->dedicated and
dedicated->Railway are then the same code path, and a new platform costs
one inventory() rather than one adapter per existing platform. Direction
is just which platform you named first.

The planner is pure: given an inventory it returns an ordered plan and
never touches the network, so `migrate plan` is safe against production
and is fully testable. Phase order is the safety property and is
enforced rather than documented -- check, bulk, freeze, delta, cutover,
enable, verify. Scheduled jobs are stopped on the source before they are
started on the target, because a cron firing on both sides is how a
migration sends every customer a duplicate email. Dropping the source is
not a phase; it is a decision a person makes days later.

Credentials never reach a plan file: connections are a describe()/reveal()
pair, and a test asserts a rendered plan contains no password.

The Postgres engine dumps custom-format, no-owner, no-acl. It does NOT
pass --clean: that would make a re-run idempotent, at the price of being
one typo away from dropping a production database, so a non-empty target
is refused instead. Credentials go through libpq's own environment
variables rather than argv -- ps is world-readable and a dump runs for
hours. That is also the only thing that works, since exec runs without a
shell and nothing would expand a $VAR written into an argument.

The delta sync is honest about its limits: for tables carrying a
timestamp column it copies rows newer than the dump, which covers the
append-heavy tables that keep being written during a long dump. It does
not cover updates or deletes, and the planner says so rather than
implying a completeness it does not have. The column name is validated
as an identifier before it reaches psql -c.

48 tests, no network, no database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The object-storage engine is one engine with a remote per side rather
than one per vendor: S3, R2, B2, Spaces, Supabase storage and MinIO
differ by endpoint and auth style, not structurally. rclone does the
copying because concurrency, retries, resume, multipart thresholds and
checksum comparison are five things to get right and it already has
them; the planner checks for the binary up front so a missing rclone is
a blocker before the freeze rather than a failure during it.

Two deliberate choices. The remote is built entirely from
RCLONE_CONFIG_* environment variables, so no credential is written to
disk or appears in argv. And it runs `copy`, never `sync` -- sync
deletes whatever at the destination is not at the source, which for a
mistyped target is indistinguishable from wiping a live bucket. A
migration tool should not be able to delete.

Object storage is also the one engine that does not stage bytes: pulling
ten gigabytes down and pushing them back up doubles the transfer for no
benefit, so export writes a manifest and import copies remote to remote.
That needed the source resource at import time, which the Engine
contract now passes explicitly rather than smuggling through metadata,
which only holds primitives.

Then the rewrite. An app that stores a whole URL instead of a key leaves
rows pointing at the account you just left. Migrate everything, cut DNS
over, check the site: every image loads -- because the OLD account is
still serving them. The day it is closed, which is the entire point of
migrating and happens weeks later, all of them 404 at once and nothing
connects the outage to the migration. crawlproof.com had 2,928 such rows
across four tables, found by looking rather than by anything failing.

So it is a first-class step: find every text-ish column (broad on
purpose -- a column called `notes` holding a pasted URL breaks exactly
as badly as one called `image_url`, and guessing from names is how those
four tables would have been missed), count what is there, rewrite inside
one transaction, then assert zero remain.

Writing that assertion surfaced a real bug in my own first version: a
trailing `having count(*) > 0` after a UNION ALL binds to the final
SELECT only, so every other column would have reported regardless of its
count and the one real offender could sit unnoticed among them. The
union now goes in a subquery with the filter outside it, and a test
pins that the filter is not inside.

90 tests, still no network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SQLite and Turso are one engine because libSQL is SQLite with a server
in front, and both dump to the same SQL text -- which is also why
Turso->dedicated and dedicated->Turso are the same code path. The dump
is text rather than a file copy: copying the file does not work for a
hosted database at all, and a SQLite file copied while something writes
to it is a corrupt file rather than an error.

That needed a contract change. Those tools only read and write stdio, and
exec runs without a shell, so a `>` written into the arguments would be
passed to the program as a literal. ExecOptions now carries stdoutFile
and stdinFile, which also keeps a multi-gigabyte dump out of a string.

Redis is included with a warning attached: the right migration for a
cache is usually an empty one, and copying it moves stale entries and
buys downtime for data that is worthless by definition. It is here for
when it is not a cache -- a BullMQ queue with jobs in it, a session
store where copying nothing logs everyone out. It exports with --rdb,
which is consistent at a point in time, rather than walking keys with
SCAN, where keys move under you as you read. It has no delta, and the
planner turns that absence into the warning it should be. Import refuses
to go over the wire, because there is no supported way to push an RDB
into a running managed Redis, and says what to do instead rather than
doing it badly.

Files is rsync, for the volume or docroot that is neither a database nor
a bucket. Writing it turned up two things I had wrong. The endpoint
helper took a source/target parameter that selected between two
identical values -- dead code pretending to encode a rule. And rsync
cannot copy remote to remote at all; it is a protocol limitation, not a
missing flag, so a VPS-to-VPS move must relay through the machine
running the migration. That now fails up front with an explanation
instead of surfacing as "The source and destination cannot both be
remote" halfway through a cutover.

As with object storage, --delete is never passed anywhere.

110 tests, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Platforms resolve credentials and enumerate resources. They never move a
byte, which is what lets one implementation serve both directions.

ssh is a box you own, and it is `both` because "get off the cloud" and
"we tried bare metal and went back" are the same code path. A server has
no API to enumerate itself, so it is described in config rather than
discovered -- not a workaround: a directory has no metadata saying "this
is the uploads volume", and guessing from paths would be worse than
being told. It does not create databases remotely either; choosing disks
and versions and a backup story on someone's server is not a decision a
migration tool should make quietly.

Railway finds databases by RECOGNISING CONNECTION STRINGS IN VARIABLES
rather than asking for a list of databases, because that list does not
exist in that shape -- a Postgres service's DSN is DATABASE_URL on the
services that use it. Classification is by URL scheme, never by variable
name: DATABASE_URL is a convention and plenty of apps use PG_URL or
something bespoke, and silently skipping the database because it was
called the wrong thing is the worst failure available. The same DSN
injected into six services is moved once.

Supabase is a Postgres with a lot bolted on, and the bolted-on parts are
what make leaving it interesting. Five quirks are named up front, all
from the crawlproof migration: auth.users restores as data but GoTrue
and the JWT secret do not, so everyone is logged out unless the secret
comes too; RLS policies reference anon/authenticated/service_role, which
do not exist on a plain Postgres and must be created first; pg_cron jobs
restore already enabled and start firing immediately. It also emits the
exact --rewrite-host flag for its own public hostname, so the
absolute-URL trap is a copyable line rather than a thing to remember.

Turso, Neon, PlanetScale, Fly, Render, Heroku and Vercel differ
enormously as products and barely at all here: each hands over a DSN and
the engines do the rest. Six of them share one factory because writing a
file each would be six copies of twenty lines. Turso gets a real
implementation, since its CLI works on a database name plus a token
rather than a connection string.

compatibleKinds() is the whole design in one function: intersect what
the source holds with what the target accepts. Turso->Neon returns empty
(sqlite vs postgres) and says so in a millisecond instead of failing at
a cutover. Supabase->ssh and ssh->Supabase both return postgres, and
neither is a code path anyone wrote.

141 tests, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The planner decides what happens; the executor does it, and its only
real job is refusing to deviate. Two things make a migration
catastrophic rather than merely failed: running a later phase before an
earlier one, and carrying on past a step that was meant to be a gate.
Both are prevented here rather than trusted to the caller. A step whose
dependency did not run is skipped, not attempted -- an import with no
export would restore whatever happened to be left in staging, possibly
from a different migration.

`--until freeze` runs the whole bulk copy and stops before anything goes
down, which is how a migration is rehearsed against production.

The DNS cutover and the cron enable/disable steps are printed, not
performed. Pointing DNS at a new host on a caller's behalf, or starting
a scheduler at the wrong moment, is exactly the failure the phase
ordering exists to prevent; automating it would put the decision back
inside the tool.

Staging is an append-only JSONL ledger rather than a rewritten JSON
file. A process killed mid-write corrupts the file it was rewriting; it
can only truncate the last line of an append-only one, and a half-written
line fails to parse and is skipped. That is the difference between
resuming a four-hour copy and starting it again.

exec spawns without a shell, always. Engine arguments are built from
connection strings, bucket names and paths that come from a config file
a person edits, so a shell would make a bucket named `; rm -rf /` a
working attack and a path with a space a silent bug. The cost is that
`>` and `<` do not work, which is why redirection is an ExecOptions
field. ENOENT is translated to "<binary> is not installed or not on
PATH", since "spawn ENOENT" tells nobody anything.

Verified end to end against a real config. `sh1pt migrate platforms
--from supabase` prints what can receive what; `migrate plan --from
supabase --to ssh` produced a correct ordered plan with the five
Supabase quirks as warnings, the absolute-URL step, and accurate
blockers -- pg_dump and rclone really are missing on this box, which is
the preflight working. A `--rewrite-host` without a scheme is rejected
before anything connects.

156 tests, tsc clean across migrate and cli, biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

ThreatCrush Security Scan

53 finding(s)

HIGH/CRITICAL: 1 | MEDIUM: 17 | LOW: 35

Severity Rule Location
HIGH js-host-header-trust packages/bots/wechat/src/index.ts:405
MEDIUM redos-nested-quantifier packages/actions-fleet-core/src/action-pack/schema.ts:3
MEDIUM redos-nested-quantifier packages/core/src/setup-helpers.ts:583
MEDIUM sql-template-interpolation packages/migrate/src/engines/mysql.ts:133
MEDIUM sql-template-interpolation packages/migrate/src/engines/postgres.ts:276
MEDIUM sql-template-interpolation packages/migrate/src/transforms.ts:178
MEDIUM redos-nested-quantifier packages/policy/src/rules/bundle-id.ts:3
MEDIUM sql-string-concatenation packages/targets/deploy-wordpress/src/index.ts:154
MEDIUM redos-nested-quantifier packages/targets/desktop-linux/src/index.ts:19
MEDIUM redos-nested-quantifier packages/targets/desktop-mac/src/index.ts:15
MEDIUM redos-nested-quantifier packages/targets/desktop-steamos/src/index.ts:28
MEDIUM redos-nested-quantifier packages/targets/mobile-android/src/index.ts:9
MEDIUM redos-nested-quantifier packages/targets/mobile-ios/src/index.ts:11
MEDIUM redos-nested-quantifier packages/targets/tv-androidtv/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-firetv/src/index.ts:13
MEDIUM redos-nested-quantifier packages/targets/tv-tvos/src/index.ts:14
MEDIUM redos-nested-quantifier packages/targets/tv-webos/src/index.ts:26
MEDIUM js-unescaped-html-sink sites/sh1pt.com/app/blog/[slug]/page.tsx:76
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:25
LOW secret-generic-credential packages/affiliates/skimlinks/src/index.test.ts:71
LOW secret-generic-api-key packages/affiliates/sovrn/src/index.ts:28
LOW secret-generic-credential packages/agent-providers/opencode/src/__tests__/opencode.test.ts:99
LOW js-nosql-injection packages/ai/amazon-bedrock/src/index.test.ts:121
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:9
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:10
LOW secret-generic-credential packages/ai/amazon-bedrock/src/index.ts:11
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:58
LOW secret-generic-credential packages/bridges/matrix/src/index.ts:59
LOW secret-generic-credential packages/bridges/slack/src/index.test.ts:259
LOW secret-generic-credential packages/captcha/captchasolver/src/index.ts:34
LOW secret-generic-credential packages/cli/src/commands/secrets.ts:189
LOW secret-generic-credential packages/cloud/linode/src/index.ts:15
LOW secret-generic-credential packages/migrate/src/platforms/index.test.ts:116
LOW secret-generic-credential packages/observability/sentry/src/index.ts:15
LOW secret-generic-credential packages/outreach/producthunt/src/index.ts:103
LOW secret-generic-credential packages/promo/posthog/src/index.ts:23
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:35
LOW secret-generic-credential packages/scanners/threatcrush/test/scan-output.txt:40
LOW secret-database-url packages/scanners/threatcrush/test/scan-output.txt:54
LOW secret-generic-credential packages/security/snyk/src/index.ts:26
LOW secret-generic-credential packages/social/hashnode/src/index.ts:4
LOW secret-generic-credential packages/social/linkedin/src/index.ts:3
LOW secret-generic-credential packages/social/linkedin/src/index.ts:4
LOW secret-generic-credential packages/social/medium/src/index.ts:4
LOW secret-generic-credential packages/social/snapchat/src/index.ts:5
LOW secret-generic-credential packages/social/tiktok/src/index.ts:5
LOW secret-generic-credential packages/targets/plugin-vscode/src/index.test.ts:115
LOW secret-generic-credential packages/targets/registry-ans/src/index.test.ts:79
LOW secret-generic-credential packages/targets/registry-ans/src/index.ts:49
LOW secret-generic-credential packages/targets/sdk-pypi/src/index.test.ts:49

…and 3 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

Comment thread packages/migrate/src/platforms/index.test.ts Fixed
Comment thread packages/migrate/src/platforms/index.test.ts Fixed
Comment thread packages/migrate/src/platforms/index.test.ts Fixed
Comment thread packages/migrate/src/platforms/index.test.ts Fixed
Comment thread packages/migrate/src/platforms/index.test.ts Fixed
Comment thread packages/migrate/src/apply.test.ts Fixed
Comment thread packages/migrate/README.md Fixed
Comment thread packages/migrate/src/engines/postgres.ts Fixed
* offender could be buried. Filtering outside the subquery applies to all of
* them, which is the point of the assertion.
*/
return `select ref, n from (\n${parts.join('\nunion all\n')}\n) as remaining where n > 0 order by n desc`;
});

it('url-encodes a password with reserved characters', () => {
expect(supabaseDsn({ projectRef: 'r', dbPassword: 'p@ss/word' })).toContain('p%40ss%2Fword');
…tures

ThreatCrush flagged 20 new alerts on the last commit. Going through them
found one real bug and a lot of my own noise.

The real one: the delta's \copy interpolated the table name UNQUOTED.
The names come from information_schema so they are real tables rather
than attacker input, but a table called `user` or `order` is a reserved
word and an unquoted reference to it is a syntax error -- arriving
partway through a cutover, which is the worst possible time. A name
containing a quote or a space does not parse at all.

Fixing it properly meant changing the discovery query to return
table_schema and table_name as separate fields instead of pre-joining
them with a dot. Joining first makes `public.user` a single string that
cannot be quoted correctly: quoting the whole thing yields
`"public.user"`, one identifier with a dot in its name, which is a
different table that does not exist. Schema, table, the timestamp column
and the output path are now all quoted, with tests pinning each.

The other 17 highs were all mine and all fake: DSN literals in test
fixtures. Most did not need a password at all -- the tests are about
plan ordering and platform pairing, not credentials -- so those are gone,
which is a real hygiene improvement rather than a workaround. Four tests
genuinely are about credential handling (masking, url-decoding) and do
need one; those now interpolate a named FAKE_PASSWORD constant, which
says plainly what it is instead of leaving a string in source that a
scanner cannot distinguish from a leak.

The remaining sql-template-interpolation alert in transforms.ts is
composition of fragments that quoteIdent and quoteLiteral already built,
which the scanner cannot see through.

159 tests, tsc clean, biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'psql',
[
'--command',
`\\copy (select * from ${quoteIdent(schema)}.${quoteIdent(table)} where ${quoteIdent(columns)} > ${quoteLiteral(since.toISOString())}) to ${quoteLiteral(`${ctx.staging.dir}/${out}`)} with csv header`,
Shipping without this would have been dishonest. `mysql` was already a
ResourceKind, and both Railway and PlanetScale declared they hold it --
so the CLI advertised PlanetScale as a source while every plan involving
it blocked out with "nothing in this build can move a mysql". Advertised
and unusable is worse than absent.

mysqldump with the flags that matter, each of which is a failure
somebody has already had: --single-transaction, because without it a
multi-gigabyte dump locks every table for its duration, which is an
outage and rather defeats copying while the source is live;
--set-gtid-purged=OFF, because a dump carrying GTID state refuses to
load into a server with its own replication history and the error names
neither the flag nor the cause; --no-tablespaces, because writing
tablespace clauses needs PROCESS privilege that managed providers do not
grant, and its absence fails the dump rather than degrading it.

The password goes in MYSQL_PWD rather than --password=, same reasoning
as libpq: argv is world-readable via ps for the hours a dump runs. The
client's warning that MYSQL_PWD is insecure on shared machines is true
and still strictly better than the alternative.

Verification reports estimated row counts WITHOUT failing on them.
information_schema.table_rows is an estimate on InnoDB, not a count;
treating it as exact would fail every verification that ever ran. Only a
table missing outright is a problem.

Then running the CLI caught a second gap of my own making: the ssh
platform never declared mysql, so `migrate platforms --from planetscale`
reported "nothing in common" against a dedicated box -- the exact
get-off-the-cloud move the tool exists for. ssh now holds mysql, with a
test pinning the pairing in both directions.

177 tests, tsc clean, biome clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'--batch',
'--skip-column-names',
'--execute',
`select count(*) from information_schema.tables where table_schema = ${quoteLiteral(databaseName(to))}`,
@ralyodio
ralyodio marked this pull request as ready for review September 25, 2026 08:31
@ralyodio
ralyodio merged commit 32f938c into master Sep 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants