diff --git a/README.md b/README.md index 9abf51d..775f65a 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,8 @@ xapi-to workers secrets list --env preview --format table # Durable Agent state, asynchronous tasks, workflows, and persistent schedules. xapi-to workers resources create \ --env preview --type do --binding AGENT_STATE --class-name AgentState +xapi-to workers resources create \ + --env preview --type d1 --binding DB --location apac --read-replication disabled xapi-to workers resources create \ --env preview --type queue --binding TASK_QUEUE xapi-to workers resources create \ diff --git a/schemas/worker-project.v1.schema.json b/schemas/worker-project.v1.schema.json index 3f31918..9b3ecf2 100644 --- a/schemas/worker-project.v1.schema.json +++ b/schemas/worker-project.v1.schema.json @@ -147,13 +147,31 @@ "className": { "type": "string", "pattern": "^[A-Za-z_$][A-Za-z0-9_$]{0,127}$" - } + }, + "location": { + "enum": ["wnam", "enam", "weur", "eeur", "apac", "oc"] + }, + "readReplication": { "enum": ["auto", "disabled"] } }, "allOf": [ { "if": { "properties": { "type": { "const": "durable_object" } } }, "then": { "required": ["className"] }, "else": { "not": { "required": ["className"] } } + }, + { + "if": { "required": ["location"] }, + "then": { + "properties": { + "type": { "enum": ["d1_database", "r2_bucket"] } + } + } + }, + { + "if": { "required": ["readReplication"] }, + "then": { + "properties": { "type": { "const": "d1_database" } } + } } ] } diff --git a/skills/xapi/guides/workers.md b/skills/xapi/guides/workers.md index 0940592..34bce15 100644 --- a/skills/xapi/guides/workers.md +++ b/skills/xapi/guides/workers.md @@ -500,3 +500,20 @@ npx xapi-to workers delete --yes ``` The backend preflights managed resources (for example, R2 must be empty), deletes active upstream scripts and resources, then marks the Worker soft-deleted. Records have a 30-day retention window. A partial upstream failure leaves the Worker in `DELETING`; report the error and do not say the Worker is deleted or active. + +## D1 and R2 data location + +Choose the expected primary data-access region when a project creates D1 or R2. The Worker code itself remains globally deployed on Cloudflare's edge network. + +```json +{ + "type": "d1_database", + "bindingName": "DB", + "location": "apac", + "readReplication": "disabled" +} +``` + +Supported location hints are `wnam`, `enam`, `weur`, `eeur`, `apac`, and `oc`. `readReplication` is D1-only and accepts `auto` or `disabled`. Omitting these fields preserves the existing compatible behavior. + +Location is creation-time placement. Changing it on an existing binding is blocked because Cloudflare cannot move an existing D1 database or R2 bucket in place. Create a new binding, migrate and verify the data, switch the application binding, and retain the old resource for rollback before deleting it. diff --git a/src/commands/workers.ts b/src/commands/workers.ts index c16e124..8ed56af 100644 --- a/src/commands/workers.ts +++ b/src/commands/workers.ts @@ -72,7 +72,7 @@ COMMANDS schedules delete --yes bindings resources list --env preview|production - resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME + resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME [--location REGION] [--read-replication MODE] resources delete --env ENV --yes secrets list --env preview|production secrets set --env ENV --from-env VARIABLE @@ -161,6 +161,8 @@ RESOURCE FLAGS --env preview|production Resource environment (required) --type kv|d1|r2|do|queue|workflow --class-name NAME Exported class for a Durable Object + --location REGION D1/R2 placement: wnam|enam|weur|eeur|apac|oc + --read-replication MODE D1 replicas: auto|disabled --binding NAME Uppercase env binding, for example STATE or FILES SECRET FLAGS @@ -1131,7 +1133,7 @@ export async function workersCommand( return; } if (action === "create") { - assertFlags(flags, ["env", "type", "binding", "class-name", "retention-price-version"]); + assertFlags(flags, ["env", "type", "binding", "class-name", "location", "read-replication", "retention-price-version"]); const id = oneId( resourceArgs, "usage: xapi-to workers resources create --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME", @@ -1151,6 +1153,27 @@ export async function workersCommand( type === "do" ? required(flags["class-name"], "--class-name") : undefined; + const location = flags.location; + if ( + location && + !["wnam", "enam", "weur", "eeur", "apac", "oc"].includes(location) + ) { + err("--location must be wnam, enam, weur, eeur, apac, or oc"); + } + if (location && type !== "d1" && type !== "r2") { + err("--location is only valid with --type d1 or r2"); + } + const readReplication = flags["read-replication"]; + if ( + readReplication && + readReplication !== "auto" && + readReplication !== "disabled" + ) { + err("--read-replication must be auto or disabled"); + } + if (readReplication && type !== "d1") { + err("--read-replication is only valid with --type d1"); + } output( await client.createWorkerResource( options(), @@ -1161,6 +1184,8 @@ export async function workersCommand( retentionPriceVersion: flags["retention-price-version"], bindingName: required(flags.binding, "--binding"), ...(className ? { className } : {}), + ...(location ? { location } : {}), + ...(readReplication ? { readReplication } : {}), }, ), ); diff --git a/src/tests/workers-plan.test.ts b/src/tests/workers-plan.test.ts index d66eaa9..0eb4ddd 100644 --- a/src/tests/workers-plan.test.ts +++ b/src/tests/workers-plan.test.ts @@ -101,6 +101,115 @@ describe("workers plan", () => { writeFileSync(join(root, "xapi.worker.json"), JSON.stringify(config)); expect(await run()).toBe("CREATE"); }); + test("blocks an in-place D1 location change and explains that migration is required", async () => { + const bundle = "export default {fetch(){return new Response('ok')}}"; + const root = project({ linked: true, bundle }); + const config = JSON.parse( + readFileSync(join(root, "xapi.worker.json"), "utf8"), + ); + config.environments.preview.resources = [ + { type: "d1_database", bindingName: "DB", location: "apac" }, + ]; + writeFileSync(join(root, "xapi.worker.json"), JSON.stringify(config)); + const client = { + listWorkers: unexpected("listWorkers"), + getWorker: async () => ({ + id: workerId, + slug: "plan-agent", + environments: [ + { + id: "env-preview", + name: "PREVIEW", + dailyBudgetUsd: 0.25, + }, + ], + artifacts: [], + deployments: [], + }), + listWorkerResources: async () => [ + { + bindingName: "DB", + type: "D1_DATABASE", + status: "ACTIVE", + config: { created_in_region: "EEUR" }, + }, + ], + listWorkerSecrets: async () => [], + }; + const plan = await createWorkerPlan({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client, + }); + expect(plan.canApply).toBe(false); + expect(plan.actions).toContainEqual( + expect.objectContaining({ + operation: "BLOCKED", + kind: "resource", + key: "DB", + message: expect.stringContaining("migrate data"), + desired: expect.objectContaining({ location: "apac" }), + current: expect.objectContaining({ effectiveLocation: "eeur" }), + }), + ); + }); + test("shows requested and effective placement when the resource matches", async () => { + const bundle = "export default {fetch(){return new Response('ok')}}"; + const root = project({ linked: true, bundle }); + const config = JSON.parse( + readFileSync(join(root, "xapi.worker.json"), "utf8"), + ); + config.environments.preview.resources = [ + { type: "d1_database", bindingName: "DB", location: "apac" }, + ]; + writeFileSync(join(root, "xapi.worker.json"), JSON.stringify(config)); + const client = { + listWorkers: unexpected("listWorkers"), + getWorker: async () => ({ + id: workerId, + slug: "plan-agent", + environments: [ + { + id: "env-preview", + name: "PREVIEW", + dailyBudgetUsd: 0.25, + }, + ], + artifacts: [], + deployments: [], + }), + listWorkerResources: async () => [ + { + bindingName: "DB", + type: "D1_DATABASE", + status: "ACTIVE", + config: { + requestedLocation: "apac", + created_in_region: "APAC", + }, + }, + ], + listWorkerSecrets: async () => [], + }; + const plan = await createWorkerPlan({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client, + }); + expect(plan.actions).toContainEqual( + expect.objectContaining({ + operation: "NO_CHANGE", + kind: "resource", + key: "DB", + current: expect.objectContaining({ + requestedLocation: "apac", + effectiveLocation: "apac", + }), + }), + ); + }); test("rejects a missing Wrangler configuration before reading remote state", async () => { const root = project(); rmSync(join(root, "wrangler.jsonc")); diff --git a/src/tests/workers-project.test.ts b/src/tests/workers-project.test.ts index c44a94b..d4180e8 100644 --- a/src/tests/workers-project.test.ts +++ b/src/tests/workers-project.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -48,6 +49,27 @@ function fixture(overrides: Record = {}) { } describe("Worker project configuration", () => { + test("publishes placement fields in the packaged JSON Schema", () => { + const schema = JSON.parse( + readFileSync( + join(import.meta.dir, "../../schemas/worker-project.v1.schema.json"), + "utf8", + ), + ); + expect(schema.$defs.resource.properties.location.enum).toEqual([ + "wnam", + "enam", + "weur", + "eeur", + "apac", + "oc", + ]); + expect(schema.$defs.resource.properties.readReplication.enum).toEqual([ + "auto", + "disabled", + ]); + }); + test("discovers the project config from a nested directory", () => { const root = fixture(); const nested = join(root, "src", "agent"); @@ -112,6 +134,52 @@ describe("Worker project configuration", () => { }); }); + test("accepts D1 and R2 placement and rejects it on unrelated resources", () => { + const validRoot = fixture({ + environments: { + preview: { + dailyBudgetUsd: 0.25, + resources: [ + { + type: "d1_database", + bindingName: "DB", + location: "apac", + readReplication: "auto", + }, + { type: "r2_bucket", bindingName: "FILES", location: "apac" }, + ], + }, + production: { dailyBudgetUsd: 2 }, + }, + }); + expect( + loadWorkerProject(validRoot).config.environments.preview.resources, + ).toEqual([ + { + type: "d1_database", + bindingName: "DB", + location: "apac", + readReplication: "auto", + }, + { type: "r2_bucket", bindingName: "FILES", location: "apac" }, + ]); + + const invalidRoot = fixture({ + environments: { + preview: { + dailyBudgetUsd: 0.25, + resources: [ + { type: "kv_namespace", bindingName: "CACHE", location: "apac" }, + ], + }, + production: { dailyBudgetUsd: 2 }, + }, + }); + expect(() => loadWorkerProject(invalidRoot)).toThrow( + "environments.preview.resources.0.location", + ); + }); + test("rejects credential fields and credential-shaped values", () => { const fieldRoot = fixture({ apiKey: "placeholder" }); expect(() => loadWorkerProject(fieldRoot)).toThrow( diff --git a/src/tests/workers-push.test.ts b/src/tests/workers-push.test.ts index ece248a..b6067a2 100644 --- a/src/tests/workers-push.test.ts +++ b/src/tests/workers-push.test.ts @@ -257,6 +257,50 @@ describe("workers push preview", () => { expect(resources).toEqual(["accepted-v1"]); expect(deployments).toEqual(["accepted-v1"]); }); + test("passes D1 location and replication from the project without inventing defaults", async () => { + const root = fixture({ + resources: [ + { + type: "d1_database", + bindingName: "DB", + location: "apac", + readReplication: "disabled", + }, + ], + }); + const platform = fakePlatform(); + const inputs: Array> = []; + const create = platform.client.createWorkerResource.bind(platform.client); + platform.client.createWorkerResource = async (...args) => { + inputs.push(args[3]); + return create(...args); + }; + await pushWorkerProject({ + cwd: root, + environment: "preview", + clientOptions: { apiHost: "localhost:3003", apiKey: "test-key" }, + client: platform.client, + confirm: async () => true, + runBuild: async () => { + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "dist/worker.mjs"), + "export default {fetch(){return new Response('ok')}};", + ); + }, + fetchPublic: (async () => + Response.json({ ok: true })) as unknown as typeof fetch, + sleep: async () => undefined, + }); + expect(inputs).toEqual([ + { + type: "d1_database", + bindingName: "DB", + location: "apac", + readReplication: "disabled", + }, + ]); + }); test("recovers uncertain writes, hides credentials from build, waits ACTIVE, and is repeatable", async () => { const root = fixture({ resources: [{ type: "kv_namespace", bindingName: "STATE" }], diff --git a/src/workers-client.ts b/src/workers-client.ts index 9674ed4..25904ca 100644 --- a/src/workers-client.ts +++ b/src/workers-client.ts @@ -477,7 +477,14 @@ export function createWorkerResource( options: WorkersClientOptions, id: string, environment: string, - input: { type: string; bindingName: string; className?: string; retentionPriceVersion?: string }, + input: { + type: string; + bindingName: string; + className?: string; + location?: string; + readReplication?: string; + retentionPriceVersion?: string; + }, ) { return request( url( diff --git a/src/workers-plan-output.ts b/src/workers-plan-output.ts index 205c239..e9f5d3a 100644 --- a/src/workers-plan-output.ts +++ b/src/workers-plan-output.ts @@ -86,6 +86,14 @@ function actionDetail(action: WorkerPlanAction): string { const parts = [RESOURCE_LABEL[type] || type]; const className = text(desired.className) || text(current.className); if (className) parts.push(`class ${className}`); + const location = + text(desired.location) || + text(current.requestedLocation) || + text(current.effectiveLocation); + if (location) parts.push(`location ${location.toUpperCase()}`); + const readReplication = + text(desired.readReplication) || text(current.readReplication); + if (readReplication) parts.push(`read replication ${readReplication}`); if (action.operation === "CREATE") parts.push("managed by xAPI"); return parts.join(" ยท "); } diff --git a/src/workers-plan.ts b/src/workers-plan.ts index 117dff9..2ac2e56 100644 --- a/src/workers-plan.ts +++ b/src/workers-plan.ts @@ -111,6 +111,19 @@ function record(value: unknown): UnknownRecord | undefined { : undefined; } +function resourceLocation(value: unknown): string | undefined { + const location = string(value)?.toLowerCase(); + return location && + ["wnam", "enam", "weur", "eeur", "apac", "oc"].includes(location) + ? location + : undefined; +} + +function readReplicationMode(value: unknown): string | undefined { + const mode = string(value)?.toLowerCase(); + return mode === "auto" || mode === "disabled" ? mode : undefined; +} + function list(value: unknown, label: string): UnknownRecord[] { const raw = Array.isArray(value) ? value @@ -216,6 +229,10 @@ function compareResources( type: resource.type, bindingName: resource.bindingName, ...(resource.className ? { className: resource.className } : {}), + ...(resource.location ? { location: resource.location } : {}), + ...(resource.readReplication + ? { readReplication: resource.readReplication } + : {}), }; if (!existing) { add( @@ -231,6 +248,28 @@ function compareResources( remoteByName.delete(resource.bindingName); const existingType = REMOTE_RESOURCE_TYPE[string(existing.type) || ""]; const existingClassName = string(record(existing.config)?.className); + const existingConfig = record(existing.config) || {}; + const requestedLocation = resourceLocation(existingConfig.requestedLocation); + const effectiveLocation = resourceLocation( + existingConfig.created_in_region ?? + existingConfig.running_in_region ?? + existingConfig.location, + ); + const comparableLocation = requestedLocation || effectiveLocation; + const replication = + existingConfig.readReplication ?? existingConfig.read_replication; + const existingReadReplication = readReplicationMode( + replication && typeof replication === "object" + ? string(record(replication)?.mode) + : replication, + ); + const currentPlacement = { + ...(requestedLocation ? { requestedLocation } : {}), + ...(effectiveLocation ? { effectiveLocation } : {}), + ...(existingReadReplication + ? { readReplication: existingReadReplication } + : {}), + }; if ( existingType !== resource.type || (resource.type === "durable_object" && @@ -251,6 +290,28 @@ function compareResources( ); continue; } + if ( + (resource.location && comparableLocation !== resource.location) || + (resource.readReplication && + existingReadReplication !== resource.readReplication) + ) { + blocked = true; + add( + actions, + "BLOCKED", + "resource", + resource.bindingName, + resource.location && comparableLocation !== resource.location + ? "The existing resource is in another location; create a new binding and migrate data before switching" + : "The existing D1 read-replication mode differs; update it explicitly before deployment", + desiredState, + { + type: existingType, + ...currentPlacement, + }, + ); + continue; + } const status = string(existing.status) || "UNKNOWN"; const readyForDeployment = status === "ACTIVE" || @@ -264,7 +325,7 @@ function compareResources( resource.bindingName, `Managed resource is ${status}; wait for or repair it before deployment`, desiredState, - { status }, + { status, ...currentPlacement }, ); continue; } @@ -277,7 +338,7 @@ function compareResources( ? "Durable Object declaration matches and will become ACTIVE with the next deployment" : "Managed resource already matches desired state", desiredState, - { status }, + { status, ...currentPlacement }, ); } for (const [name, existing] of [...remoteByName].sort(([a], [b]) => diff --git a/src/workers-project.ts b/src/workers-project.ts index 0e74cf1..b461340 100644 --- a/src/workers-project.ts +++ b/src/workers-project.ts @@ -41,6 +41,10 @@ export const workerManagedResourceSchema = z .string() .regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/) .optional(), + location: z + .enum(["wnam", "enam", "weur", "eeur", "apac", "oc"]) + .optional(), + readReplication: z.enum(["auto", "disabled"]).optional(), }) .strict() .superRefine((resource, context) => { @@ -58,6 +62,24 @@ export const workerManagedResourceSchema = z message: "is only valid for a durable_object resource", }); } + if ( + resource.location && + resource.type !== "d1_database" && + resource.type !== "r2_bucket" + ) { + context.addIssue({ + code: "custom", + path: ["location"], + message: "is only valid for d1_database and r2_bucket resources", + }); + } + if (resource.readReplication && resource.type !== "d1_database") { + context.addIssue({ + code: "custom", + path: ["readReplication"], + message: "is only valid for a d1_database resource", + }); + } }); const desiredResourcesSchema = z diff --git a/src/workers-push.ts b/src/workers-push.ts index c13418e..5bf7da6 100644 --- a/src/workers-push.ts +++ b/src/workers-push.ts @@ -69,7 +69,14 @@ export interface PushClient extends PlanClient, DeploymentClient { options: WorkersClientOptions, id: string, environment: string, - input: { type: string; bindingName: string; className?: string; retentionPriceVersion?: string }, + input: { + type: string; + bindingName: string; + className?: string; + location?: string; + readReplication?: string; + retentionPriceVersion?: string; + }, ): Promise; listWorkerArtifacts( options: WorkersClientOptions, @@ -440,9 +447,27 @@ function resourceMatches( remote.config && typeof remote.config === "object" ? text((remote.config as UnknownRecord).className) : undefined; + const config = + remote.config && typeof remote.config === "object" + ? (remote.config as UnknownRecord) + : {}; + const remoteLocation = text( + config.requestedLocation ?? + config.created_in_region ?? + config.running_in_region ?? + config.location, + )?.toLowerCase(); + const replication = config.readReplication ?? config.read_replication; + const remoteReadReplication = + replication && typeof replication === "object" + ? text((replication as UnknownRecord).mode) + : text(replication); return ( remoteType === desired.type && - (desired.type !== "durable_object" || remoteClassName === desired.className) + (desired.type !== "durable_object" || remoteClassName === desired.className) && + (!desired.location || remoteLocation === desired.location) && + (!desired.readReplication || + remoteReadReplication === desired.readReplication) ); }