JSON Schema-Driven Database Migrations: Automating SQLite at Scale

SQLite Performance & Scaling vs External Databases

Published: June 30, 2025

Introduction

Managing database schemas in production is traditionally a manual, error-prone process involving handwritten SQL migrations, DBA approvals, and careful coordination between teams. What if we could automate the entire pipeline using a simple JSON schema definition?

This approach transforms your database schema into code—versioned, diffable, and completely automatable. A single JSON file can define hundreds of tables with complex constraints, and the migration engine handles everything from hash-based change detection to zero-downtime deployments.

We'll explore how this JSON-driven approach eliminates migration bottlenecks, enables AI-assisted development, and scales to handle complex enterprise schemas—all while maintaining the simplicity and performance advantages of SQLite.

1. JSON Schema Automation at Scale

A JSON-based schema definition can handle remarkably complex database structures while maintaining full automation. Here's what's possible:

📊 JSON Schema Capabilities

Schema Feature JSON Definition Automation Level
Table Definitions 200+ tables
nested JSON structure
Fully Automated
zero manual SQL
Constraints & Indexes Complex CHECK, FK, UNIQUE
declarative syntax
Code Generated
from JSON rules
Schema Changes Hash-based detection
automatic diffing
Zero Manual Steps
atomic migrations
Data Migration Streaming copy + change log
handles millions of rows
Transactional Safety
rollback on failure
Version Control Git-friendly diffs
structured JSON format
Branch & Merge
like any code file

When SQLite Shines

  • Declarative definitions — no SQL expertise required
  • Version controlled — diff, branch, merge like code
  • AI-friendly — compact context for LLM assistance
  • Atomic migrations — all-or-nothing safety

🚨 When You Need PostgreSQL/MySQL

  • Manual SQL writing — error-prone and slow
  • DBA bottlenecks — review and approval delays
  • Environment drift — dev/staging/prod inconsistencies
  • Rollback complexity — difficult to undo changes

Real-world testing with 200+ table schemas and millions of rows at Innerscene

2. The Hidden Cost of Database Migrations

A production schema is a living organism. Every new feature requires schema changes—new tables, columns, indexes, and constraints. The traditional database migration workflow looks like this:

🐌 Traditional Migration Workflow

  1. 1 Define the migration in SQL or DSL (Knex, Sequelize, Prisma, Liquibase...)
  2. 2 Review the generated SQL for safety → migrations team / DBA approval
  3. 3 Test against staging environment → debug failures and edge cases
  4. 4 Schedule maintenance window → hope nothing blocks the lock

This process is safe but slow—perfect for regulated FinTech, painful for rapid iteration.

⚡ Hash-Based Migrations with SQLite

The approach we use at Innerscene takes a fundamentally different approach:

  1. 1 Canonical schema lives in a single schema.json file
    → self-documenting, AI-parseable
  2. 2 On app start, hash the normalized schema
    SHA-256 deterministic fingerprint
  3. 3 Compare hash to _schema_versions table
    → detect schema changes instantly
  4. 4 Generate new database, copy data atomically
    → all within a single transaction

📄 Example excerpt from schema.json

{
  "Users": {
    "user_id": "INTEGER PRIMARY KEY",
    "email": "TEXT UNIQUE NOT NULL",
    "role": "TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user'))",
    "first_name": "TEXT",
    "last_name": "TEXT",
    "is_active": "BOOLEAN NOT NULL DEFAULT 1",
    "created_at": "DATETIME DEFAULT CURRENT_TIMESTAMP"
  },
  "Contacts": {
    "contact_id": "INTEGER PRIMARY KEY",
    "contact_type": "TEXT NOT NULL CHECK (contact_type IN ('Distributor','Direct_Customer'))",
    "company_name": "TEXT"
  }
  // … more tables …
}
{
  "Users": {
    "user_id": "INTEGER PRIMARY KEY",
    "email": "TEXT UNIQUE NOT NULL",
    "role": "TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user','manager'))",
    "department_id": "INTEGER REFERENCES Departments(dept_id) ON DELETE SET NULL",
    "salary_band": "INTEGER CHECK (salary_band BETWEEN 1 AND 10)",
    "created_at": "DATETIME DEFAULT CURRENT_TIMESTAMP",
    "updated_at": "DATETIME DEFAULT CURRENT_TIMESTAMP",
    "_indexes": ["email", "department_id", "role"]
  },
  "Departments": {
    "dept_id": "INTEGER PRIMARY KEY",
    "dept_name": "TEXT UNIQUE NOT NULL",
    "budget": "DECIMAL(12,2) CHECK (budget > 0)",
    "manager_id": "INTEGER REFERENCES Users(user_id)",
    "location": "TEXT NOT NULL",
    "_indexes": ["dept_name", "manager_id"]
  },
  "Projects": {
    "project_id": "INTEGER PRIMARY KEY",
    "title": "TEXT NOT NULL",
    "description": "TEXT",
    "status": "TEXT DEFAULT 'planning' CHECK (status IN ('planning','active','completed','cancelled'))",
    "start_date": "DATE NOT NULL",
    "end_date": "DATE CHECK (end_date > start_date)",
    "budget": "DECIMAL(10,2) DEFAULT 0.00",
    "department_id": "INTEGER NOT NULL REFERENCES Departments(dept_id)",
    "created_by": "INTEGER NOT NULL REFERENCES Users(user_id)",
    "_indexes": ["status", "department_id", "start_date"],
    "_unique_constraints": [["title", "department_id"]]
  },
  "ProjectAssignments": {
    "assignment_id": "INTEGER PRIMARY KEY",
    "project_id": "INTEGER NOT NULL REFERENCES Projects(project_id) ON DELETE CASCADE",
    "user_id": "INTEGER NOT NULL REFERENCES Users(user_id) ON DELETE CASCADE",
    "role": "TEXT NOT NULL CHECK (role IN ('lead','developer','analyst','tester'))",
    "hours_allocated": "INTEGER DEFAULT 0 CHECK (hours_allocated >= 0)",
    "start_date": "DATE NOT NULL",
    "end_date": "DATE CHECK (end_date >= start_date)",
    "_indexes": ["project_id", "user_id", "start_date"],
    "_unique_constraints": [["project_id", "user_id", "role"]]
  },
  "TimeEntries": {
    "entry_id": "INTEGER PRIMARY KEY",
    "assignment_id": "INTEGER NOT NULL REFERENCES ProjectAssignments(assignment_id) ON DELETE CASCADE",
    "entry_date": "DATE NOT NULL",
    "hours_worked": "DECIMAL(4,2) NOT NULL CHECK (hours_worked > 0 AND hours_worked <= 24)",
    "description": "TEXT",
    "billable": "BOOLEAN DEFAULT 1",
    "created_at": "DATETIME DEFAULT CURRENT_TIMESTAMP",
    "_indexes": ["assignment_id", "entry_date", "billable"]
  }
  // … 195 more tables with complex relationships …
}

This single JSON file defines our entire database schema with 200+ tables, complex foreign key relationships, check constraints, and custom indexes. The engine automatically generates all DDL statements, handles constraint dependencies, and manages the migration order.

Every deployment, we hash this normalized JSON (SHA-256) and compare it against _schema_versions. If changed, the migration engine creates a fresh database file, applies the new schema respecting FK dependencies, streams data across using ATTACH, then atomically swaps files—all within a single transaction.

Result: Because migrations are code-generated and atomic, there's no human bottleneck. Developers can evolve the schema dozens of times per day without touching handcrafted SQL.

🔧 Under the Hood – Step-by-Step

  1. Normalize schema.json (sort keys, strip comments).
  2. Compute SHA-256 hash ⇒ currentSchemaHash.
  3. If currentSchemaHash exists in _schema_versionsskip the rest.
  4. Otherwise, diff against last known schema via compareSchemas() to produce a
    Plan { tablesToAdd, columnsToModify, … }.
  5. Create fresh database file new-hash.db; enable WAL & PRAGMA settings.
  6. Apply the new schema (DDL) in one transaction.
  7. Attach the old DB as old; stream-copy each table:
    INSERT INTO main.Users SELECT * FROM old.Users;
  8. For long copies we also install change-log triggers to capture writes that occur mid-copy.
  9. Re-play change-log, verify row counts.
  10. Update _schema_versions in the new DB with currentSchemaHash.
  11. Atomically swap files: mv new-hash.db live.db.
  12. Delete change-log & old file (or keep as instant roll-back).

📝 Pseudocode

function migrateIfNeeded(schemaJson):
    hash = sha256(normalize(schemaJson))
    if hash in select version from _schema_versions:
        return  # up to date

    diff = compareSchemas(prevSchema, schemaJson)
    newDB = f"tmp-{hash}.db"

    with sqlite3.connect(newDB) as conn:
        applySchema(conn, schemaJson)
        conn.execute("ATTACH DATABASE 'live.db' AS old")
        streamCopyTables(conn, diff.tables)
        replayChangeLog(conn)
        conn.execute("INSERT INTO _schema_versions (version, applied_at) VALUES (?, NOW())", (hash,))

    atomicSwap(newDB, 'live.db')
    vacuumOld()

3. AI-Assisted Development Advantage

🤖 Why LLMs Love Compact Schemas

Large language models work best with small, deterministic contexts. A compact schema.json file offers several advantages:

📏 Fits in Context

Entire schema fits in a < 10kB prompt, leaving room for other context

🎯 Precise Reasoning

LLMs can reason about joins & constraints without hallucination

🔍 Easy Diffing

Models can "see" changes and auto-generate corresponding UI/API updates

Contrast: 200+ incremental migration files? No chance you can fit that into a single prompt context window.

Outcome: Faster prototyping + higher-confidence code generation

4. Near-Zero-Downtime Deployment Strategy

Even with SQLite, you don't want prolonged downtime. Here's our two-pass migration strategy:

1 First Pass (Online Copy)

  • Create new database file
  • Apply new schema
  • Stream-copy data while live
  • Track changes in log triggers

2 Switch to Read-Only

  • Reject new writes
  • Continue serving reads
  • Apply change-log (seconds)

3 Atomic Cut-Over

  • mv new.db live.db
  • Resume read/write ops
  • Single TCP reconnect

Total Downtime: Often just a single TCP reconnect

5. Operational Superpowers

⚡ Instant Roll-Back

Because each migration writes to a brand-new .db file, reverting is literally as simple as moving the old file back into place:

$ mv live.db broken.db.backup
$ mv erp_database42.db live.db   # ← instant roll-back
$ systemctl restart app.service  # re-open connection

No long downgrade scripts, no data loss—the previous file still contains all rows up to the migration point.

📦 Zero-Downtime Backups

SQLite supports backup API & .backup which can stream a hot backup while the database stays online. Pair this with nightly off-box copies and you have PostgreSQL-level durability in a single file.

  • Full file copy (cp/rsync) when app is idle.
  • Live streaming via sqlite3 .backup command (no downtime).
  • S3/GCS object-storage versions for DR.

Take-away: Treat your database file like a deployable artefact—you can roll forward or back instantly and archive versions just like binaries.

6. Remote DB vs Local SQLite — Choosing Wisely

SQLite isn't the hammer for every nail. Below is a quick decision matrix:

🥇 SQLite Wins When

  • Single-tenant or small multi-tenant SaaS
  • Edge / on-device / kiosk apps
  • Internal tools, admin dashboards
  • CI pipelines & testing harnesses
  • Teams optimising dev velocity over extreme scale

🌐 Remote DB Required When

  • > 100 concurrent writers
  • Multi-node compute cluster
  • Strict RPO/RTO & managed fail-over SLA
  • Complex analytics / full-text search built-ins
  • Regulatory requirement for managed DB service

Rule of thumb: If one server can handle your traffic, SQLite will supercharge your iteration speed  —  otherwise reach for Postgres/MySQL.

7. Bonus Advantages You Might Not Have Considered

🔒 Smaller Attack Surface

No open database ports, no TLS config, no SQL-fuzzing across the wire—all queries stay in-process. Fewer moving parts means fewer CVEs to track.

🧪 Perfect Dev/Test Parity

Spin up an in-memory database for unit tests or share a seeded .db file with teammates—exact same engine runs locally and in prod.

💸 Lower Cloud Bill

Remove a whole tier from your architecture: no managed DB subscription, fewer cores idling, and simpler backups stored in cheap object storage.

📦 Reproducible Builds

The schema hash can be embedded in your app binary & CI logs, guaranteeing prod matches the commit you shipped—great for audits and supply-chain integrity.

8. Performance-Driven Migration Decisions

The beauty of JSON schema automation is that it works equally well for SQLite and PostgreSQL. When your workload outgrows SQLite, the same schema definition can generate PostgreSQL migrations. Here's when to make the switch:

📊 Real-World Performance Thresholds

Scenario (2 vCPU / 4GB VM) Read-Heavy (≥90% reads) Mixed (80/20) Write-Heavy (≥40% writes)
SQLite + JSON Schema
WAL mode, local NVMe
🏆 Winner
~65k HTTP RPS
2M simple queries/s
Sub-millisecond p99
⚖️ Holds up
~300-500 commits/s
Before busy-retry stalls
❌ Falls behind
>500-1000 writes/s
>50 parallel writers
PostgreSQL + JSON Schema
Same VM, network overhead
⚖️ Good
+2ms network latency
Read replicas available
🏆 Sweet spot
1000+ inserts/s
Row-level locks
🏆 Winner
Thousands of writes/s
Parallel processing

🎯 JSON Schema Migration Strategy

🚀 Phase 1: Start with SQLite

  • JSON schema drives everything
  • Automated migrations on deploy
  • Sub-ms latency for reads
  • Perfect dev/prod parity
  • Zero infrastructure complexity

📊 Phase 2: Monitor Signals

  • Write contention at 500+ TPS
  • Busy timeouts appearing
  • 50+ concurrent writers
  • Multi-TB data requirements
  • Complex analytics needs

🔄 Phase 3: Seamless Switch

  • Same JSON schema works
  • Generate PostgreSQL DDL
  • Migrate data via COPY
  • Keep automation benefits
  • Scale to thousands of writers

⚡ Why SQLite + JSON Schema Wins Early

🏃‍♂️ Zero Network Cost

In-process queries avoid TCP overhead. Point selects are literally one memcpy away when cached.

🔧 Automated Everything

JSON schema generates migrations, handles dependencies, and deploys atomically—no DBA bottlenecks.

📈 Massive Read Throughput

65k HTTP RPS or 2M simple queries/s on modest hardware. Perfect for read-heavy applications.

🎯 Migration Decision Matrix

// JSON schema migration decision logic
class SchemaManager {
  assessMigrationNeed(metrics) {
    const signals = {
      writeContention: metrics.avgWriteLatency > 10, // ms
      highConcurrency: metrics.concurrentWriters > 50,
      busyTimeouts: metrics.busyTimeoutRate > 0.01, // 1%
      dataSize: metrics.dbSizeGB > 500,
      complexQueries: metrics.needsJSONB || metrics.needsFTS
    };
    
    const criticalSignals = Object.values(signals).filter(Boolean).length;
    
    if (criticalSignals >= 2) {
      return {
        recommendation: 'MIGRATE_TO_POSTGRES',
        reason: 'Multiple scaling bottlenecks detected',
        timeline: 'Plan migration within 2-4 weeks',
        benefits: [
          'Row-level locking eliminates write contention',
          'Parallel query processing for complex operations', 
          'Built-in replication and backup solutions',
          'Same JSON schema drives PostgreSQL migrations'
        ]
      };
    }
    
    return {
      recommendation: 'OPTIMIZE_SQLITE',
      reason: 'SQLite still optimal for current workload',
      optimizations: [
        'Enable WAL mode and increase cache size',
        'Batch writes in transactions',
        'Consider read replicas via Litestream',
        'Monitor write throughput trends'
      ]
    };
  }
}

Key Insight: JSON schema automation means you're never locked into SQLite. The same schema definition that powers your rapid SQLite development can seamlessly generate PostgreSQL migrations when you need to scale. Start fast, scale smart.

9. Docker & Containerization Strategies

SQLite's file-based nature makes it perfect for containerized deployments. The key is treating your database as persistent data that lives outside the ephemeral container lifecycle.

🐳 Container-Friendly Architecture

# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Create data directory for database files
RUN mkdir -p /app/data
COPY . .

# Database will be mounted at /app/data/
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      # Mount persistent directory for SQLite files
      - ./data:/app/data
      # Mount schema definitions
      - ./schemas:/app/schemas:ro
    environment:
      - DB_PATH=/app/data/production.db
      - SCHEMA_PATH=/app/schemas/current.json

Benefits

  • Database survives container restarts
  • Easy backup via volume snapshots
  • Schema migrations on container startup
  • Perfect for CI/CD pipelines

🎯 Best Practices

  • Use named volumes for production
  • Set proper file permissions (1000:1000)
  • Enable WAL mode in startup script
  • Health checks verify DB connectivity

🚀 Production Deployment Example

# Production deployment with Docker Swarm/Kubernetes
version: '3.8'
services:
  web:
    image: myapp:latest
    deploy:
      replicas: 2
      placement:
        constraints:
          - node.labels.database == true  # Pin to nodes with SSD storage
    volumes:
      - type: volume
        source: sqlite_data
        target: /app/data
        volume:
          driver: local
          driver_opts:
            type: none
            o: bind
            device: /mnt/fast-ssd/sqlite-data
    healthcheck:
      test: ["CMD", "node", "health-check.js"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  sqlite_data:
    external: true

Pro Tip: Use docker volume create with specific drivers for high-performance storage, and always run migrations as init containers to ensure schema consistency before app startup.

10. Multi-Tenant Scaling: Database-Per-Customer

One of SQLite's most powerful scaling patterns is database-per-tenant. Instead of fighting with row-level security and complex partitioning, give each customer their own .db file. This unlocks horizontal scaling that's as simple as moving files between servers.

🏗️ Architecture Overview

🖥️

Server A

customer_001.db
customer_002.db
customer_003.db
🖥️

Server B

customer_004.db
customer_005.db
customer_006.db
🖥️

Server C

customer_007.db
customer_008.db
customer_009.db

⚡ Scaling Operations

📦 Customer Migration

Phase 1 (Online): Stream-copy customer DB to target server
Phase 2 (Brief pause): Stop writes, sync final changes
Phase 3 (Atomic): Update routing, resume operations
Downtime: ~5-10 seconds per customer

⚖️ Load Balancing

Route by customer_id hash
Consistent hashing for even distribution
Monitor DB file sizes
Auto-rebalance heavy customers

💾 Backup Strategy

Per-customer backup schedules
S3 with customer-specific prefixes
Point-in-time recovery per tenant
Compliance isolation

💻 Implementation Example

🔄 Zero-Downtime Customer Migration Process

Just like schema migrations, customer migrations use a two-pass strategy to minimize downtime:

1 Phase 1: Online Copy
  • Stream-copy customer DB to target server
  • Customer remains fully operational
  • Install change-log triggers
  • Monitor copy progress
2 Phase 2: Brief Pause
  • Mark customer as "migrating"
  • Reject new writes (5-10 seconds)
  • Apply change-log to target DB
  • Verify data consistency
3 Phase 3: Atomic Switch
  • Update routing table atomically
  • Resume customer operations
  • Clean up old DB and triggers
  • Migration complete!

Total Customer Downtime: 5-10 seconds (just the routing update)

// Enhanced customer database migration with change tracking
class CustomerDBManager {
  constructor() {
    this.connections = new Map();
    this.serverMapping = new Map(); // customer_id -> server_id
    this.migrationStatus = new Map(); // customer_id -> migration_state
  }

  async getCustomerDB(customerId) {
    // Check if customer is being migrated
    if (this.migrationStatus.get(customerId) === 'migrating') {
      throw new Error('Customer database temporarily unavailable during migration');
    }
    
    if (!this.connections.has(customerId)) {
      const serverInfo = this.getServerForCustomer(customerId);
      const dbPath = `${serverInfo.dataPath}/customer_${customerId}.db`;
      const db = new Database(dbPath);
      db.pragma('journal_mode = WAL');
      db.pragma('synchronous = NORMAL');
      this.connections.set(customerId, db);
    }
    return this.connections.get(customerId);
  }

  async migrateCustomerZeroDowntime(customerId, targetServer) {
    console.log(`Starting zero-downtime migration for customer ${customerId}`);
    
    // Phase 1: Online copy with change tracking
    this.migrationStatus.set(customerId, 'copying');
    const sourceDB = await this.getCustomerDB(customerId);
    
    // Install change-log triggers
    await this.installChangeLogTriggers(sourceDB, customerId);
    
    // Stream copy to target server (can take minutes/hours)
    await this.streamCopyDatabase(customerId, targetServer);
    
    // Phase 2: Brief pause for final sync
    this.migrationStatus.set(customerId, 'migrating');
    
    // Stop accepting new writes (customers get temporary error)
    await this.sleep(100); // Let in-flight requests complete
    
    // Apply change-log to target database
    await this.applyChangeLog(customerId, targetServer);
    
    // Verify data consistency
    await this.verifyDataConsistency(customerId, targetServer);
    
    // Phase 3: Atomic routing update
    this.serverMapping.set(customerId, targetServer);
    this.connections.delete(customerId); // Force reconnect to new server
    this.migrationStatus.delete(customerId); // Resume normal operations
    
    // Cleanup
    await this.removeChangeLogTriggers(sourceDB, customerId);
    await this.archiveOldDatabase(customerId);
    
    console.log(`Migration complete for customer ${customerId} -> ${targetServer}`);
  }

  async installChangeLogTriggers(db, customerId) {
    // Create change log table and triggers for all customer tables
    const tables = await this.getCustomerTables(db);
    
    db.exec(`CREATE TABLE IF NOT EXISTS _migration_log_${customerId} (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      table_name TEXT NOT NULL,
      operation TEXT NOT NULL,
      row_id INTEGER,
      timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
    )`);
    
    for (const table of tables) {
      db.exec(`
        CREATE TRIGGER IF NOT EXISTS log_${table}_insert_${customerId}
        AFTER INSERT ON ${table}
        BEGIN
          INSERT INTO _migration_log_${customerId} (table_name, operation, row_id)
          VALUES ('${table}', 'INSERT', NEW.rowid);
        END;
        
        CREATE TRIGGER IF NOT EXISTS log_${table}_update_${customerId}
        AFTER UPDATE ON ${table}
        BEGIN
          INSERT INTO _migration_log_${customerId} (table_name, operation, row_id)
          VALUES ('${table}', 'UPDATE', NEW.rowid);
        END;
        
        CREATE TRIGGER IF NOT EXISTS log_${table}_delete_${customerId}
        AFTER DELETE ON ${table}
        BEGIN
          INSERT INTO _migration_log_${customerId} (table_name, operation, row_id)
          VALUES ('${table}', 'DELETE', OLD.rowid);
        END;
      `);
    }
  }
}

🚀 Advantages

  • Perfect isolation - no cross-tenant data leaks
  • Elastic scaling - add servers as needed
  • Custom SLAs - premium customers on faster hardware
  • Simple compliance - data residency per customer
  • Granular backups - restore individual customers

⚠️ Considerations

  • Connection pooling - manage many DB handles
  • Cross-tenant queries - reporting becomes complex
  • Schema versioning - ensure all DBs stay in sync
  • File system limits - monitor inode usage
  • Monitoring complexity - track health per tenant

Real-world Success: Companies like Notion and Linear use similar patterns to scale to millions of users while maintaining blazing-fast performance. Each workspace gets its own data silo, making scaling as simple as provisioning new hardware.

Conclusion

SQLite's stigma is rooted in 2000-era limitations. Modern WAL mode, fast NVMe storage, and thoughtful migration strategies transform it into a serious contender for many SaaS backends.

Simple
🚀
Fast
🔥
Productive

The Development Velocity Advantage: SQLite is an excellent choice for most new applications. The development speed you gain from zero infrastructure complexity, instant local testing, and automated schema management creates tremendous business value early on.

When you eventually outgrow SQLite, the migration effort is often much smaller than the accumulated complexity of managing hundreds of incremental migrations as your app matures. A single JSON schema transition beats debugging migration #247 that failed in production.

Start fast, scale smart. The velocity gains from SQLite will have paid for the eventual migration a dozen times over.

When you outgrow SQLite, you'll have clear signals—write contention, multi-node scaling, or compliance needs. By then, the JSON schema approach makes migration straightforward.

Happy building! 🚀