Test, migrate, and operate

Evolve and clean up objects

Add migration tags, version embedded schemas, observe failures, and delete object storage only through a deliberate lifecycle.

A Durable Object namespace outlives every deploy that touches it. Two kinds of change need discipline: class-level changes the platform tracks, and schema changes inside each object’s SQLite database.

Class changes go through the migrations list. The rule is absolute: never edit a migration tag you already deployed. Tags are an append-only history the platform replays. Add a new tag for each change:

{
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["ProjectRoom"] },
    { "tag": "v2", "renamed_classes": [
      { "from": "ProjectRoom", "to": "WorkspaceRoom" }
    ] }
  ]
}

The renamed_classes entry carries existing objects and their storage over to the new class name. Rename the class in code without it and you get a fresh, empty namespace. Every existing object’s data sits orphaned under the old name. Users see their rooms wiped, though nothing was deleted.

Version the schema inside each object

Each object owns a private SQLite database, and thousands of them upgrade lazily as they wake. Track schema migrations in a table, because PRAGMA user_version is not supported there:

ctx.blockConcurrencyWhile(async () => {
  this.ctx.storage.sql.exec(
    'create table if not exists schema_migrations (version integer primary key)')

  const applied = new Set(this.ctx.storage.sql
    .exec('select version from schema_migrations').toArray().map(r => r.version))

  if (!applied.has(2)) {
    this.ctx.storage.sql.exec('alter table messages add column edited_at integer')
    this.ctx.storage.sql.exec('insert into schema_migrations (version) values (2)')
  }
})

Every object checks what it already has and applies only what’s missing. It doesn’t matter whether it wakes a day or a year after the deploy.

Observe, then delete deliberately

You can’t list what a namespace contains, so your logs are the inventory. Log safe object identifiers, method names, durations, and failure classes. Enough to answer “which rooms are erroring”, without dumping user content into logs.

Deletion is a lifecycle decision, not a reflex. Define when an object counts as abandoned and who can call deleteAll. An object with empty storage stops existing and stops billing, and deleteAll is the only complete way there. Dropping your tables still leaves metadata behind. Check the current docs for point-in-time recovery and platform limits before you promise either in a runbook.

Try the whole cycle on something disposable: migrate a test object’s schema, verify the old data is still there, then run your deletion and recovery runbook against it. A migration you only ran on empty objects hasn’t been tested.

Lesson completed