Skip to main content
A migration is a numbered .sql file on disk. telnyx-edge storage sqldb migrations creates those files, applies the ones that have not run yet, and records what it applied — in a table inside the database, not in a state file beside your code. Three commands cover the whole workflow:
--remote is required on list and apply. There is no local database to run against — local execution is not supported, and there is no --local flag. create is the exception: it only writes a file, so it needs no authentication and no network.

A Worked Example

Start with a provisioned database named links-db. Create the first migration:
The file is a stub with a header comment. Fill in the schema:
Add a second migration the same way:
Before applying, check what is outstanding:
Apply them:
list now reflects the new state:
Running apply again does nothing — it only ever runs what is pending:

The Ledger Lives in the Database

Applied state is tracked in a table called sqldb_migrations, created inside the database on the first apply:
Read it like any other table:
  • name is the filename, not the label — renaming a file after it has been applied makes it pending again.
  • applied_at is an RFC 3339 UTC timestamp generated by the CLI at apply time, so it reflects the clock of the machine that ran the command.
  • id is assigned by SQLite in apply order.
Because the ledger is a table in the database, there is no external state file to commit, share, or lose. Any checkout of the migration files, on any machine, sees the same applied set. Dropping the table makes every file pending again. list never creates the table — it is read-only, and treats a missing sqldb_migrations as “nothing applied yet”. Only apply creates it.

Apply Is Not All-or-Nothing

Each file is submitted as one script in one call: the migration body, then the INSERT into sqldb_migrations that records it. A script is applied atomically: if any statement in it fails, the whole script rolls back — a script that creates a table, inserts a row, and then hits a bad statement leaves no table behind. So a migration file either applies completely and is recorded, or changes nothing and is not recorded. There is no state in between for a single file. Across the batch there is no atomicity at all. If the third of five migrations fails, the first two stay applied and the command stops:
The line relays the API error verbatim, so the SQLite message — duplicate column name: owner here — arrives embedded in a longer transport string rather than on its own. The command exits non-zero with an error that begins migration 0003_add_owner.sql failed after applying 2 migration(s):, followed by the same relayed API error. The database is left partway through the batch — schema changes from 0001 and 0002 are live, and 0003 is not recorded, so apply will try it again. Recover by fixing forward:
  1. Run migrations list <database> --remote to see exactly where the batch stopped.
  2. Correct the file that failed. It is still pending, and apply runs pending files in numeric order, so the next run reaches it before anything numbered after it — a later migration cannot reconcile around it. Edit that file until it succeeds, or empty it out if the change is no longer wanted.
  3. Run apply --remote again. Only the still-pending files run, beginning with the one that failed.
  4. Prefer statements that are safe to retry — CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, INSERT OR IGNORE — because a retry re-runs the failed file from the top.
Never edit a migration that has already been applied. The ledger only knows the filename, so an edited file stays applied and its new contents never run. Nothing coordinates two apply runs against the same database. apply reads the ledger and then submits each pending file as its own call, so two runs started at the same time — two CI jobs, or a pipeline racing an operator — can both see a file as pending and run it twice. Run migrations from one place, and write them so that a second run is harmless.
Foreign keys are on and enforced. A migration that rebuilds or drops a referenced table has to order its statements so no statement leaves a dangling reference — a foreign-key violation fails the file like any other error.

Files and Directories

create numbers files with a 4-digit zero-padded, auto-incrementing prefix (0001_, 0002_, …), taking the next number from the highest prefix already in the directory. The label is the name argument with every run of non-alphanumeric characters collapsed to _, and leading or trailing runs dropped. Files land in migrations/<database> by default, with the same collapsing applied to the database argument — links-db becomes migrations/links_db:
Per-database directories keep two databases from sharing one file sequence. The directory name is derived from the argument as given, so passing the id to one command and the name to another points at two different directories — pick one form and use it consistently. Collapsing can also map two different names onto one directory: app-db and app--db both resolve to migrations/app_db. Give one of them an explicit --migrations-dir so their files stay apart. Override the location with --migrations-dir:
Any file matching a numeric prefix, an underscore, and a .sql suffix is picked up, whatever the digit count. Files run in numeric-prefix order, then filename order.

Machine-Readable Output

apply --json always emits the same shape, including on a no-op run ({"applied": []}), and emits no JSON at all on failure. One wrinkle in the current CLI: the JSON — for apply and list both — is written to stderr, not stdout, so capture it with 2>&1 >/dev/null rather than piping stdout:
list --json emits one entry per local file: