Skip to content
Closed
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
27 changes: 27 additions & 0 deletions packages/alchemy/src/Cloudflare/Workers/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,33 @@ export interface WorkerProps<
};
limits?: WorkerLimits;
placement?: WorkerPlacement;
/**
* Opt in to deleting a Durable Object class (and its stored data) when it
* moves from this Worker to another script.
*
* When a Durable Object class that this Worker previously hosted becomes a
* cross-script reference (a `Cloudflare.DurableObject(..., { scriptName })`
* binding pointing at another Worker), Cloudflare requires the class to be
* removed from this Worker with a delete-class migration. **Durable Object
* storage does not move with the class** — the new host starts with a fresh,
* empty namespace, and the delete-class migration permanently destroys the
* old namespace and all of its data on this Worker. This is irreversible.
*
* Because of that, the deploy fails by default with an explanatory error.
* Set this to `true` to confirm the deletion: Alchemy then performs the move
* as two uploads (Cloudflare rejects doing both at once) — first removing the
* cross-script binding while applying the delete-class migration, then
* re-attaching the full binding set. Between the two uploads there is a
* brief window where the moved binding is absent — in-flight requests that
* use it will fail; the same window exists when performing the move
* manually across two deploys.
*
* Only affects the transition where the class leaves this Worker; it never
* deletes classes the Worker still hosts locally.
*
* @default false
*/
deleteMovedDurableObjectClasses?: boolean;
/**
* Tracks Durable Object and Workflow exports for Effect-native Workers only.
* Populated automatically from bindings; do not set manually.
Expand Down
217 changes: 178 additions & 39 deletions packages/alchemy/src/Cloudflare/Workers/WorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ class MissingDurableObjects extends Data.TaggedError("MissingDurableObjects")<{
expected: string[];
}> {}

/**
* Raised when a Durable Object class moves from this Worker to another script
* (it becomes a cross-script binding with `scriptName` set) while the class is
* simultaneously being removed from this Worker, and the user has not opted in
* to the destructive delete. Durable Object storage does not transfer with the
* class, so applying the delete-class migration would irreversibly destroy the
* namespace and its data on this Worker. See
* {@link WorkerProps.deleteMovedDurableObjectClasses}.
*/
export class DurableObjectClassMoved extends Data.TaggedError(
"DurableObjectClassMoved",
)<{
scriptName: string;
movedClasses: { className: string; scriptName: string }[];
}> {
override get message(): string {
const moves = this.movedClasses
.map((m) => `'${m.className}' (now hosted on '${m.scriptName}')`)
.join(", ");
return [
`Durable Object class ${moves} moved from Worker '${this.scriptName}' to another script — the binding is now a cross-script reference — and the class is being removed from '${this.scriptName}'.`,
`Durable Object storage does NOT transfer when a class moves: the new host starts with a fresh, empty namespace, while the namespace on '${this.scriptName}' still holds all of its data. Deploying this change applies a delete-class migration that permanently destroys that namespace and every object stored in it. This cannot be undone.`,
`If the data is disposable (or you have already migrated it), opt in explicitly by setting 'deleteMovedDurableObjectClasses: true' on Worker '${this.scriptName}'. Alchemy will then remove the binding and delete the class in two separate uploads, as Cloudflare requires.`,
`To keep the data, either keep the class defined on '${this.scriptName}', or perform the move manually across two deploys (first remove the binding, then re-add it as a cross-script reference).`,
].join("\n\n");
}
}

/**
* Resolve the Workers for Platforms dispatch-namespace *name* from a resolved
* `namespace` prop or persisted attribute. The engine resolves a passed
Expand Down Expand Up @@ -1524,47 +1552,129 @@ export const LiveWorkerProvider = () =>
tailConsumers: undefined,
usageModel: undefined,
};
const worker = yield* putWorkerScript({
accountId,
scriptName: name,
dispatchNamespace,
metadata,
files: bundle.files,
}).pipe(
Effect.catch((err) => {
// When adopting a Worker managed by Wrangler (or after a previous
// deploy with mismatched migrations), the old_tag precondition
// fails. The only way to discover the actual tag is through the
// error message — getScriptSettings is meant to return it but
// doesn't at runtime.
const msg = String(
typeof err === "object" && err !== null && "message" in err
? err.message
: err,
);
const expectedTag = msg.match(
/when expected tag is ['"]?([^'"]+)['"]?/,
)?.[1];
if (expectedTag) {
return putWorkerScript({
accountId,
scriptName: name,
dispatchNamespace,
metadata: {
...metadata,
migrations: {
...migrations,
oldTag: expectedTag,
newTag: bumpMigrationTagVersion(expectedTag),
// A Durable Object class that moved from this worker to another script
// (now referenced via a cross-script binding with `scriptName` set)
// lands in `deletedClasses` here while its class name is still bound.
// Cloudflare rejects that single upload: its validator matches DO
// bindings by `class_name` alone (ignoring `script_name`) and refuses
// to delete a class whose binding is still attached ("Cannot apply
// --delete-class migration to class 'X' without also removing the
// binding that references it").
//
// That rejection is a safety interlock: Durable Object storage does
// NOT move with the class — the new host gets a fresh, empty namespace
// and the old namespace on THIS worker still holds all its data, which
// the delete-class migration irreversibly destroys.
const conflictingCrossScriptBindings =
getConflictingCrossScriptDoBindings(
metadataBindings,
deletedClasses,
name,
);
if (
conflictingCrossScriptBindings.length > 0 &&
news.deleteMovedDurableObjectClasses !== true
) {
return yield* Effect.fail(
new DurableObjectClassMoved({
scriptName: name,
movedClasses: Array.from(
new Map(
conflictingCrossScriptBindings.map((b) => [
`${b.className}${b.scriptName}`,
{ className: b.className, scriptName: b.scriptName },
]),
).values(),
),
}),
);
}
// Opt-in confirmed (or no conflict). When there is a conflict, split
// the deploy: upload #1 applies the delete migration with the
// conflicting cross-script binding(s) omitted, upload #2 re-attaches
// the full binding set with no migration.
const isTwoPhaseDoMove = conflictingCrossScriptBindings.length > 0;
if (isTwoPhaseDoMove) {
yield* session.note(
"DO class(es) moved cross-script: applying delete migration in a separate upload before attaching cross-script bindings",
);
}

const uploadWithTagRetry = (
uploadMetadata: workers.PutScriptRequest["metadata"],
) =>
putWorkerScript({
accountId,
scriptName: name,
dispatchNamespace,
metadata: uploadMetadata,
files: bundle.files,
}).pipe(
Effect.catch((err) => {
// When adopting a Worker managed by Wrangler (or after a previous
// deploy with mismatched migrations), the old_tag precondition
// fails. The only way to discover the actual tag is through the
// error message — getScriptSettings is meant to return it but
// doesn't at runtime.
const msg = String(
typeof err === "object" && err !== null && "message" in err
? err.message
: err,
);
const expectedTag = msg.match(
/when expected tag is ['"]?([^'"]+)['"]?/,
)?.[1];
if (expectedTag && uploadMetadata.migrations) {
return putWorkerScript({
accountId,
scriptName: name,
dispatchNamespace,
metadata: {
...uploadMetadata,
migrations: {
...uploadMetadata.migrations,
oldTag: expectedTag,
newTag: bumpMigrationTagVersion(expectedTag),
},
},
},
files: bundle.files,
});
}
// @effect-diagnostics-next-line anyUnknownInErrorContext:off
return Effect.fail(err as any);
}),
files: bundle.files,
});
}
// @effect-diagnostics-next-line anyUnknownInErrorContext:off
return Effect.fail(err as any);
}),
);

// Upload #1 carries the delete migration. In the two-phase case it
// omits the conflicting cross-script bindings so Cloudflare accepts the
// class delete; otherwise it is the normal single upload with the full
// binding set.
// Widened to the metadata element type so `.includes` accepts any
// binding (the conflict list is narrowed by the type-guard filter).
const conflictingBindings: ReadonlyArray<
(typeof metadataBindings)[number]
> = conflictingCrossScriptBindings;
let worker = yield* uploadWithTagRetry(
isTwoPhaseDoMove
? {
...metadata,
bindings: metadataBindings.filter(
(b) => !conflictingBindings.includes(b),
),
}
: metadata,
);
// Upload #2 re-attaches the full binding set (cross-script bindings
// included) with no migration. Upload #1 already advanced Cloudflare's
// migration tag to `newMigrationTag`, and the metadata tags carry the
// same `alchemy:migration-tag:` value, so the recorded tag stays
// consistent after this final upload.
if (isTwoPhaseDoMove) {
worker = yield* uploadWithTagRetry({
...metadata,
migrations: undefined,
});
}
const { settings, durableObjectNamespaces } =
yield* getWorkerSettingsWithDurableObjects(
name,
Expand Down Expand Up @@ -2543,6 +2653,35 @@ function mergeDurableObjectClasses(
);
}

/**
* Given the resolved `durable_object_namespace` metadata bindings and the set
* of class names about to be dropped by a `deleted_classes` migration, return
* the bindings that would make Cloudflare reject the upload: cross-script
* bindings (`scriptName` set to another worker) whose `className` is in
* `deletedClasses`. Cloudflare matches DO bindings by `class_name` alone when
* validating a delete-class migration, so a `script_name` pointing at the new
* host does not exempt the binding — the delete and the binding removal must
* ship in two separate uploads. When this returns a non-empty list the provider
* takes the two-phase upload path.
*
* @internal exported for unit testing.
*/
export const getConflictingCrossScriptDoBindings = <
B extends { type: string; className?: string; scriptName?: string },
>(
metadataBindings: readonly B[],
deletedClasses: readonly string[],
workerName: string,
): (B & { className: string; scriptName: string })[] =>
metadataBindings.filter(
(b): b is B & { className: string; scriptName: string } =>
b.type === "durable_object_namespace" &&
b.className !== undefined &&
deletedClasses.includes(b.className) &&
b.scriptName !== undefined &&
b.scriptName !== workerName,
);

function getDurableObjectBindings(
bindings: ReadonlyArray<ResourceBinding>,
workerName: string,
Expand Down
Loading