Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.vers.sh/llms.txt

Use this file to discover all available pages before exploring further.

Schema migrations are the canonical example of a change you want to try three ways before committing to one. The tax is always the same: reset the database, reseed test data, apply migration, test. For every scenario. Vers removes the tax. Seed once, branch the live database, apply competing migrations against isolated copies, compare in parallel.

What you’ll build

  • A SQLite e-commerce database with a seeded base schema
  • Three parallel migration branches: premium-user features, inventory tracking, analytics
  • A workflow for comparing the three outcomes side by side
  • Time: ~25 minutes

Prerequisites

  • Vers CLI installed and authenticated
  • Basic familiarity with SQL

The idea

The expensive part of testing a migration isn’t the migration — it’s the base state the migration modifies. You want to test three different premium-features implementations against the same seeded database, and traditionally that means reseeding three times. With Vers you seed once, branch the live VM (which includes the SQLite file’s exact page layout, WAL state, and any in-memory buffers), then apply a different migration to each branch. Every branch sees the same starting data; each diverges only in the migration you run.

Step 1: Initialize the project

mkdir database-state-testing && cd database-state-testing
vers init
Default vers.toml resources are fine for SQLite:
[machine]
mem_size_mib = 1024
vcpu_count = 1
fs_size_mib = 2048

[rootfs]
name = "default"

[kernel]
name = "default.bin"

Step 2: Boot the VM and seed the base schema

vers run --vm-alias db-root
vers connect
Inside the VM:
apt-get update -qq && apt-get install -y -qq sqlite3
mkdir -p /app/db && cd /app
Seed the base schema — users, products, orders, plus sample rows:
sqlite3 /app/db/ecommerce.db << 'EOF'
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  stock INTEGER DEFAULT 0
);

CREATE TABLE orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER REFERENCES users(id),
  total DECIMAL(10,2),
  status TEXT DEFAULT 'pending',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (email, name) VALUES
  ('alice@example.com', 'Alice Smith'),
  ('bob@example.com',   'Bob Jones'),
  ('carol@example.com', 'Carol White');

INSERT INTO products (name, price, stock) VALUES
  ('Laptop',   999.99, 50),
  ('Mouse',     29.99, 200),
  ('Keyboard',  79.99, 150),
  ('Monitor',  299.99, 75);

INSERT INTO orders (user_id, total, status) VALUES
  (1, 1029.98, 'completed'),
  (2,   29.99, 'pending'),
  (1,  379.98, 'shipped');
EOF
Write a helper to dump schema + row counts so you can quickly diff branches later:
cat > /app/inspect.sh << 'EOF'
#!/bin/bash
echo "=== Tables ==="
sqlite3 /app/db/ecommerce.db ".tables"
echo "=== Row counts ==="
for t in $(sqlite3 /app/db/ecommerce.db ".tables"); do
  c=$(sqlite3 /app/db/ecommerce.db "SELECT COUNT(*) FROM $t")
  echo "  $t: $c"
done
EOF
chmod +x /app/inspect.sh
/app/inspect.sh
Exit back to your host shell:
exit

Step 3: Branch at the pre-migration state

This is the decision point. Three migration candidates, one base state:
vers branch --alias migration-premium
vers branch --alias migration-inventory
vers branch --alias migration-analytics
Four VMs running now — db-root as the baseline, three children each with an identical copy of the seeded database. No reseeding, no reset scripts.

Step 4: Apply three migrations in parallel

Each migration goes on its own branch. For genuine parallelism, open three terminals; for a linear read, just run them one after the other — the vers execute form is fine.

Migration A — premium user features

vers execute migration-premium "sqlite3 /app/db/ecommerce.db \"\
ALTER TABLE users ADD COLUMN is_premium BOOLEAN DEFAULT 0;\
ALTER TABLE users ADD COLUMN premium_since DATETIME;\
CREATE TABLE premium_benefits (\
  id INTEGER PRIMARY KEY AUTOINCREMENT,\
  name TEXT NOT NULL,\
  discount_percent INTEGER DEFAULT 0\
);\
INSERT INTO premium_benefits (name, discount_percent) VALUES\
  ('Free Shipping', 0),\
  ('10% Discount', 10);\
UPDATE users SET is_premium = 1, premium_since = CURRENT_TIMESTAMP WHERE id = 1;\
\""

Migration B — inventory management

vers execute migration-inventory "sqlite3 /app/db/ecommerce.db \"\
ALTER TABLE products ADD COLUMN reorder_level INTEGER DEFAULT 10;\
ALTER TABLE products ADD COLUMN supplier_id INTEGER;\
CREATE TABLE suppliers (\
  id INTEGER PRIMARY KEY AUTOINCREMENT,\
  name TEXT NOT NULL,\
  contact_email TEXT\
);\
CREATE TABLE inventory_movements (\
  id INTEGER PRIMARY KEY AUTOINCREMENT,\
  product_id INTEGER REFERENCES products(id),\
  quantity_change INTEGER NOT NULL,\
  reason TEXT\
);\
INSERT INTO suppliers (name, contact_email) VALUES\
  ('Tech Supplies Inc', 'orders@techsupplies.com');\
UPDATE products SET supplier_id = 1 WHERE id IN (1, 2, 3);\
\""

Migration C — analytics tables

vers execute migration-analytics "sqlite3 /app/db/ecommerce.db \"\
CREATE TABLE page_views (\
  id INTEGER PRIMARY KEY AUTOINCREMENT,\
  user_id INTEGER REFERENCES users(id),\
  page_path TEXT NOT NULL,\
  viewed_at DATETIME DEFAULT CURRENT_TIMESTAMP\
);\
CREATE TABLE daily_stats (\
  date DATE PRIMARY KEY,\
  total_orders INTEGER DEFAULT 0,\
  total_revenue DECIMAL(10,2) DEFAULT 0\
);\
INSERT INTO page_views (user_id, page_path) VALUES\
  (1, '/products'),\
  (2, '/cart');\
\""

Step 5: Compare outcomes

The inspect.sh helper you wrote in Step 2 is present in every branch — they inherit the parent’s filesystem at branch time. Run it on each:
vers execute migration-premium   "/app/inspect.sh"
vers execute migration-inventory "/app/inspect.sh"
vers execute migration-analytics "/app/inspect.sh"
Each branch shows a different schema. Every branch still has the baseline users/products/orders data unchanged — only the migration’s additions diverge.

Confirm the baseline is untouched

The root VM is exactly where you left it at Step 2:
vers execute db-root "/app/inspect.sh"
No premium_benefits, no suppliers, no page_views. The migrations live only on their branches.

Step 6: Branch a branch — test a rollback scenario

A branch is itself a VM you can branch. Say you want to apply a risky secondary migration on top of migration-premium without disturbing it:
vers branch migration-premium --alias premium-risky
vers execute premium-risky "sqlite3 /app/db/ecommerce.db 'DROP TABLE premium_benefits'"
vers execute premium-risky "/app/inspect.sh"
vers execute migration-premium "/app/inspect.sh"   # unchanged
The risky change lives on its own branch. The parent migration branch is unaffected. This is the rollback story for free: every commit you take is a restoration point, every branch is an isolation boundary.

Step 7: Clean up

vers checkout db-root
vers kill db-root -r
The -r tears down the root and all descendants.

The pattern

Seed expensive state once, branch at the decision point, diverge per scenario. Databases happen to be the cleanest example because the setup cost (schema + seed data) is visibly expensive, but the pattern is the same one as parallel scenario testing and agent swarms:
  • The setup prefix is paid once, at commit time
  • Every fork inherits the exact state — byte-identical, memory and filesystem both
  • Divergence only costs what it actually writes
Combine that with vers commit and you have a rollback primitive strong enough that “test three migrations and keep the best one” becomes one afternoon of work, not one sprint.

What’s next

Parallel scenario testing

Same pattern applied to end-to-end HTTP testing against a live stateful service.

Agent swarms

Branch a golden state into parallel coding agents.

vers branch

Every flag on the branch primitive.

Architecture

How commits, overlays, and content addressing make migration testing cheap.