Skip to main content

Migrations

Beta

This feature is in beta. Core behavior is stable and ready to try, but some APIs or configuration may still evolve before general availability.

How EdgeBase handles schema evolution across D1-backed single-instance blocks and large fleets of dynamic Durable Object instances.

Overview

EdgeBase uses a Lazy Migration architecture. Instead of running one central migration job across every backing database, each DB block syncs its schema on the first request it receives after a deploy. Single-instance blocks usually materialize once on D1, while dynamic blocks may fan out to thousands of Durable Object instances, so per-instance lazy migration is the practical model.

There are two categories of schema changes:

Change TypeExampleHandling
Non-destructiveAdd a new column, add a new table, enable field.unique on duplicate-free SQLite-backed dataAutomatic (Lazy Schema Init)
DestructiveRename column, delete column, change type, remove an inline SQLite UNIQUE constraintManual migration required

Lazy Schema Init

Every time a DB block instance receives a request, it checks whether its schema is up to date:

DB block receives request
→ Read stored schemaHash from _meta table
→ Compute djb2 hash of the deployed config schema
→ If hashes match → skip the schema-shape diff
→ If hashes differ → run Lazy Schema Init:
- Missing tables → CREATE TABLE
- Missing columns → ALTER TABLE ADD COLUMN
- FTS5 virtual tables + triggers → CREATE IF NOT EXISTS
- Indexes → CREATE INDEX IF NOT EXISTS
→ D1 / Durable Object field uniqueness → always reconcile EdgeBase-owned UNIQUE indexes
→ Store new hash in _meta

The hash is computed using djb2 over JSON.stringify(deepSort({ schema: config.schema ?? {} })). It only hashes the schema field (column definitions), not the full table config. This means changes to fts, indexes, or migrations alone do not trigger a hash mismatch — those are re-checked on every request regardless of hash.

What Lazy Schema Init Handles

  • New tables: CREATE TABLE with the full schema
  • New columns: ALTER TABLE ADD COLUMN with defaults
  • D1 / Durable Object field uniqueness: add or remove EdgeBase-owned single-column unique indexes
  • FTS5 setup: Virtual table and insert/update/delete triggers (always re-checked regardless of hash)
  • Index creation: CREATE INDEX IF NOT EXISTS (always re-checked regardless of hash)

SQLite-backed field.unique Changes

For an existing D1 or Durable Object column, changing field.unique from false or omitted to true creates a real single-column UNIQUE index during lazy schema initialization. Physical uniqueness is verified even when the stored schema hash already matches, which repairs databases whose hash was saved by an older runtime without creating the index. SQLite permits multiple NULL values in that index. If duplicate non-NULL values already exist, initialization fails with a duplicate-data error, the previous schema hash remains stored, and the next request retries after the data is repaired. EdgeBase never deletes or silently deduplicates records.

Changing field.unique back to false or omitting it removes only the deterministic uidx_<table>_<field> index owned by that field option. A unique index declared separately in table.indexes, or created by a user migration, has separate ownership and is preserved.

SQLite cannot drop an inline UNIQUE table constraint without rebuilding the table. If the existing table was created with inline uniqueness rather than an EdgeBase-managed uidx_* index, lazy schema initialization fails with explicit migration guidance instead of pretending the constraint was removed. Apply a table-rebuild migration while the old schema config is still deployed, and have that migration create the equivalent uidx_<table>_<field> index so uniqueness remains enforced. Then change field.unique in a following deploy; lazy schema initialization removes that managed index.

What It Does Not Handle

  • Column renames
  • Column deletions
  • Column type changes
  • Table deletions
  • Inline SQLite UNIQUE constraint removal

These destructive changes require explicit migrations.

Destructive Changes (Lazy Migration)

For changes that Lazy Schema Init cannot handle, define migrations in edgebase.config.ts:

// edgebase.config.ts
export default defineConfig({
databases: {
app: {
tables: {
posts: {
schema: {
// Current final schema (after all migrations)
title: { type: 'string', required: true },
displayName: { type: 'string' },
content: { type: 'text' },
},
migrations: [
{
version: 2,
description: 'Rename column',
up: 'ALTER TABLE posts RENAME COLUMN username TO displayName',
},
{
version: 3,
description: 'Remove legacy field',
up: 'ALTER TABLE posts DROP COLUMN legacyField',
},
],
},
},
},
},
});

How Migration Execution Works

DB block receives request
→ Lazy Schema Init runs first (hash comparison)
→ Lazy Migration runs next:
→ Read migration_version from _meta (default: 1)
→ Execute unapplied migrations in order
→ Each migration is executed directly (not wrapped in an explicit transaction)
→ On success: update migration_version in _meta
→ On failure: stop at the failed migration, return 503
→ Next request retries from the failed migration

New Instances Skip Migrations

When a DB block instance is created for the first time (no migration_version key in _meta), it has never stored any data. Lazy Schema Init creates the table with the final schema directly. All migrations are skipped, and the latest migration version is recorded in _meta.

This means new D1 databases or new Durable Object instances never execute historical migrations -- they always start with the current schema.

Migration Rules

RuleDetail
Single SQL statementUse one SQL statement per migration entry
Version numbersMust be sequential integers starting from 2 (version 1 is the initial schema)
Schema fieldAlways reflects the current final state (after all migrations)
BEGIN...END blocksCREATE TRIGGER or similar multi-statement blocks must be in separate migration entries
Direct executionEach migration is executed via execMulti() (no explicit transaction wrapper)
Failure behaviorFailed migration returns HTTP 503; the DO retries on next request
upPg (optional)PostgreSQL-specific migration SQL. When present, upPg is used instead of up for PostgreSQL-backed databases (e.g., Neon). Use this when the migration SQL differs between SQLite and PostgreSQL.
tip

Keep each migration to a single SQL statement. If you need a CREATE TRIGGER with a BEGIN...END block, put it in its own migration entry.

Schema Locking

When you run edgebase deploy or edgebase dev, the CLI compares your current schema against a saved snapshot file (edgebase-schema.lock.json) to detect destructive changes before they reach production.

What Gets Detected

  • Column deleted -- a field was removed from the schema
  • Column type changed -- a field's type was modified (e.g., string to number)
  • Table deleted -- an entire table was removed from config

How It Works

edgebase deploy / edgebase dev
→ Load edgebase-schema.lock.json (first deploy = no snapshot, just saves)
→ Build snapshot from current config
→ Diff against saved snapshot → list destructive changes
→ Per-table migration auto-pass filter (compare migration versions)
→ If unresolved changes remain:
- release: true → print migration guide and exit (reset not allowed)
- release: false → offer [r] Reset DB / [m] Migration guide
→ On successful deploy → save updated snapshot

The snapshot file records each table's effective schema (including auto fields like id, createdAt, updatedAt) and the latest migration version.

Migration Auto-Pass

If you have already written a migration for a table (i.e., the table's latest migration version in the snapshot is older than the newest migration in config), the destructive change detection is automatically resolved for that table. Each table is evaluated independently.

Developer Mode (release: false)

When destructive changes are detected, the CLI prompts you to choose:

  • [r] Reset -- Delete local database state and start fresh. All data is lost.
  • [m] Migration guide -- The CLI prints suggested SQL for each change. Write a migration and re-run.

Release Mode (release: true)

Database reset is not available. You must write migrations. The CLI prints a migration guide and exits with an error.

CI/CD (Non-Interactive)

Use the --if-destructive flag to control behavior in automated environments:

# Reject destructive changes — exits with error (default)
edgebase deploy --if-destructive=reject

# Auto-reset in dev (not allowed with release: true)
edgebase deploy --if-destructive=reset

Isolated DO Considerations

In isolated DO environments (e.g., user:{id} namespace), individual DOs may remain dormant for extended periods. When they finally receive a request, they will run all pending migrations at that time.

This means some DOs may operate on older schema versions while others have already migrated. This is a known characteristic of the Lazy Migration pattern and is generally acceptable because each DO's data is independent.

CLI Migration Helpers

Generate a migration skeleton:

npx edgebase migration create <name>
# Example:
npx edgebase migration create rename-username --table posts

The <name> argument is required and becomes the migration description. Use --table <name> to auto-detect the next version number from your config.