Migrations
Cloudflare Durable Object schemas evolve via migration entries in wrangler.jsonc. ayjnt generates those entries automatically from your file tree — and enforces a git discipline that makes divergent-migration races impossible.
What a Durable Object migration is
DO migrations are wrangler's way of declaring schema changes:
when you add a new Agent class, rename one, or delete one, wrangler
needs a migration entry describing what happened so the
platform can allocate storage (or free it) safely. A fragment of a
typical wrangler config:
{
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatAgent"] },
{ "tag": "v2", "new_sqlite_classes": ["OrdersAgent", "InventoryAgent"] },
{ "tag": "v3", "renamed_classes": [{ "from": "ChatAgent", "to": "TalkAgent" }] },
{ "tag": "v4", "deleted_classes": ["OldAgent"] }
]
}
Once a migration is applied (by a successful wrangler deploy), it cannot be undone or reordered —
wrangler refuses to rewind. Every future deploy must include the
full history plus any new entries at the end.
The lockfile: .ayjnt/migrations.json
ayjnt treats the migration history as a lockfile. It lives at .ayjnt/migrations.json, it's committed to your
repo, and it's the source of truth for what schema is in
production.
{
"version": 1,
"migrations": [
{
"tag": "v1",
"timestamp": "2026-04-14T00:00:00Z",
"new_sqlite_classes": ["ChatAgent", "OrdersAgent"]
},
{
"tag": "v2",
"timestamp": "2026-04-14T05:30:00Z",
"renamed_classes": [{ "from": "ChatAgent", "to": "TalkAgent" }]
}
],
"classes": {
"chat": { "agentId": "chat", "className": "TalkAgent", "firstTag": "v1" },
"orders": { "agentId": "orders", "className": "OrdersAgent", "firstTag": "v1" }
}
}Two fields worth understanding:
-
migrations— the append-only list that wrangler actually consumes. Don't edit past entries. -
classes— a derived snapshot of what each agent currently is after all migrations have been applied. This is what the diff algorithm compares against on the next build to figure out whether to stage a new migration.
How a new migration gets staged
On every ayjnt build (which dev and deploy both invoke), the lockfile is read and compared
against the current file tree:
- The scanner produces a manifest: every agent found, with its className and agentId.
-
The diff compares the manifest against
lockfile.classes:- agentId in manifest but not in lockfile → new class (adds to
new_sqlite_classes) -
agentId in both, different className → rename (adds to
renamed_classes) - agentId in lockfile but not manifest → deleted (adds to
deleted_classes)
- agentId in manifest but not in lockfile → new class (adds to
-
If anything changed, a new entry (
v1,v2, …) is appended tomigrationsandclassesis updated. -
ayjnt buildwrites the updated lockfile back to disk.ayjnt deploydeliberately does not — see below.
Stable agentIds
Rename detection relies on a stable agentId. By default
it's derived from the folder path (admin/users → admin_users), which means renaming a folder will be
misread as a delete + add — wiping the DO's storage. To make
renames safe, export an explicit id from the agent:
export const agentId = "admin_users_v1";
export default class AdminUsersAgent extends Agent<Env, State> {
// ...
}The string is arbitrary — pick something that won't change. Once set, folder moves don't affect it, and class renames (changing the TypeScript class name) are detected as renames, not deletes.
The first time you actually need to rename an agent is usually
the first time you discover the default agentId
isn't stable. At that point, setting an explicit id still
works — but you'll have to do one careful deploy to ensure
the lockfile records your intent correctly.
Easier: add export const agentId = "..." to
every agent at creation time and treat it as part of the
ceremony of defining an agent.
Preview what would change
ayjnt migrate shows the migration diff without writing
anything:
$ bun run migrate
Pending migration: v3 (2026-04-14T12:15:00Z)
~ renamed:
ChatAgent -> TalkAgent (agentId: chat)
- deleted (storage will be destroyed):
OldAgent (agentId: old_agent)
Run `ayjnt build` to stage this migration in .ayjnt/migrations.json.Use this before every deploy if you've made structural changes, especially during refactors.
The git-safety contract
Here's the core rule: ayjnt deploy refuses to run from an out-of-sync
working tree
. Specifically, it checks:
-
git status --porcelainis empty — no uncommitted changes. - Your branch isn't ahead of
origin/<branch>— no unpushed commits. origin/<branch>isn't ahead of yours — no unpulled commits.-
Running the build wouldn't produce a new migration entry not
yet in the committed
migrations.json.
If any of these fail, deploy aborts with an actionable error
message. --force exists for emergencies but is loud
about it.
Two developers can't race to deploy and produce divergent
migration histories. Without this check, dev A could rename ChatAgent → ChatV2Agent, deploy it, and
commit the lockfile; dev B (who hadn't pulled) could rename ChatAgent → TalkAgent, deploy
that, overwriting the wrangler-side state. Production
would be in an undefined state relative to both developers'
lockfiles. ayjnt catches this before the push happens.
Typical workflow
The canonical flow for making a DO-schema-affecting change:
- Rename / add / delete an agent folder.
-
ayjnt migrate— review the preview. Does it match your intent? -
ayjnt build— stages the new migration into.ayjnt/migrations.json. -
git diff .ayjnt/migrations.json— sanity check the committed version matches. git add -A && git commit -m "rename chat to talk"git push origin mainayjnt deploy
Deleting an agent
Removing an agent folder stages a deleted_classes
entry. The next deploy permanently destroys the DO storage
for that class. There's no recovery.
Wrangler deletes the SQLite storage for every DO instance of that class when the migration applies. If you weren't sure, put the migration off. Once shipped, the data is gone.
In practice, if you want to retire an agent but keep the data: write a migration script that reads the agent's state and writes it to D1 or R2 before you delete the folder. Run it in dev or staging, confirm, then retire.
Environments
Wrangler supports per-environment deployments (staging, production)
via the --env flag. Each environment has its own DO
storage and therefore its own migration tag state at Cloudflare. The
lockfile you commit applies to all environments — the assumption is
that every migration should apply everywhere in order.
If you want environment-specific overrides (different KV binding,
different routes), the wrangler pass-through in ayjnt
accepts any extra flags: ayjnt deploy --env staging
forwards to wrangler deploy --env staging. More on
this under
Deployment
.