Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
.DS_Store
.alchemy
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Repository Instructions

- `src/worker.ts` and `src/client.ts` are copied into user projects, not imported as a package. Keep each one self-contained: no imports between them or from other repo files. `worker.ts` may depend only on `devalue`; `client.ts` only on `fetch`.
- The two files share only the HTTP contract: `/__migrate/:binding/{export,resolve}`, the `x-migrator` response header, and `migrator.*` error codes. Change both sides together.
- `src/worker.ts`, `src/client.ts` and `src/alchemy.ts` are copied into user projects, not imported as a package. `worker.ts` and `client.ts` import nothing from each other or from other repo files. `alchemy.ts` may import `./worker`, and nothing imports `alchemy.ts`. `worker.ts` may depend only on `devalue`, `client.ts` only on `fetch`, and `alchemy.ts` only on `effect`, `alchemy` and `@alchemy.run/cloudflare`.
- `worker.ts` and `client.ts` share only the HTTP contract: `/__migrate/:binding/{export,resolve}`, the `x-migrator` response header, and `migrator.*` error codes. Change both sides together.
- Never add a code path that deletes or rewrites user data on Cloudflare. The only allowed writes are the `__migrator` state table and removing a migrated object's alarm, which is saved there so rollback can restore it.
- An export may mark a Durable Object migrated only inside the same `transactionSync` as the dirty check.
- Tests run the real fixture Worker (`tests/fixture/wrangler.jsonc`) through Wrangler's `createTestHarness` and talk to it over HTTP. Do not use Miniflare directly, mocks, or direct calls into `src/worker.ts`.
- The Alchemy tests run `tests/alchemy-fixture` with `alchemy dev` in a temp directory with its own HOME and a minimal env. Never pass Cloudflare credentials to them.
- Test outcomes a caller can observe. Never paper over a flake with retries or longer waits; find the race.
- `tests/smoke/run.ts` checks what local workerd cannot: Cloudflare limits, `ctx.id.name`, the REST list API. Run it before changing the export format or the list logic. Keep account ids and URLs out of the repo.
- Examples in `examples/` must type-check (`pnpm check-types`). Update `docs/guide.md` and the README API table when the API changes.
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

| Path | Holds |
|---|---|
| `src/` | The two files users copy. Each stays self-contained. |
| `tests/` | End-to-end tests. `tests/fixture/` is a real Worker that Wrangler's test harness runs in local workerd. |
| `src/` | The files users copy. `worker.ts` and `client.ts` stay self-contained. `alchemy.ts` builds on `worker.ts`. |
| `tests/` | End-to-end tests. `tests/fixture/` is a real Worker that Wrangler's test harness runs in local workerd. `tests/alchemy-fixture/` is an Alchemy stack that `alchemy dev` runs in local workerd. |
| `tests/smoke/` | The same checks against the fixture deployed to a real Cloudflare account |
| `examples/` | Examples that must type-check |
| `docs/`, `skills/` | The guide and the agent skill |
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ This is a repo, not a package. Copy two files:
| [`src/worker.ts`](src/worker.ts) | Your Cloudflare Worker | Adds the export route, and blocks a Durable Object once it is copied |
| [`src/client.ts`](src/client.ts) | Your new server | Copies each Durable Object's data, and finds the ones nobody used |

On [Alchemy](https://alchemy.run) with Effect-native Durable Objects, also copy [`src/alchemy.ts`](src/alchemy.ts). See the [guide](docs/guide.md#alchemy).

**Worker:**

```ts
Expand Down Expand Up @@ -72,6 +74,8 @@ Call `migrate` before you handle a request for that Durable Object. Run `migrato
|---|---|---|
| Worker | `migratable(DOClass)` | Wraps your Durable Object class. Adds the export, and blocks requests once it is copied. |
| Worker | `migrationRoute(req, env, { token })` | Handles the migrator's requests (`/__migrate/*`). Returns `null` for everything else, so your app handles it. |
| Alchemy | `Migrator.migratable(effect)` | `src/alchemy.ts`. Wraps an Effect-native Durable Object. After migration its RPC methods fail with `MigrationMovedError`. |
| Alchemy | `yield* Migrator.withMigrationRoute(app)` | `src/alchemy.ts`. Gives back your Worker's fetch with `/__migrate/*` handled first. |
| Server | `createClient({ workerUrl, token })` | Connects to your Worker |
| Server | `migrator.exportObject({ binding, id })` | Returns the Durable Object's data as SQL, one page at a time. Throws `MigrationDirtyError` if it was written to during the copy. |
| Server | `migrator.resolve(binding, { name } \| { id })` | Converts a name to an id, or an id to its name |
Expand All @@ -85,7 +89,7 @@ Call `migrate` before you handle a request for that Durable Object. Run `migrato
## Resources

- [Guide](docs/guide.md): how to switch over, how to roll back, and what is not supported.
- [Examples](examples/): copy into a `.sqlite` file, track progress in Postgres, copy into Rivet Actors.
- [Examples](examples/): copy into a `.sqlite` file, track progress in Postgres, copy into Rivet Actors, set up a Worker on Alchemy.
- [Migration skill](skills/durable-object-migrator/SKILL.md): give it to your coding agent to set this up in your project.
- [Discord](https://rivet.dev/discord): questions and support.

Expand Down
41 changes: 41 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,46 @@ The migrator never deletes data on Cloudflare. Its writes are its own `__migrato
- When a Durable Object is copied, its pending alarm is removed, so scheduled work cannot run on the old copy. The new server gets the alarm time in `__do_meta`.
- With `MIGRATOR_DISABLED=true`, the alarm is set again the next time the object starts. If its time has passed, it runs right away. An object that gets no request after rollback keeps its alarm off until it does.

## Alchemy

[Alchemy v2](https://alchemy.run) has two ways to write a Durable Object. Both can be migrated. See [`examples/alchemy/`](../examples/alchemy/).

**Plain class.** Wrap it with `migratable` and add `migrationRoute`, as in the [README](../README.md#getting-started). Put the token and the rollback flag in the Worker's env:

```ts
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: {
ROOMS: Rooms,
WORKER_MIGRATOR_TOKEN: Config.Redacted("WORKER_MIGRATOR_TOKEN"),
MIGRATOR_DISABLED: Config.String("MIGRATOR_DISABLED").pipe(Config.withDefault("false")),
},
});
```

**Effect-native.** Alchemy writes the class for you, so there is no class to wrap. Copy [`src/alchemy.ts`](../src/alchemy.ts) next to `src/worker.ts` and wrap the Effect instead:

```ts
import * as Migrator from "./alchemy"; // src/alchemy.ts

export default class Room extends Cloudflare.DurableObject<Room>()(
"Room",
Migrator.migratable(Effect.gen(function* () { /* your Durable Object */ })),
) {}

// In your Worker's init:
return { fetch: yield* Migrator.withMigrationRoute(app) };
```

- The adapter reads `WORKER_MIGRATOR_TOKEN` and `MIGRATOR_DISABLED` with `Config`, so Alchemy adds them to the Worker. Set them in `.env` before you deploy.
- After migration, every RPC method fails with `MigrationMovedError`. It is part of each method's error type, so you can handle it with `Effect.catchTag("MigrationMovedError", ...)`. Your methods keep their own argument and return types.
- The binding name is the Durable Object's name: `exportObject({ binding: "Room", id })`.

For both kinds:

- Backfill needs the Worker's script name. Use the `workerName` output of your stack, not the stack name.
- If you read env directly in an Effect Worker (for example through `Cloudflare.WorkerEnvironment`), values that Alchemy bound from `Config` arrive wrapped, as `{"_tag":"Redacted","value":"..."}`. Read them with `Config` instead.

## Limits

| Limit | What it means |
Expand All @@ -120,5 +160,6 @@ The migrator never deletes data on Cloudflare. Its writes are its own `__migrato
| Virtual tables (FTS5) | Not supported. The export fails with a clear error. |
| Writes from background work after its request returns | Covered when the work goes through `ctx.waitUntil`: an export cannot finish while it is pending. Work that is neither awaited nor passed to `ctx.waitUntil` (a floating promise, a detached Effect fiber) can still write after migration and be lost. |
| Sockets accepted with `ws.accept()` instead of hibernation | Not closed after migration |
| Alchemy alarm callbacks (`Alchemy.makeCallback`) | A callback that is still running when an export finishes is not counted as in flight, so a write it makes after that point stays on the old copy. The export removes the pending alarm, so no new callback starts after migration. |
| KV-backed (non-SQLite) Durable Objects | Not supported |
| Every finished export marks the Durable Object migrated | There is no backup-only export yet. Exporting a Durable Object moves it. |
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Examples

Each example imports `src/client.ts` from this repo. In your project, copy `src/client.ts` next to your code instead.
Each example imports files from `src/` in this repo. In your project, copy those files next to your code instead.

| Example | Shows |
|---|---|
| [`export-to-sqlite/`](export-to-sqlite/export.ts) | Move one Durable Object into a local `.sqlite` file |
| [`postgres/`](postgres/migrate.ts) | Postgres holds the done list, a lock per Durable Object, and the backfill position. Data goes to `.sqlite` files. |
| [`alchemy/`](alchemy/alchemy.run.ts) | An Alchemy v2 stack with both kinds of Durable Object: a plain class ([`plain-worker.ts`](alchemy/plain-worker.ts)) and an Effect-native one ([`Chat.ts`](alchemy/Chat.ts)) |
| [`rivet/`](rivet/registry.ts) | Each Durable Object becomes a Rivet Actor with the same name. The actor imports its data in `onMigrate`. [`server.ts`](rivet/server.ts) starts the actors. [`backfill.ts`](rivet/backfill.ts) wakes the rest. |

Translating SQLite SQL into Postgres tables is not included. The export is SQLite SQL.
39 changes: 39 additions & 0 deletions examples/alchemy/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// The Effect Worker in front of Chat. The migrator's binding name is "Chat".
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Effect from "effect/Effect";
import * as HttpRouter from "effect/http/HttpRouter";
import * as HttpServerRequest from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
import * as Schema from "effect/Schema";
import * as Migrator from "../../src/alchemy.ts";
import Chat from "./Chat.ts";

const ChatParams = Schema.Struct({ room: Schema.String });

export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url, compatibility: { date: "2026-07-01" } },
Effect.gen(function* () {
const chats = yield* Chat;

const routes = HttpRouter.add(
"POST",
"/chats/:room/messages",
Effect.gen(function* () {
const { room } = yield* HttpRouter.schemaPathParams(ChatParams);
const text = yield* (yield* HttpServerRequest.HttpServerRequest).text;
yield* chats.getByName(room).addMessage(text);
return HttpServerResponse.text("sent");
}),
);

const app = (yield* HttpRouter.toHttpEffect(routes)).pipe(
Effect.catchTags({
SchemaError: (error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 400 })),
// Only reached if a request still lands on Cloudflare after the object moved.
MigrationMovedError: () => Effect.succeed(HttpServerResponse.text("moved", { status: 503 })),
}),
);
return { fetch: yield* Migrator.withMigrationRoute(app) };
}),
) {}
22 changes: 22 additions & 0 deletions examples/alchemy/Chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// An Effect-native Durable Object. It needs src/alchemy.ts and src/worker.ts.
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Effect from "effect/Effect";
import * as Migrator from "../../src/alchemy.ts";

export default class Chat extends Cloudflare.DurableObject<Chat>()(
"Chat",
Migrator.migratable(
Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
const sql = state.storage.sql;
yield* sql.exec(`CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY, text TEXT)`).pipe(Effect.orDie);
return {
addMessage: Effect.fn("Chat.addMessage")(function* (text: string) {
yield* sql.exec(`INSERT INTO messages(text) VALUES (?)`, text).pipe(Effect.orDie);
}),
};
});
}),
),
) {}
40 changes: 40 additions & 0 deletions examples/alchemy/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Both kinds of Durable Object on Alchemy v2, each ready to export.
//
// Room: a plain class, wrapped with `migratable` from src/worker.ts.
// Chat: an Effect-native object, wrapped with `migratable` from src/alchemy.ts.
//
// Set WORKER_MIGRATOR_TOKEN in .env before you deploy. Backfill needs each
// Worker's script name, which is the `workerName` output, not the stack name.
import { join } from "node:path";
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Alchemy from "alchemy";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import Api from "./Api.ts";

export const Rooms = Cloudflare.DurableObject("Room", { className: "Room" });

// The plain Worker reads its env directly, so the migrator values are declared here.
export const PlainWorker = Cloudflare.Worker("PlainWorker", {
// Resolved from this file, so the stack deploys from any directory.
main: join(import.meta.dirname, "plain-worker.ts"),
compatibility: { date: "2026-07-01" },
env: {
ROOMS: Rooms,
WORKER_MIGRATOR_TOKEN: Config.Redacted("WORKER_MIGRATOR_TOKEN"),
MIGRATOR_DISABLED: Config.String("MIGRATOR_DISABLED").pipe(Config.withDefault("false")),
},
});

export default Alchemy.Stack(
"MigratorExample",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const plain = yield* PlainWorker;
const api = yield* Api;
return {
plain: { url: plain.url.as<string>(), scriptName: plain.workerName },
api: { url: api.url.as<string>(), scriptName: api.workerName },
};
}),
);
11 changes: 11 additions & 0 deletions examples/alchemy/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "example-alchemy",
"private": true,
"type": "module",
"dependencies": {
"@alchemy.run/cloudflare": "2.0.0-beta.84",
"@effect/platform-node": "4.0.6",
"alchemy": "2.0.0-beta.84",
"effect": "4.0.6"
}
}
28 changes: 28 additions & 0 deletions examples/alchemy/plain-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// A plain-class Durable Object. It needs only src/worker.ts.
import { DurableObject } from "cloudflare:workers";
import { migratable, migrationRoute } from "../../src/worker.ts";

type Env = {
ROOMS: DurableObjectNamespace<RoomImpl>;
WORKER_MIGRATOR_TOKEN: string;
};

class RoomImpl extends DurableObject<Env> {
async addMessage(text: string): Promise<void> {
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY, text TEXT)`);
this.ctx.storage.sql.exec(`INSERT INTO messages(text) VALUES (?)`, text);
}
}

export const Room = migratable(RoomImpl);

export default {
async fetch(req: Request, env: Env): Promise<Response> {
const migration = migrationRoute(req, env, { token: env.WORKER_MIGRATOR_TOKEN });
if (migration) return migration;

const name = new URL(req.url).searchParams.get("room") ?? "lobby";
await env.ROOMS.getByName(name).addMessage(await req.text());
return new Response("sent");
},
};
8 changes: 8 additions & 0 deletions examples/alchemy/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types", "node"],
"allowImportingTsExtensions": true
},
"include": ["."]
}
2 changes: 1 addition & 1 deletion examples/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"noEmit": true
},
"include": ["**/*.ts"],
"exclude": ["**/node_modules"]
"exclude": ["**/node_modules", "alchemy"]
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"scripts": {
"test": "vitest run",
"check-types": "tsc --noEmit -p . && tsc --noEmit -p tests && tsc --noEmit -p examples",
"check-types": "tsc --noEmit -p . && tsc --noEmit -p tests && tsc --noEmit -p tests/alchemy-fixture && tsc --noEmit -p examples && tsc --noEmit -p examples/alchemy",
"lint": "biome lint .",
"format:check": "biome format .",
"ci": "pnpm lint && pnpm format:check && pnpm check-types && pnpm test"
Expand All @@ -19,9 +19,13 @@
"devalue": "^5.9.4"
},
"devDependencies": {
"@alchemy.run/cloudflare": "2.0.0-beta.84",
"@biomejs/biome": "2.3.11",
"@cloudflare/workers-types": "^5.20261027.1",
"@effect/platform-node": "4.0.6",
"@types/node": "^26.6.5",
"alchemy": "2.0.0-beta.84",
"effect": "4.0.6",
"typescript": "^5.9.3",
"vitest": "^5.0.5",
"wrangler": "^4.153.0"
Expand Down
Loading
Loading