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.
8 minute lesson
A Durable Object namespace outlives every deploy that touches it. Two kinds of evolution need discipline: class-level changes tracked by the platform, and schema changes inside each object’s SQLite database.
Class changes go through the migrations list, and the rule is absolute: never edit a previously deployed Durable Object migration tag. Tags are an append-only history the platform replays. Add a new tag for class changes:
{
"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 to the new class name. Rename the class in code without it and you get a fresh, empty namespace while 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. Inside SQLite, 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, 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 is abandoned and who can call deleteAll. An object whose storage is empty ceases to exist and stops billing, and deleteAll is the only complete way there — dropping your tables still leaves metadata behind. Point-in-time recovery and platform limits should be checked in current docs before you promise either in a runbook.
Rehearse the whole cycle on something disposable: migrate a disposable object schema, verify old data, then exercise the product’s deletion and recovery runbook. A migration you’ve only run on empty objects hasn’t been tested.
Lesson completed