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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ xapi-to workers secrets list <worker-id> --env preview --format table
# Durable Agent state, asynchronous tasks, workflows, and persistent schedules.
xapi-to workers resources create <worker-id> \
--env preview --type do --binding AGENT_STATE --class-name AgentState
xapi-to workers resources create <worker-id> \
--env preview --type d1 --binding DB --location apac --read-replication disabled
xapi-to workers resources create <worker-id> \
--env preview --type queue --binding TASK_QUEUE
xapi-to workers resources create <worker-id> \
Expand Down
20 changes: 19 additions & 1 deletion schemas/worker-project.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
}
}
]
}
Expand Down
17 changes: 17 additions & 0 deletions skills/xapi/guides/workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,3 +500,20 @@ npx xapi-to workers delete <worker-id> --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.
29 changes: 27 additions & 2 deletions src/commands/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ COMMANDS
schedules delete <worker-id> <schedule-id> --yes
bindings
resources list <worker-id> --env preview|production
resources create <worker-id> --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME
resources create <worker-id> --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME [--location REGION] [--read-replication MODE]
resources delete <worker-id> <resource-id> --env ENV --yes
secrets list <worker-id> --env preview|production
secrets set <worker-id> <NAME> --env ENV --from-env VARIABLE
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <worker-id> --env ENV --type kv|d1|r2|do|queue|workflow --binding NAME",
Expand All @@ -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(),
Expand All @@ -1161,6 +1184,8 @@ export async function workersCommand(
retentionPriceVersion: flags["retention-price-version"],
bindingName: required(flags.binding, "--binding"),
...(className ? { className } : {}),
...(location ? { location } : {}),
...(readReplication ? { readReplication } : {}),
},
),
);
Expand Down
109 changes: 109 additions & 0 deletions src/tests/workers-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
68 changes: 68 additions & 0 deletions src/tests/workers-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import {
mkdtempSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
Expand Down Expand Up @@ -48,6 +49,27 @@ function fixture(overrides: Record<string, unknown> = {}) {
}

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");
Expand Down Expand Up @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions src/tests/workers-push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
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" }],
Expand Down
9 changes: 8 additions & 1 deletion src/workers-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>(
url(
Expand Down
Loading
Loading