Background

I recently migrated an internal tool from Cloudflare Worker to self-hosting it on fly.io. The main reason driving this move is that we had ran into some weird issues with using fetch calls on the server-side. (Like custom domain would somehow cause it to not work so we had to redirect users to .pages.dev domain first, and POST for some reason does not work in our Rust-based wasm code.)

We were using the D1 database which is a SQLite wrapped in some easy to use APIs (both in JS and over HTTP, however, no DB GUI tools have support for these APIs natively yet so management is a bit annoying) so we need to move them. I decided to also just use a sqlite DB but store it on fly.io’s LiteFS. Importing data is easy, we just need to export the data as SQL, put it in a sqlite3 db and put that file into LiteFS. However, now we do not have a CLI to do migrations anymore. Therefore, I wrote a simple script to facilitate this. The code can be found at the bottom of the post.

Usage

To use the script, call it by: Usage: migration.sh <dbpath> <list|up>.

The list command list out all migrations and if they’re applied, and up runs the migrations. We just use the d1_migration table that Cloudflare D1 created, if you do not have it in your DB, you can create one via:

CREATE TABLE d1_migrations(
	id         INTEGER PRIMARY KEY AUTOINCREMENT,
	name       TEXT UNIQUE,
	applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

Use it with LiteFS

LiteFS provides a mount command that took care of setting itself up and also running your application. You can take advantage of that mechanism to run this migration script, to do so, add this to your exec section:

exec:
  - cmd: "/app/scripts/migration.sh /litefs/<dbname> up"
    if-candidate: true
  - cmd: "<your app command>"

The if-candidate option will ensure it runs on the primary node.

Source Code

#!/usr/bin/env bash
# SQLite3 Migration Tool
#
# Copyright 2024 Zero Cho
#
# MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
set -euo pipefail
 
function check_migration_applied {
  local migration="$1"
 
  if sqlite3 $DBPATH "SELECT 1 FROM d1_migrations WHERE name = '$migration'" | grep -q 1; then
    echo 1
  else
    echo 0
  fi
}
 
function print_help_and_exit() {
  echo "Usage: $0 <dbpath> <list|up>"
  exit 1
}
 
cd $(dirname $0)/..
MIGRATIONS=$(
  cd migrations
  ls -1 *.sql | sort -V
)
 
DBPATH="$1"
CMD="$2"
 
if [[ -z "$DBPATH" ]]; then
  print_help_and_exit
fi
 
if [[ ! -f "$DBPATH" ]]; then
  echo "Database file not found: $DBPATH"
  exit 1
fi
 
case $CMD in
list)
  echo "List of migrations:"
  echo
 
  for f in $MIGRATIONS; do
    applied=$(check_migration_applied $f)
    id=$(basename $f | cut -d '_' -f 1)
    echo -n "$id "
    if [[ "$applied" -eq 0 ]]; then
      echo -n "❌ "
    else
      echo -n "✅ "
    fi
    echo $f
  done
  ;;
up)
  for f in $MIGRATIONS; do
    applied=$(check_migration_applied $f)
    if [[ "$applied" -eq 0 ]]; then
      echo -n "Applying $f ..."
      set +e
      if output=$(sqlite3 $DBPATH <migrations/$f 2>&1); then
        if output=$(sqlite3 $DBPATH "INSERT INTO d1_migrations (name) VALUES ('$f')" 2>&1); then
          set -e
          echo " ✅"
        else
          set -e
          echo " ❌"
          echo
          echo "FATAL ERROR: Failed to update migration status. The migration have been APPLIED. Please manually inspect and fix the database."
          echo
          echo $output
          exit 1
        fi
      else
        set -e
        echo " ❌"
        echo
        echo "Migration failed:"
        echo
        echo $output
        exit 1
      fi
    fi
  done
  echo "All migrations applied successfully."
  ;;
*)
  print_help_and_exit
  ;;
esac