diff --git a/.changeset/nasty-teachers-drop.md b/.changeset/nasty-teachers-drop.md new file mode 100644 index 000000000000..4613e6cc5e03 --- /dev/null +++ b/.changeset/nasty-teachers-drop.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +--- + +feat: implement service environments + durable objects + +Now that the APIs for getting migrations tags of services works as expected, this lands support for publishing durable objects to service environments, including migrations. It also removes the error we used to throw when attempting to use service envs + durable objects. + +Fixes https://github.com/cloudflare/wrangler2/issues/739 diff --git a/packages/wrangler/src/__tests__/publish.test.ts b/packages/wrangler/src/__tests__/publish.test.ts index dace2ef36ea5..da3ee66fd350 100644 --- a/packages/wrangler/src/__tests__/publish.test.ts +++ b/packages/wrangler/src/__tests__/publish.test.ts @@ -2389,7 +2389,7 @@ export default{ `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); mockSubDomainRequest(); - mockScriptData({ scripts: [] }); // no previously uploaded scripts at all + mockLegacyScriptData({ scripts: [] }); // no previously uploaded scripts at all mockUploadWorkerRequest({ expectedMigrations: { new_tag: "v2", @@ -2428,7 +2428,9 @@ export default{ `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); mockSubDomainRequest(); - mockScriptData({ scripts: [{ id: "test-name", migration_tag: "v1" }] }); + mockLegacyScriptData({ + scripts: [{ id: "test-name", migration_tag: "v1" }], + }); mockUploadWorkerRequest({ expectedMigrations: { old_tag: "v1", @@ -2473,7 +2475,9 @@ export default{ `export class SomeClass{}; export class SomeOtherClass{}; export class YetAnotherClass{}; export default {};` ); mockSubDomainRequest(); - mockScriptData({ scripts: [{ id: "test-name", migration_tag: "v3" }] }); + mockLegacyScriptData({ + scripts: [{ id: "test-name", migration_tag: "v3" }], + }); mockUploadWorkerRequest({ expectedMigrations: undefined, }); @@ -2492,7 +2496,7 @@ export default{ }); describe("service environments", () => { - it("should error when using service environments + durable objects", async () => { + it("should publish all migrations on first publish", async () => { writeWranglerToml({ durable_objects: { bindings: [ @@ -2500,28 +2504,200 @@ export default{ { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, ], }, - migrations: [{ tag: "v1", new_classes: ["SomeClass"] }], + migrations: [ + { tag: "v1", new_classes: ["SomeClass"] }, + { tag: "v2", new_classes: ["SomeOtherClass"] }, + ], }); fs.writeFileSync( "index.js", `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); + mockSubDomainRequest(); + mockServiceScriptData({}); // no scripts at all + mockUploadWorkerRequest({ + legacyEnv: false, + expectedMigrations: { + new_tag: "v2", + steps: [ + { new_classes: ["SomeClass"] }, + { new_classes: ["SomeOtherClass"] }, + ], + }, + }); + await runWrangler("publish index.js --legacy-env false"); + expect(std.out).toMatchInlineSnapshot(` + "Uploaded test-name (TIMINGS) + Published test-name (TIMINGS) + test-name.test-sub-domain.workers.dev" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(` + "▲ [WARNING] Service environments are in beta, and their behaviour is guaranteed to change in the future. DO NOT USE IN PRODUCTION. + + " + `); + }); + + it("should publish all migrations on first publish (--env)", async () => { + writeWranglerToml({ + durable_objects: { + bindings: [ + { name: "SOMENAME", class_name: "SomeClass" }, + { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, + ], + }, + env: { + xyz: { + durable_objects: { + bindings: [ + { name: "SOMENAME", class_name: "SomeClass" }, + { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, + ], + }, + }, + }, + migrations: [ + { tag: "v1", new_classes: ["SomeClass"] }, + { tag: "v2", new_classes: ["SomeOtherClass"] }, + ], + }); + fs.writeFileSync( + "index.js", + `export class SomeClass{}; export class SomeOtherClass{}; export default {};` + ); mockSubDomainRequest(); + mockServiceScriptData({ env: "xyz" }); // no scripts at all + mockUploadWorkerRequest({ + legacyEnv: false, + env: "xyz", + expectedMigrations: { + new_tag: "v2", + steps: [ + { new_classes: ["SomeClass"] }, + { new_classes: ["SomeOtherClass"] }, + ], + }, + }); - await expect( - runWrangler("publish index.js --legacy-env false") - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Publishing Durable Objects to a service environment is not currently supported. This is being tracked at https://github.com/cloudflare/wrangler2/issues/739"` + await runWrangler("publish index.js --legacy-env false --env xyz"); + expect(std.out).toMatchInlineSnapshot(` + "Uploaded test-name (xyz) (TIMINGS) + Published test-name (xyz) (TIMINGS) + xyz.test-name.test-sub-domain.workers.dev" + `); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.warn).toMatchInlineSnapshot(` + "▲ [WARNING] Service environments are in beta, and their behaviour is guaranteed to change in the future. DO NOT USE IN PRODUCTION. + + " + `); + }); + + it("should use a script's current migration tag when publishing migrations", async () => { + writeWranglerToml({ + durable_objects: { + bindings: [ + { name: "SOMENAME", class_name: "SomeClass" }, + { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, + ], + }, + migrations: [ + { tag: "v1", new_classes: ["SomeClass"] }, + { tag: "v2", new_classes: ["SomeOtherClass"] }, + ], + }); + fs.writeFileSync( + "index.js", + `export class SomeClass{}; export class SomeOtherClass{}; export default {};` ); + mockSubDomainRequest(); + mockServiceScriptData({ + script: { id: "test-name", migration_tag: "v1" }, + }); + mockUploadWorkerRequest({ + legacyEnv: false, + expectedMigrations: { + old_tag: "v1", + new_tag: "v2", + steps: [ + { + new_classes: ["SomeOtherClass"], + }, + ], + }, + }); + + await runWrangler("publish index.js --legacy-env false"); expect(std).toMatchInlineSnapshot(` Object { "debug": "", - "err": "X [ERROR] Publishing Durable Objects to a service environment is not currently supported. This is being tracked at https://github.com/cloudflare/wrangler2/issues/739 + "err": "", + "out": "Uploaded test-name (TIMINGS) + Published test-name (TIMINGS) + test-name.test-sub-domain.workers.dev", + "warn": "▲ [WARNING] Service environments are in beta, and their behaviour is guaranteed to change in the future. DO NOT USE IN PRODUCTION. ", - "out": " - If you think this is a bug then please create an issue at https://github.com/cloudflare/wrangler2/issues/new.", + } + `); + }); + + it("should use an environment's current migration tag when publishing migrations", async () => { + writeWranglerToml({ + durable_objects: { + bindings: [ + { name: "SOMENAME", class_name: "SomeClass" }, + { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, + ], + }, + env: { + xyz: { + durable_objects: { + bindings: [ + { name: "SOMENAME", class_name: "SomeClass" }, + { name: "SOMEOTHERNAME", class_name: "SomeOtherClass" }, + ], + }, + }, + }, + migrations: [ + { tag: "v1", new_classes: ["SomeClass"] }, + { tag: "v2", new_classes: ["SomeOtherClass"] }, + ], + }); + fs.writeFileSync( + "index.js", + `export class SomeClass{}; export class SomeOtherClass{}; export default {};` + ); + mockSubDomainRequest(); + mockServiceScriptData({ + script: { id: "test-name", migration_tag: "v1" }, + env: "xyz", + }); + mockUploadWorkerRequest({ + legacyEnv: false, + env: "xyz", + expectedMigrations: { + old_tag: "v1", + new_tag: "v2", + steps: [ + { + new_classes: ["SomeOtherClass"], + }, + ], + }, + }); + + await runWrangler("publish index.js --legacy-env false --env xyz"); + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "out": "Uploaded test-name (xyz) (TIMINGS) + Published test-name (xyz) (TIMINGS) + xyz.test-name.test-sub-domain.workers.dev", "warn": "▲ [WARNING] Service environments are in beta, and their behaviour is guaranteed to change in the future. DO NOT USE IN PRODUCTION. ", @@ -2674,7 +2850,7 @@ export default{ ], }); mockSubDomainRequest(); - mockScriptData({ scripts: [] }); + mockLegacyScriptData({ scripts: [] }); await expect(runWrangler("publish index.js")).resolves.toBeUndefined(); expect(std.out).toMatchInlineSnapshot(` @@ -3434,7 +3610,9 @@ export default{ `export class ExampleDurableObject {}; export default{};` ); mockSubDomainRequest(); - mockScriptData({ scripts: [{ id: "test-name", migration_tag: "v1" }] }); + mockLegacyScriptData({ + scripts: [{ id: "test-name", migration_tag: "v1" }], + }); mockUploadWorkerRequest({ expectedBindings: [ { @@ -3508,7 +3686,9 @@ export default{ `export class ExampleDurableObject {}; export default{};` ); mockSubDomainRequest(); - mockScriptData({ scripts: [{ id: "test-name", migration_tag: "v1" }] }); + mockLegacyScriptData({ + scripts: [{ id: "test-name", migration_tag: "v1" }], + }); mockUploadWorkerRequest({ expectedType: "esm", expectedBindings: [ @@ -4353,7 +4533,7 @@ function mockDeleteUnusedAssetsRequest( type LegacyScriptInfo = { id: string; migration_tag?: string }; -function mockScriptData(options: { scripts: LegacyScriptInfo[] }) { +function mockLegacyScriptData(options: { scripts: LegacyScriptInfo[] }) { const { scripts } = options; setMockResponse( "/accounts/:accountId/workers/scripts", @@ -4364,3 +4544,59 @@ function mockScriptData(options: { scripts: LegacyScriptInfo[] }) { } ); } + +type DurableScriptInfo = { id: string; migration_tag?: string }; + +function mockServiceScriptData(options: { + script?: DurableScriptInfo; + scriptName?: string; + env?: string; +}) { + const { script } = options; + if (options.env) { + if (!script) { + setMockRawResponse( + "/accounts/:accountId/workers/services/:scriptName/environments/:envName", + "GET", + () => { + return createFetchResult(null, false, [ + { code: 10092, message: "workers.api.error.environment_not_found" }, + ]); + } + ); + return; + } + setMockResponse( + "/accounts/:accountId/workers/services/:scriptName/environments/:envName", + "GET", + ([_url, accountId, scriptName, envName]) => { + expect(accountId).toEqual("some-account-id"); + expect(scriptName).toEqual(options.scriptName || "test-name"); + expect(envName).toEqual(options.env); + return { script }; + } + ); + } else { + if (!script) { + setMockRawResponse( + "/accounts/:accountId/workers/services/:scriptName", + "GET", + () => { + return createFetchResult(null, false, [ + { code: 10090, message: "workers.api.error.service_not_found" }, + ]); + } + ); + return; + } + setMockResponse( + "/accounts/:accountId/workers/services/:scriptName", + "GET", + ([_url, accountId, scriptName]) => { + expect(accountId).toEqual("some-account-id"); + expect(scriptName).toEqual(options.scriptName || "test-name"); + return { default_environment: { script } }; + } + ); + } +} diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index 2e668c966ff3..0a11bc4c5193 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -1050,7 +1050,7 @@ const validateBindingsProperty = diagnostics.warnings.push( `The following bindings are at the top level, but not on "env.${envName}".\n` + `This is not what you probably want, since "${field}" configuration is not inherited by environments.\n` + - `Please add a binding for each to "${fieldPath}.bindings".` + + `Please add a binding for each to "${fieldPath}.bindings":\n` + missingBindings.map((name) => `- ${name}`).join("\n") ); } diff --git a/packages/wrangler/src/publish.ts b/packages/wrangler/src/publish.ts index 41df3a6c6437..da67dbd3d2ef 100644 --- a/packages/wrangler/src/publish.ts +++ b/packages/wrangler/src/publish.ts @@ -145,13 +145,6 @@ export default async function publish(props: Props): Promise { // Some validation of durable objects + migrations if (config.durable_objects.bindings.length > 0) { - // TODO: implement durable objects for service environments - if (!props.legacyEnv) { - throw new Error( - "Publishing Durable Objects to a service environment is not currently supported. This is being tracked at https://github.com/cloudflare/wrangler2/issues/739" - ); - } - // intrinsic [durable_objects] implies [migrations] const exportedDurableObjects = config.durable_objects.bindings.filter( (binding) => !binding.script_name @@ -173,14 +166,48 @@ export default async function publish(props: Props): Promise { let migrations; if (config.migrations.length > 0) { // get current migration tag - const scripts = await fetchResult< - { id: string; migration_tag: string }[] - >(`/accounts/${accountId}/workers/scripts`); - const script = scripts.find(({ id }) => id === scriptName); + type ScriptData = { id: string; migration_tag?: string }; + let script: ScriptData | undefined; + if (!props.legacyEnv) { + try { + if (props.env) { + const scriptData = await fetchResult<{ + script: ScriptData; + }>( + `/accounts/${accountId}/workers/services/${scriptName}/environments/${props.env}` + ); + script = scriptData.script; + } else { + const scriptData = await fetchResult<{ + default_environment: { + script: ScriptData; + }; + }>(`/accounts/${accountId}/workers/services/${scriptName}`); + script = scriptData.default_environment.script; + } + } catch (err) { + if ( + ![ + 10090, // corresponds to workers.api.error.service_not_found, so the script wasn't previously published at all + 10092, // workers.api.error.environment_not_found, so the script wasn't published to this environment yet + ].includes((err as { code: number }).code) + ) { + throw err; + } + // else it's a 404, no script found, and we can proceed + } + } else { + const scripts = await fetchResult( + `/accounts/${accountId}/workers/scripts` + ); + script = scripts.find(({ id }) => id === scriptName); + } + if (script?.migration_tag) { // was already published once + const scriptMigrationTag = script.migration_tag; const foundIndex = config.migrations.findIndex( - (migration) => migration.tag === script.migration_tag + (migration) => migration.tag === scriptMigrationTag ); if (foundIndex === -1) { logger.warn(