OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      bcb96bdb3d56
DATE      2025-12-04
SUBJECT   ci: bucket database
FILES     6 CHANGED
HASH      bcb96bdb3d56
DATE      2025-12-04
SUBJECT   ci: bucket database
FILES     6 CHANGED
 

diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-previ
ew.yml
new file mode 100644
index 0000000..c2595e0
--- /dev/null
+++ b/.github/workflows/deploy-preview.yml
@@ -0,0 +1,58 @@
+name: Deploy Preview
+
+on:
+  workflow_dispatch:
+
+concurrency:
+  group: database-builder-preview
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+env:
+  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Install Nix
+        uses: cachix/install-nix-action@v27
+
+      - name: Authenticate with Blizzard API
+        id: blizzard_auth
+        run: |
+          echo "Authenticating with Blizzard API..."
+
+          RESPONSE=$(curl -s -X POST "https://oauth.battle.net/token" \
+            -H "Content-Type: application/x-www-form-urlencoded" \
+            -d "grant_type=client_credentials" \
+            -u "${{ secrets.BLIZZARD_CLIENT_ID }}:${{ secrets.BLIZZARD_CLIENT_SEC
RET }}")
+
+          ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
+
+          if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
+            echo "Failed to authenticate with Blizzard API"
+            exit 1
+          fi
+
+          echo "::add-mask::$ACCESS_TOKEN"
+          echo "Successfully authenticated with Blizzard API"
+          echo "BLIZZARD_API_TOKEN=$ACCESS_TOKEN" >> $GITHUB_ENV
+
+      - name: Deploy ookstats (preview)
+        env:
+          BLIZZARD_API_TOKEN: ${{ env.BLIZZARD_API_TOKEN }}
+          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          AWS_ENDPOINT_URL: ${{ vars.AWS_ENDPOINT_URL }}
+          AWS_BUCKET: ${{ vars.AWS_BUCKET }}
+          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+        run: nix run .#ookstats-deploy -- --preview

diff --git a/.github/workflows/deploy-previe
w.yml b/.github/workflows/deploy-preview.yml
new file mode 100644
index 0000000..c2595e0
--- /dev/null
+++ b/.github/workflows/deploy-preview.yml
@@ -0,0 +1,58 @@
+name: Deploy Preview
+
+on:
+  workflow_dispatch:
+
+concurrency:
+  group: database-builder-preview
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+env:
+  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID 
}}
+  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PRO
JECT_ID }}
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }
}
+
+      - name: Install Nix
+        uses: cachix/install-nix-action@v27
+
+      - name: Authenticate with Blizzard AP
I
+        id: blizzard_auth
+        run: |
+          echo "Authenticating with Blizzar
d API..."
+
+          RESPONSE=$(curl -s -X POST "https
://oauth.battle.net/token" \
+            -H "Content-Type: application/x
-www-form-urlencoded" \
+            -d "grant_type=client_credentia
ls" \
+            -u "${{ secrets.BLIZZARD_CLIENT
_ID }}:${{ secrets.BLIZZARD_CLIENT_SECRET }}
")
+
+          ACCESS_TOKEN=$(echo "$RESPONSE" |
 jq -r '.access_token')
+
+          if [ "$ACCESS_TOKEN" = "null" ] |
| [ -z "$ACCESS_TOKEN" ]; then
+            echo "Failed to authenticate wi
th Blizzard API"
+            exit 1
+          fi
+
+          echo "::add-mask::$ACCESS_TOKEN"
+          echo "Successfully authenticated 
with Blizzard API"
+          echo "BLIZZARD_API_TOKEN=$ACCESS_
TOKEN" >> $GITHUB_ENV
+
+      - name: Deploy ookstats (preview)
+        env:
+          BLIZZARD_API_TOKEN: ${{ env.BLIZZ
ARD_API_TOKEN }}
+          AWS_ACCESS_KEY_ID: ${{ secrets.AW
S_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secret
s.AWS_SECRET_ACCESS_KEY }}
+          AWS_ENDPOINT_URL: ${{ vars.AWS_EN
DPOINT_URL }}
+          AWS_BUCKET: ${{ vars.AWS_BUCKET }
}
+          VERCEL_TOKEN: ${{ secrets.VERCEL_
TOKEN }}
+        run: nix run .#ookstats-deploy -- -
-preview
 

diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml
new file mode 100644
index 0000000..5820e8c
--- /dev/null
+++ b/.github/workflows/deploy-prod.yml
@@ -0,0 +1,60 @@
+name: Deploy Production
+
+on:
+  workflow_dispatch:
+  # schedule:
+  #   - cron: "0 */2 * * *"
+
+concurrency:
+  group: database-builder
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+env:
+  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Install Nix
+        uses: cachix/install-nix-action@v27
+
+      - name: Authenticate with Blizzard API
+        id: blizzard_auth
+        run: |
+          echo "Authenticating with Blizzard API..."
+
+          RESPONSE=$(curl -s -X POST "https://oauth.battle.net/token" \
+            -H "Content-Type: application/x-www-form-urlencoded" \
+            -d "grant_type=client_credentials" \
+            -u "${{ secrets.BLIZZARD_CLIENT_ID }}:${{ secrets.BLIZZARD_CLIENT_SEC
RET }}")
+
+          ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token')
+
+          if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then
+            echo "Failed to authenticate with Blizzard API"
+            exit 1
+          fi
+
+          echo "::add-mask::$ACCESS_TOKEN"
+          echo "Successfully authenticated with Blizzard API"
+          echo "BLIZZARD_API_TOKEN=$ACCESS_TOKEN" >> $GITHUB_ENV
+
+      - name: Deploy ookstats
+        env:
+          BLIZZARD_API_TOKEN: ${{ env.BLIZZARD_API_TOKEN }}
+          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          AWS_ENDPOINT_URL: ${{ vars.AWS_ENDPOINT_URL }}
+          AWS_BUCKET: ${{ vars.AWS_BUCKET }}
+          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+        run: nix run .#ookstats-deploy

diff --git a/.github/workflows/deploy-prod.y
ml b/.github/workflows/deploy-prod.yml
new file mode 100644
index 0000000..5820e8c
--- /dev/null
+++ b/.github/workflows/deploy-prod.yml
@@ -0,0 +1,60 @@
+name: Deploy Production
+
+on:
+  workflow_dispatch:
+  # schedule:
+  #   - cron: "0 */2 * * *"
+
+concurrency:
+  group: database-builder
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+env:
+  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID 
}}
+  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PRO
JECT_ID }}
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }
}
+
+      - name: Install Nix
+        uses: cachix/install-nix-action@v27
+
+      - name: Authenticate with Blizzard AP
I
+        id: blizzard_auth
+        run: |
+          echo "Authenticating with Blizzar
d API..."
+
+          RESPONSE=$(curl -s -X POST "https
://oauth.battle.net/token" \
+            -H "Content-Type: application/x
-www-form-urlencoded" \
+            -d "grant_type=client_credentia
ls" \
+            -u "${{ secrets.BLIZZARD_CLIENT
_ID }}:${{ secrets.BLIZZARD_CLIENT_SECRET }}
")
+
+          ACCESS_TOKEN=$(echo "$RESPONSE" |
 jq -r '.access_token')
+
+          if [ "$ACCESS_TOKEN" = "null" ] |
| [ -z "$ACCESS_TOKEN" ]; then
+            echo "Failed to authenticate wi
th Blizzard API"
+            exit 1
+          fi
+
+          echo "::add-mask::$ACCESS_TOKEN"
+          echo "Successfully authenticated 
with Blizzard API"
+          echo "BLIZZARD_API_TOKEN=$ACCESS_
TOKEN" >> $GITHUB_ENV
+
+      - name: Deploy ookstats
+        env:
+          BLIZZARD_API_TOKEN: ${{ env.BLIZZ
ARD_API_TOKEN }}
+          AWS_ACCESS_KEY_ID: ${{ secrets.AW
S_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secret
s.AWS_SECRET_ACCESS_KEY }}
+          AWS_ENDPOINT_URL: ${{ vars.AWS_EN
DPOINT_URL }}
+          AWS_BUCKET: ${{ vars.AWS_BUCKET }
}
+          VERCEL_TOKEN: ${{ secrets.VERCEL_
TOKEN }}
+        run: nix run .#ookstats-deploy
 

diff --git a/.github/workflows/vercel-build-deploy-prod.yml b/.github/workflows/ve
rcel-build-deploy-prod.yml.old
similarity index 100%
rename from .github/workflows/vercel-build-deploy-prod.yml
rename to .github/workflows/vercel-build-deploy-prod.yml.old

diff --git a/.github/workflows/vercel-build-
deploy-prod.yml b/.github/workflows/vercel-b
uild-deploy-prod.yml.old
similarity index 100%
rename from .github/workflows/vercel-build-d
eploy-prod.yml
rename to .github/workflows/vercel-build-dep
loy-prod.yml.old
 

diff --git a/nix/apps/default.nix b/nix/apps/default.nix
index 2b45327..32b45e6 100644
--- a/nix/apps/default.nix
+++ b/nix/apps/default.nix
@@ -42,6 +42,10 @@
           type = "app";
           program = lib.getExe config.packages.ookstats;
         };
+        ookstats-deploy = {
+          type = "app";
+          program = lib.getExe config.packages.ookstats-deploy;
+        };
         default = config.apps.ookstats;
         allSimulations = {
           type = "app";

diff --git a/nix/apps/default.nix b/nix/apps
/default.nix
index 2b45327..32b45e6 100644
--- a/nix/apps/default.nix
+++ b/nix/apps/default.nix
@@ -42,6 +42,10 @@
           type = "app";
           program = lib.getExe config.packa
ges.ookstats;
         };
+        ookstats-deploy = {
+          type = "app";
+          program = lib.getExe config.packa
ges.ookstats-deploy;
+        };
         default = config.apps.ookstats;
         allSimulations = {
           type = "app";
 

diff --git a/nix/pkgs/default.nix b/nix/pkgs/default.nix
index 17bf37a..bc25609 100644
--- a/nix/pkgs/default.nix
+++ b/nix/pkgs/default.nix
@@ -31,13 +31,14 @@
     };
 
     ookstats = callPackage ./ookstats {inherit go-libsql-src wowsims-db;};
+    ookstats-deploy = callPackage ./ookstats-deploy {inherit ookstats;};
   in {
     packages =
       simulationPackages
       // racePackages
       // trinketPackages
       // {
-        inherit ookstats;
+        inherit ookstats ookstats-deploy;
         allSimulations = simulation.generateAllSimulationsScript pkgs;
         simInputs = simulation.generateSimInputs pkgs;
         testRaid = callPackage ./testRaid.nix {

diff --git a/nix/pkgs/default.nix b/nix/pkgs
/default.nix
index 17bf37a..bc25609 100644
--- a/nix/pkgs/default.nix
+++ b/nix/pkgs/default.nix
@@ -31,13 +31,14 @@
     };
 
     ookstats = callPackage ./ookstats {inhe
rit go-libsql-src wowsims-db;};
+    ookstats-deploy = callPackage ./ookstat
s-deploy {inherit ookstats;};
   in {
     packages =
       simulationPackages
       // racePackages
       // trinketPackages
       // {
-        inherit ookstats;
+        inherit ookstats ookstats-deploy;
         allSimulations = simulation.generat
eAllSimulationsScript pkgs;
         simInputs = simulation.generateSimI
nputs pkgs;
         testRaid = callPackage ./testRaid.n
ix {
 

diff --git a/nix/pkgs/ookstats-deploy/default.nix b/nix/pkgs/ookstats-deploy/defau
lt.nix
new file mode 100644
index 0000000..2cc5542
--- /dev/null
+++ b/nix/pkgs/ookstats-deploy/default.nix
@@ -0,0 +1,415 @@
+{
+  writeShellApplication,
+  ookstats,
+  awscli2,
+  coreutils,
+  jq,
+  nodejs,
+  nodePackages,
+  sqlite,
+  gum,
+}:
+writeShellApplication {
+  name = "ookstats-deploy";
+
+  runtimeInputs = [
+    ookstats
+    awscli2
+    coreutils
+    jq
+    nodejs
+    nodePackages.vercel
+    sqlite
+    gum
+  ];
+
+  text =
+    /*
+    bash
+    */
+    ''
+
+      # configuration
+
+      # how many entries per leaderboard page
+      PAGE_SIZE=25
+
+      # how many concurrent connections
+      CONCURRENCY=20
+
+      # player search api shard size
+      SHARD_SIZE=5000
+
+      # where to output the static API
+      API_OUT=web/public
+
+      REGIONS=us,tw,kr,eu
+
+      DB_PATH="db/current.db"
+      HASH_PATH="db/current.db.sha256"
+      LOCK_PATH="db/db.lock"
+      BACKUP_PREFIX="db/backups/"
+      LOCAL_DB="local.db"
+      LOCK_TIMEOUT=7200  # 2 hours in seconds
+
+      # log helper function
+      log() {
+        local type="$1"
+        local prefix="$2"
+        local message="$3"
+
+        if [ "$type" = "verbose" ]; then
+          if [ -n "$verbose" ]; then
+            gum log --structured --level debug "$message" --prefix "$prefix"
+          fi
+          return
+        fi
+        if [ "$type" = "abort" ]; then
+          gum log --structured --level error "$message" --prefix "$prefix"
+          exit 1
+        else
+          gum log --structured --level "$type" "$message" --prefix "$prefix"
+        fi
+      }
+
+
+      # configuration
+
+      # parse arguments
+      DRY_RUN=false
+      ENVIRONMENT="production"
+
+      for arg in "$@"; do
+        case $arg in
+          --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+          --preview)
+            ENVIRONMENT="preview"
+            shift
+            ;;
+          --production|--prod)
+            ENVIRONMENT="production"
+            shift
+            ;;
+          *)
+            echo "Unknown argument: $arg"
+            echo "Usage: ookstats-deploy [--dry-run] [--preview|--production]"
+            exit 1
+            ;;
+        esac
+      done
+
+      verifyEnv() {
+        log info "Environment" "Deploying to: $ENVIRONMENT"
+
+        if [ "$DRY_RUN" = true ]; then
+          log info "Dry Run" "DRY RUN MODE - No uploads will be performed"
+        fi
+
+        # verify required environment variables
+        if [ -z "''${BLIZZARD_API_TOKEN:-}" ]; then
+          log abort "Environment" "BLIZZARD_API_TOKEN not set"
+        fi
+
+        if [ "$DRY_RUN" = false ]; then
+          if [ -z "''${AWS_ENDPOINT_URL:-}" ]; then
+            log abort "Environment" "AWS_ENDPOINT_URL not set"
+          fi
+          if [ -z "''${AWS_BUCKET:-}" ]; then
+            log abort "Environment" "AWS_BUCKET not set"
+          fi
+          if [ -z "''${AWS_ACCESS_KEY_ID:-}" ]; then
+            log abort "Environment" "AWS_ACCESS_KEY_ID not set"
+          fi
+          if [ -z "''${AWS_SECRET_ACCESS_KEY:-}" ]; then
+            log abort "Environment" "AWS_SECRET_ACCESS_KEY not set"
+          fi
+          if [ -z "''${VERCEL_TOKEN:-}" ]; then
+            log abort "Environment" "VERCEL_TOKEN not set"
+          fi
+          if [ -z "''${VERCEL_ORG_ID:-}" ]; then
+            log abort "Environment" "VERCEL_ORG_ID not set"
+          fi
+          if [ -z "''${VERCEL_PROJECT_ID:-}" ]; then
+            log abort "Environment" "VERCEL_PROJECT_ID not set"
+          fi
+        fi
+      }
+
+      # s3 helper function
+      s3() {
+        aws s3 "$@"
+      }
+
+      # cleanup function for trap
+      cleanup() {
+        local exit_code=$?
+        if [ "$DRY_RUN" = false ] && [ -f ".lock_acquired" ]; then
+          log info "Lock File" "Releasing lock"
+          s3 rm "s3://$AWS_BUCKET/$LOCK_PATH" 2>/dev/null || true
+          rm -f .lock_acquired
+        fi
+        exit $exit_code
+      }
+      trap cleanup EXIT INT TERM
+
+      # === STEP 1: LOCK ACQUISITION ===
+      getLock() {
+        log info "Lock File" "Acquiring lock..."
+
+        if [ "$DRY_RUN" = false ]; then
+          # check if lock exists
+          if s3 ls "s3://$AWS_BUCKET/$LOCK_PATH" >/dev/null 2>&1; then
+            log info "Lock File" "Lock file exists, checking age..."
+
+            # download and check lock timestamp
+            s3 cp "s3://$AWS_BUCKET/$LOCK_PATH" .lock_check || true
+            if [ -f .lock_check ]; then
+              lock_time=$(cat .lock_check)
+              current_time=$(date +%s)
+              age=$((current_time - lock_time))
+
+              if [ "$age" -lt "$LOCK_TIMEOUT" ]; then
+                log error "Lock File" "Another build is running (lock age: $age s
econds)"
+                log error "Lock File" "Lock was acquired at: $(date -d @"$lock_ti
me" 2>/dev/null || date -r "$lock_time")"
+                rm -f .lock_check
+                exit 1
+              else
+                log warn "Lock File" "Stale lock detected (age: $age seconds), ov
erriding..."
+                rm -f .lock_check
+              fi
+            fi
+          fi
+
+          # create lock
+          current_timestamp=$(date +%s)
+          echo "$current_timestamp" > .lock_temp
+          s3 cp .lock_temp "s3://$AWS_BUCKET/$LOCK_PATH"
+          rm -f .lock_temp
+          touch .lock_acquired
+          log info "Lock File" "Lock acquired at: $(date)"
+        else
+          log info "Dry Run" "Skipping lock acquisition"
+        fi
+      }
+
+      # === STEP 2: DATABASE DOWNLOAD ===
+      downloadDB() {
+        log info "Database" "Downloading database..."
+
+        if [ "$DRY_RUN" = false ]; then
+          # download database if exists
+          if s3 ls "s3://$AWS_BUCKET/$DB_PATH" >/dev/null 2>&1; then
+            log info "Database" "Downloading from R2..."
+            s3 cp "s3://$AWS_BUCKET/$DB_PATH" "$LOCAL_DB"
+
+            # download and verify hash
+            if s3 ls "s3://$AWS_BUCKET/$HASH_PATH" >/dev/null 2>&1; then
+              s3 cp "s3://$AWS_BUCKET/$HASH_PATH" "$LOCAL_DB.sha256"
+
+              log info "Database" "Verifying integrity..."
+              if ! sha256sum -c "$LOCAL_DB.sha256"; then
+                log abort "Database" "Hash mismatch - corrupted download"
+              fi
+              log info "Database" "Integrity verified"
+            else
+              log warn "Database" "No hash file found, skipping verification"
+            fi
+
+            db_size=$(stat -c%s "$LOCAL_DB" 2>/dev/null || stat -f%z "$LOCAL_DB")
+            log info "Database" "Downloaded size: $((db_size / 1024 / 1024)) MB"
+          else
+            log info "Database" "No existing database found, starting fresh"
+            rm -f "$LOCAL_DB"
+          fi
+        else
+          log info "Dry Run" "Using existing local database or starting fresh"
+        fi
+
+        # record pre-build database size
+        if [ -f "$LOCAL_DB" ]; then
+          PRE_BUILD_SIZE=$(stat -c%s "$LOCAL_DB" 2>/dev/null || stat -f%z "$LOCAL
_DB")
+        else
+          PRE_BUILD_SIZE=0
+        fi
+      }
+
+      # === STEP 3: BACKUP CREATION ===
+
+      backupDB() {
+        log info "Backup" "Creating backup..."
+
+        if [ "$DRY_RUN" = false ] && [ -f "$LOCAL_DB" ]; then
+          # cleanup old backups FIRST (>24h)
+          log info "Backup" "Cleaning up old backups..."
+          cutoff_timestamp=$(date -u -d '24 hours ago' +%s 2>/dev/null || date -u
 -v-24H +%s)
+
+          # check if backup directory exists
+          if s3 ls "s3://$AWS_BUCKET/$BACKUP_PREFIX" >/dev/null 2>&1; then
+            s3 ls "s3://$AWS_BUCKET/$BACKUP_PREFIX" | awk '{print $4}' | while re
ad -r backup_file; do
+              if [ -n "$backup_file" ]; then
+                # extract timestamp from filename: 2025-12-04T14:00:00Z.db
+                file_timestamp=$(echo "$backup_file" | sed 's/\.db$//' | xargs -I
 {} date -d {} +%s 2>/dev/null || echo "0")
+
+                if [ "$file_timestamp" -gt 0 ] && [ "$file_timestamp" -lt "$cutof
f_timestamp" ]; then
+                  log info "Backup" "Deleting old backup: $backup_file"
+                  s3 rm "s3://$AWS_BUCKET/$BACKUP_PREFIX$backup_file" || true
+                fi
+              fi
+            done
+          else
+            log info "Backup" "No existing backups found"
+          fi
+
+          # now create new backup
+          backup_timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+          backup_path="$BACKUP_PREFIX$backup_timestamp.db"
+
+          log info "Backup" "Uploading to $backup_path"
+          s3 cp "$LOCAL_DB" "s3://$AWS_BUCKET/$backup_path"
+
+          log info "Backup" "Backup created successfully"
+        else
+          log info "Dry Run" "Skipping backup (dry run or no database)"
+        fi
+      }
+
+      # === STEP 4: BUILD PIPELINE ===
+
+      buildPipeline() {
+        log info "Build" "Running ookstats build"
+
+        export OOKSTATS_DB="$LOCAL_DB"
+
+        ookstats build \
+          --out "$API_OUT" \
+          --regions "$REGIONS" \
+          --page-size "$PAGE_SIZE" \
+          --shard-size "$SHARD_SIZE" \
+          --concurrency "$CONCURRENCY"
+
+        log info "Build" "Build completed successfully"
+      }
+
+      # === STEP 5: DATABASE INTEGRITY CHECK ===
+
+      dbIntegrityCheck() {
+        log info "Integrity" "Checking database integrity"
+
+        if [ -f "$LOCAL_DB" ]; then
+          POST_BUILD_SIZE=$(stat -c%s "$LOCAL_DB" 2>/dev/null || stat -f%z "$LOCA
L_DB")
+
+          log info "Integrity" "Pre-build size:  $((PRE_BUILD_SIZE / 1024 / 1024)
) MB"
+          log info "Integrity" "Post-build size: $((POST_BUILD_SIZE / 1024 / 1024
)) MB"
+
+          # check for significant shrinkage
+          if [ "$PRE_BUILD_SIZE" -gt 0 ]; then
+            size_ratio=$((POST_BUILD_SIZE * 100 / PRE_BUILD_SIZE))
+            if [ "$size_ratio" -lt 90 ]; then
+              log abort "Integrity" "Database shrunk by >10% - possible data loss
 (Ratio: $size_ratio%)"
+            fi
+          fi
+
+          # basic sanity check - count players
+          player_count=$(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM players WHERE 
is_valid = 1" 2>/dev/null || echo "0")
+          log info "Integrity" "Valid players in database: $player_count"
+
+          if [ "$player_count" -eq 0 ] && [ "$PRE_BUILD_SIZE" -gt 0 ]; then
+            log warn "Integrity" "No valid players found in database"
+          fi
+        else
+          log abort "Integrity" "Database file not found after build"
+        fi
+      }
+
+      # === STEP 6: HASH & UPLOAD DATABASE ===
+      uploadDB() {
+        log info "Upload" "Uploading database to R2..."
+
+        if [ "$DRY_RUN" = false ]; then
+          log info "Upload" "Calculating database hash..."
+          sha256sum "$LOCAL_DB" > "$LOCAL_DB.sha256"
+
+          log info "Upload" "Uploading database..."
+          s3 cp "$LOCAL_DB" "s3://$AWS_BUCKET/$DB_PATH"
+
+          log info "Upload" "Uploading hash..."
+          s3 cp "$LOCAL_DB.sha256" "s3://$AWS_BUCKET/$HASH_PATH"
+
+          # verify upload
+          if ! s3 ls "s3://$AWS_BUCKET/$DB_PATH" >/dev/null 2>&1; then
+            log abort "Upload" "Database upload verification failed"
+          fi
+
+          log info "Upload" "Database uploaded successfully"
+        else
+          log info "Dry Run" "Skipping database upload"
+          sha256sum "$LOCAL_DB" > "$LOCAL_DB.sha256"
+          log info "Dry Run" "Hash calculated (not uploaded):"
+          cat "$LOCAL_DB.sha256"
+        fi
+      }
+
+      # VERCEL DEPLOYMENT
+      vercelDeploy() {
+        log info "Vercel" "Deploying to Vercel ($ENVIRONMENT)"
+
+        if [ "$DRY_RUN" = false ]; then
+          cd web
+
+          log info "Vercel" "Installing dependencies..."
+          npm ci
+
+          log info "Vercel" "Pulling configuration..."
+          vercel pull --yes --environment="$ENVIRONMENT" --token="$VERCEL_TOKEN"
+
+          if [ "$ENVIRONMENT" = "production" ]; then
+            log info "Vercel" "Building for production..."
+            vercel build --prod --token="$VERCEL_TOKEN"
+
+            log info "Vercel" "Deploying to production..."
+            deployment_url=$(vercel deploy --prebuilt --prod --archive=tgz --toke
n="$VERCEL_TOKEN")
+          else
+            log info "Vercel" "Building for preview..."
+            vercel build --token="$VERCEL_TOKEN"
+
+            log info "Vercel" "Deploying to preview..."
+            deployment_url=$(vercel deploy --prebuilt --archive=tgz --token="$VER
CEL_TOKEN")
+          fi
+
+          log info "Vercel" "Deployment complete: $deployment_url"
+
+          cd ..
+        else
+          log info "Dry Run" "Skipping Vercel deployment"
+          log info "Dry Run" "Would deploy $API_OUT to $ENVIRONMENT"
+        fi
+      }
+
+      # === STEP 8: LOCK RELEASE ===
+      # handled by cleanup trap
+
+      finish() {
+        log info "Deploy" "Deployment Complete"
+        if [ "$DRY_RUN" = true ]; then
+          log info "Dry Run" "This was a dry run - no uploads or deployments were
 performed"
+        fi
+      }
+
+      main() {
+        verifyEnv
+        getLock
+        downloadDB
+        backupDB
+        buildPipeline
+        dbIntegrityCheck
+        uploadDB
+        vercelDeploy
+        finish
+      }
+
+      main
+    '';
+}

diff --git a/nix/pkgs/ookstats-deploy/defaul
t.nix b/nix/pkgs/ookstats-deploy/default.nix
new file mode 100644
index 0000000..2cc5542
--- /dev/null
+++ b/nix/pkgs/ookstats-deploy/default.nix
@@ -0,0 +1,415 @@
+{
+  writeShellApplication,
+  ookstats,
+  awscli2,
+  coreutils,
+  jq,
+  nodejs,
+  nodePackages,
+  sqlite,
+  gum,
+}:
+writeShellApplication {
+  name = "ookstats-deploy";
+
+  runtimeInputs = [
+    ookstats
+    awscli2
+    coreutils
+    jq
+    nodejs
+    nodePackages.vercel
+    sqlite
+    gum
+  ];
+
+  text =
+    /*
+    bash
+    */
+    ''
+
+      # configuration
+
+      # how many entries per leaderboard pa
ge
+      PAGE_SIZE=25
+
+      # how many concurrent connections
+      CONCURRENCY=20
+
+      # player search api shard size
+      SHARD_SIZE=5000
+
+      # where to output the static API
+      API_OUT=web/public
+
+      REGIONS=us,tw,kr,eu
+
+      DB_PATH="db/current.db"
+      HASH_PATH="db/current.db.sha256"
+      LOCK_PATH="db/db.lock"
+      BACKUP_PREFIX="db/backups/"
+      LOCAL_DB="local.db"
+      LOCK_TIMEOUT=7200  # 2 hours in secon
ds
+
+      # log helper function
+      log() {
+        local type="$1"
+        local prefix="$2"
+        local message="$3"
+
+        if [ "$type" = "verbose" ]; then
+          if [ -n "$verbose" ]; then
+            gum log --structured --level de
bug "$message" --prefix "$prefix"
+          fi
+          return
+        fi
+        if [ "$type" = "abort" ]; then
+          gum log --structured --level erro
r "$message" --prefix "$prefix"
+          exit 1
+        else
+          gum log --structured --level "$ty
pe" "$message" --prefix "$prefix"
+        fi
+      }
+
+
+      # configuration
+
+      # parse arguments
+      DRY_RUN=false
+      ENVIRONMENT="production"
+
+      for arg in "$@"; do
+        case $arg in
+          --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+          --preview)
+            ENVIRONMENT="preview"
+            shift
+            ;;
+          --production|--prod)
+            ENVIRONMENT="production"
+            shift
+            ;;
+          *)
+            echo "Unknown argument: $arg"
+            echo "Usage: ookstats-deploy [-
-dry-run] [--preview|--production]"
+            exit 1
+            ;;
+        esac
+      done
+
+      verifyEnv() {
+        log info "Environment" "Deploying t
o: $ENVIRONMENT"
+
+        if [ "$DRY_RUN" = true ]; then
+          log info "Dry Run" "DRY RUN MODE 
- No uploads will be performed"
+        fi
+
+        # verify required environment varia
bles
+        if [ -z "''${BLIZZARD_API_TOKEN:-}"
 ]; then
+          log abort "Environment" "BLIZZARD
_API_TOKEN not set"
+        fi
+
+        if [ "$DRY_RUN" = false ]; then
+          if [ -z "''${AWS_ENDPOINT_URL:-}"
 ]; then
+            log abort "Environment" "AWS_EN
DPOINT_URL not set"
+          fi
+          if [ -z "''${AWS_BUCKET:-}" ]; th
en
+            log abort "Environment" "AWS_BU
CKET not set"
+          fi
+          if [ -z "''${AWS_ACCESS_KEY_ID:-}
" ]; then
+            log abort "Environment" "AWS_AC
CESS_KEY_ID not set"
+          fi
+          if [ -z "''${AWS_SECRET_ACCESS_KE
Y:-}" ]; then
+            log abort "Environment" "AWS_SE
CRET_ACCESS_KEY not set"
+          fi
+          if [ -z "''${VERCEL_TOKEN:-}" ]; 
then
+            log abort "Environment" "VERCEL
_TOKEN not set"
+          fi
+          if [ -z "''${VERCEL_ORG_ID:-}" ];
 then
+            log abort "Environment" "VERCEL
_ORG_ID not set"
+          fi
+          if [ -z "''${VERCEL_PROJECT_ID:-}
" ]; then
+            log abort "Environment" "VERCEL
_PROJECT_ID not set"
+          fi
+        fi
+      }
+
+      # s3 helper function
+      s3() {
+        aws s3 "$@"
+      }
+
+      # cleanup function for trap
+      cleanup() {
+        local exit_code=$?
+        if [ "$DRY_RUN" = false ] && [ -f "
.lock_acquired" ]; then
+          log info "Lock File" "Releasing l
ock"
+          s3 rm "s3://$AWS_BUCKET/$LOCK_PAT
H" 2>/dev/null || true
+          rm -f .lock_acquired
+        fi
+        exit $exit_code
+      }
+      trap cleanup EXIT INT TERM
+
+      # === STEP 1: LOCK ACQUISITION ===
+      getLock() {
+        log info "Lock File" "Acquiring loc
k..."
+
+        if [ "$DRY_RUN" = false ]; then
+          # check if lock exists
+          if s3 ls "s3://$AWS_BUCKET/$LOCK_
PATH" >/dev/null 2>&1; then
+            log info "Lock File" "Lock file
 exists, checking age..."
+
+            # download and check lock times
tamp
+            s3 cp "s3://$AWS_BUCKET/$LOCK_P
ATH" .lock_check || true
+            if [ -f .lock_check ]; then
+              lock_time=$(cat .lock_check)
+              current_time=$(date +%s)
+              age=$((current_time - lock_ti
me))
+
+              if [ "$age" -lt "$LOCK_TIMEOU
T" ]; then
+                log error "Lock File" "Anot
her build is running (lock age: $age seconds
)"
+                log error "Lock File" "Lock
 was acquired at: $(date -d @"$lock_time" 2>
/dev/null || date -r "$lock_time")"
+                rm -f .lock_check
+                exit 1
+              else
+                log warn "Lock File" "Stale
 lock detected (age: $age seconds), overridi
ng..."
+                rm -f .lock_check
+              fi
+            fi
+          fi
+
+          # create lock
+          current_timestamp=$(date +%s)
+          echo "$current_timestamp" > .lock
_temp
+          s3 cp .lock_temp "s3://$AWS_BUCKE
T/$LOCK_PATH"
+          rm -f .lock_temp
+          touch .lock_acquired
+          log info "Lock File" "Lock acquir
ed at: $(date)"
+        else
+          log info "Dry Run" "Skipping lock
 acquisition"
+        fi
+      }
+
+      # === STEP 2: DATABASE DOWNLOAD ===
+      downloadDB() {
+        log info "Database" "Downloading da
tabase..."
+
+        if [ "$DRY_RUN" = false ]; then
+          # download database if exists
+          if s3 ls "s3://$AWS_BUCKET/$DB_PA
TH" >/dev/null 2>&1; then
+            log info "Database" "Downloadin
g from R2..."
+            s3 cp "s3://$AWS_BUCKET/$DB_PAT
H" "$LOCAL_DB"
+
+            # download and verify hash
+            if s3 ls "s3://$AWS_BUCKET/$HAS
H_PATH" >/dev/null 2>&1; then
+              s3 cp "s3://$AWS_BUCKET/$HASH
_PATH" "$LOCAL_DB.sha256"
+
+              log info "Database" "Verifyin
g integrity..."
+              if ! sha256sum -c "$LOCAL_DB.
sha256"; then
+                log abort "Database" "Hash 
mismatch - corrupted download"
+              fi
+              log info "Database" "Integrit
y verified"
+            else
+              log warn "Database" "No hash 
file found, skipping verification"
+            fi
+
+            db_size=$(stat -c%s "$LOCAL_DB"
 2>/dev/null || stat -f%z "$LOCAL_DB")
+            log info "Database" "Downloaded
 size: $((db_size / 1024 / 1024)) MB"
+          else
+            log info "Database" "No existin
g database found, starting fresh"
+            rm -f "$LOCAL_DB"
+          fi
+        else
+          log info "Dry Run" "Using existin
g local database or starting fresh"
+        fi
+
+        # record pre-build database size
+        if [ -f "$LOCAL_DB" ]; then
+          PRE_BUILD_SIZE=$(stat -c%s "$LOCA
L_DB" 2>/dev/null || stat -f%z "$LOCAL_DB")
+        else
+          PRE_BUILD_SIZE=0
+        fi
+      }
+
+      # === STEP 3: BACKUP CREATION ===
+
+      backupDB() {
+        log info "Backup" "Creating backup.
.."
+
+        if [ "$DRY_RUN" = false ] && [ -f "
$LOCAL_DB" ]; then
+          # cleanup old backups FIRST (>24h
)
+          log info "Backup" "Cleaning up ol
d backups..."
+          cutoff_timestamp=$(date -u -d '24
 hours ago' +%s 2>/dev/null || date -u -v-24
H +%s)
+
+          # check if backup directory exist
s
+          if s3 ls "s3://$AWS_BUCKET/$BACKU
P_PREFIX" >/dev/null 2>&1; then
+            s3 ls "s3://$AWS_BUCKET/$BACKUP
_PREFIX" | awk '{print $4}' | while read -r 
backup_file; do
+              if [ -n "$backup_file" ]; the
n
+                # extract timestamp from fi
lename: 2025-12-04T14:00:00Z.db
+                file_timestamp=$(echo "$bac
kup_file" | sed 's/\.db$//' | xargs -I {} da
te -d {} +%s 2>/dev/null || echo "0")
+
+                if [ "$file_timestamp" -gt 
0 ] && [ "$file_timestamp" -lt "$cutoff_time
stamp" ]; then
+                  log info "Backup" "Deleti
ng old backup: $backup_file"
+                  s3 rm "s3://$AWS_BUCKET/$
BACKUP_PREFIX$backup_file" || true
+                fi
+              fi
+            done
+          else
+            log info "Backup" "No existing 
backups found"
+          fi
+
+          # now create new backup
+          backup_timestamp=$(date -u +%Y-%m
-%dT%H:%M:%SZ)
+          backup_path="$BACKUP_PREFIX$backu
p_timestamp.db"
+
+          log info "Backup" "Uploading to $
backup_path"
+          s3 cp "$LOCAL_DB" "s3://$AWS_BUCK
ET/$backup_path"
+
+          log info "Backup" "Backup created
 successfully"
+        else
+          log info "Dry Run" "Skipping back
up (dry run or no database)"
+        fi
+      }
+
+      # === STEP 4: BUILD PIPELINE ===
+
+      buildPipeline() {
+        log info "Build" "Running ookstats 
build"
+
+        export OOKSTATS_DB="$LOCAL_DB"
+
+        ookstats build \
+          --out "$API_OUT" \
+          --regions "$REGIONS" \
+          --page-size "$PAGE_SIZE" \
+          --shard-size "$SHARD_SIZE" \
+          --concurrency "$CONCURRENCY"
+
+        log info "Build" "Build completed s
uccessfully"
+      }
+
+      # === STEP 5: DATABASE INTEGRITY CHEC
K ===
+
+      dbIntegrityCheck() {
+        log info "Integrity" "Checking data
base integrity"
+
+        if [ -f "$LOCAL_DB" ]; then
+          POST_BUILD_SIZE=$(stat -c%s "$LOC
AL_DB" 2>/dev/null || stat -f%z "$LOCAL_DB")
+
+          log info "Integrity" "Pre-build s
ize:  $((PRE_BUILD_SIZE / 1024 / 1024)) MB"
+          log info "Integrity" "Post-build 
size: $((POST_BUILD_SIZE / 1024 / 1024)) MB"
+
+          # check for significant shrinkage
+          if [ "$PRE_BUILD_SIZE" -gt 0 ]; t
hen
+            size_ratio=$((POST_BUILD_SIZE *
 100 / PRE_BUILD_SIZE))
+            if [ "$size_ratio" -lt 90 ]; th
en
+              log abort "Integrity" "Databa
se shrunk by >10% - possible data loss (Rati
o: $size_ratio%)"
+            fi
+          fi
+
+          # basic sanity check - count play
ers
+          player_count=$(sqlite3 "$LOCAL_DB
" "SELECT COUNT(*) FROM players WHERE is_val
id = 1" 2>/dev/null || echo "0")
+          log info "Integrity" "Valid playe
rs in database: $player_count"
+
+          if [ "$player_count" -eq 0 ] && [
 "$PRE_BUILD_SIZE" -gt 0 ]; then
+            log warn "Integrity" "No valid 
players found in database"
+          fi
+        else
+          log abort "Integrity" "Database f
ile not found after build"
+        fi
+      }
+
+      # === STEP 6: HASH & UPLOAD DATABASE 
===
+      uploadDB() {
+        log info "Upload" "Uploading databa
se to R2..."
+
+        if [ "$DRY_RUN" = false ]; then
+          log info "Upload" "Calculating da
tabase hash..."
+          sha256sum "$LOCAL_DB" > "$LOCAL_D
B.sha256"
+
+          log info "Upload" "Uploading data
base..."
+          s3 cp "$LOCAL_DB" "s3://$AWS_BUCK
ET/$DB_PATH"
+
+          log info "Upload" "Uploading hash
..."
+          s3 cp "$LOCAL_DB.sha256" "s3://$A
WS_BUCKET/$HASH_PATH"
+
+          # verify upload
+          if ! s3 ls "s3://$AWS_BUCKET/$DB_
PATH" >/dev/null 2>&1; then
+            log abort "Upload" "Database up
load verification failed"
+          fi
+
+          log info "Upload" "Database uploa
ded successfully"
+        else
+          log info "Dry Run" "Skipping data
base upload"
+          sha256sum "$LOCAL_DB" > "$LOCAL_D
B.sha256"
+          log info "Dry Run" "Hash calculat
ed (not uploaded):"
+          cat "$LOCAL_DB.sha256"
+        fi
+      }
+
+      # VERCEL DEPLOYMENT
+      vercelDeploy() {
+        log info "Vercel" "Deploying to Ver
cel ($ENVIRONMENT)"
+
+        if [ "$DRY_RUN" = false ]; then
+          cd web
+
+          log info "Vercel" "Installing dep
endencies..."
+          npm ci
+
+          log info "Vercel" "Pulling config
uration..."
+          vercel pull --yes --environment="
$ENVIRONMENT" --token="$VERCEL_TOKEN"
+
+          if [ "$ENVIRONMENT" = "production
" ]; then
+            log info "Vercel" "Building for
 production..."
+            vercel build --prod --token="$V
ERCEL_TOKEN"
+
+            log info "Vercel" "Deploying to
 production..."
+            deployment_url=$(vercel deploy 
--prebuilt --prod --archive=tgz --token="$VE
RCEL_TOKEN")
+          else
+            log info "Vercel" "Building for
 preview..."
+            vercel build --token="$VERCEL_T
OKEN"
+
+            log info "Vercel" "Deploying to
 preview..."
+            deployment_url=$(vercel deploy 
--prebuilt --archive=tgz --token="$VERCEL_TO
KEN")
+          fi
+
+          log info "Vercel" "Deployment com
plete: $deployment_url"
+
+          cd ..
+        else
+          log info "Dry Run" "Skipping Verc
el deployment"
+          log info "Dry Run" "Would deploy 
$API_OUT to $ENVIRONMENT"
+        fi
+      }
+
+      # === STEP 8: LOCK RELEASE ===
+      # handled by cleanup trap
+
+      finish() {
+        log info "Deploy" "Deployment Compl
ete"
+        if [ "$DRY_RUN" = true ]; then
+          log info "Dry Run" "This was a dr
y run - no uploads or deployments were perfo
rmed"
+        fi
+      }
+
+      main() {
+        verifyEnv
+        getLock
+        downloadDB
+        backupDB
+        buildPipeline
+        dbIntegrityCheck
+        uploadDB
+        vercelDeploy
+        finish
+      }
+
+      main
+    '';
+}
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET