Skip to content

Commit 6c93e17

Browse files
committed
Provision Cloudflare D1 coordinator deterministically
1 parent f2ce42d commit 6c93e17

4 files changed

Lines changed: 110 additions & 1 deletion

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"postinstall": "node scripts/apply-development-patches.mjs",
9393
"generate:cloudflare-wordpress-runtime-corpus": "tsx scripts/generate-cloudflare-wordpress-runtime-corpus.ts",
9494
"provision:cloudflare-wordpress-runtime-corpus": "node scripts/provision-cloudflare-wordpress-runtime-corpus.mjs",
95+
"provision:cloudflare-d1-coordinator": "node scripts/provision-cloudflare-d1-coordinator.mjs",
9596
"prepare": "npm run build",
9697
"package:wordpress-plugin": "tsx scripts/build-wordpress-plugin-zip.ts",
9798
"release:package": "tsx scripts/package-release-artifact.ts",
@@ -104,7 +105,7 @@
104105
"generate:cloudflare-canonical-mdi-seed": "npm run generate:cloudflare-mdi-runtime-bundle && php scripts/build-cloudflare-canonical-mdi-seed.php",
105106
"smoke": "tsx scripts/run-smoke.ts",
106107
"test:redaction": "tsx tests/redaction.test.ts",
107-
"test:cloudflare-runtime": "tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
108+
"test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
108109
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
109110
"test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts",
110111
"test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && tsx tests/agent-task-canonical-evidence.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts",

packages/runtime-cloudflare/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ SSI is extracted only for import requests, so normal browser, mutation, publicat
5252
2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to <directory>` to verify and upload all exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require every upload to succeed before deploying the Worker that imports their manifests.
5353
3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage.
5454
4. Run `npm run cloudflare:dry-run` and `npm run cloudflare:dry-run:d1` to compile the Durable Object and D1 profiles without creating Cloudflare resources. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment.
55+
Use `npm run provision:cloudflare-d1-coordinator -- --output <production-config>` to idempotently create or resolve the named production database and emit a mode-`0600` deployment config from the checked-in template.
5556
5. Run `npm run cloudflare:local-gate` and `npm run cloudflare:local-gate:d1` for the same isolated workerd workflow through both coordinator implementations. Each gate generates and provisions all artifacts, verifies the selected backend through the state envelope, injects stable test-only admin-password, auth-secret, and operator-token values, uploads and activates a real plugin ZIP, establishes an anonymous homepage and canonical-permalink publication, updates a published post through authenticated REST without immediate rendering, restarts with pending publication work, and proves bounded scheduled draining eventually exposes the updated permalink through coordinator-free R2. The cron gate also proves that a scheduled post becomes readable from the publication path without an operator call. Restart verification requires the immutable R2 publication. A final two-host phase proves isolated mutations, coordinator versions, REST collections, credentials, publications, and caches plus fail-closed unknown-host routing. The remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart.
5657

5758
This document describes local candidate verification only. It does not claim remote deployment.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { spawn } from "node:child_process"
2+
import { readFile, writeFile } from "node:fs/promises"
3+
import { resolve } from "node:path"
4+
5+
const args = process.argv.slice(2)
6+
const option = (name, fallback) => {
7+
const index = args.indexOf(name)
8+
return index === -1 ? fallback : args[index + 1]
9+
}
10+
const databaseName = option("--database-name", "wp-codebox-runtime-state")
11+
const binding = option("--binding", "WORDPRESS_STATE_DATABASE")
12+
const templatePath = resolve(option("--template", "packages/runtime-cloudflare/wrangler.d1.jsonc"))
13+
const outputPath = resolve(option("--output"))
14+
const wrangler = option("--wrangler", resolve("node_modules/.bin/wrangler"))
15+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(databaseName) || !/^[A-Z][A-Z0-9_]*$/.test(binding) || !option("--output")) throw new Error("A safe database name, binding, and --output path are required.")
16+
17+
let databases = await listDatabases()
18+
let matches = databases.filter((database) => database.name === databaseName)
19+
if (matches.length > 1) throw new Error(`Multiple D1 databases are named ${databaseName}.`)
20+
if (!matches.length) {
21+
await run(["d1", "create", databaseName])
22+
databases = await listDatabases()
23+
matches = databases.filter((database) => database.name === databaseName)
24+
}
25+
const database = matches[0]
26+
if (!database || typeof database.uuid !== "string" || !/^[a-f0-9-]{36}$/.test(database.uuid)) throw new Error(`D1 database ${databaseName} was not resolved after provisioning.`)
27+
28+
const template = parseJsonc(await readFile(templatePath, "utf8"))
29+
const configured = template.d1_databases?.find((candidate) => candidate.binding === binding && candidate.database_name === databaseName)
30+
if (!configured) throw new Error(`D1 template does not declare ${binding} for ${databaseName}.`)
31+
configured.database_id = database.uuid
32+
await writeFile(outputPath, `${JSON.stringify(template, null, 2)}\n`, { mode: 0o600 })
33+
process.stdout.write(`${JSON.stringify({ schema: "wp-codebox/cloudflare-d1-provision/v1", databaseName, databaseId: database.uuid, binding, config: outputPath })}\n`)
34+
35+
async function listDatabases() {
36+
const output = await run(["d1", "list", "--json"])
37+
const parsed = JSON.parse(output)
38+
if (!Array.isArray(parsed)) throw new Error("Wrangler returned an invalid D1 database list.")
39+
return parsed
40+
}
41+
42+
async function run(command) {
43+
return new Promise((resolveRun, reject) => {
44+
const child = spawn(wrangler, command, { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] })
45+
let stdout = ""
46+
let stderr = ""
47+
child.stdout.setEncoding("utf8")
48+
child.stderr.setEncoding("utf8")
49+
child.stdout.on("data", (chunk) => { stdout += chunk })
50+
child.stderr.on("data", (chunk) => { stderr += chunk })
51+
child.on("error", reject)
52+
child.on("exit", (code) => code === 0 ? resolveRun(stdout) : reject(new Error(`Wrangler ${command.slice(0, 2).join(" ")} failed with status ${code}: ${stderr}`)))
53+
})
54+
}
55+
56+
function parseJsonc(value) {
57+
return JSON.parse(value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,\s*([}\]])/g, "$1"))
58+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict"
2+
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join, resolve } from "node:path"
5+
import { spawn } from "node:child_process"
6+
import test from "node:test"
7+
8+
const script = resolve("scripts/provision-cloudflare-d1-coordinator.mjs")
9+
10+
test("D1 provisioner creates once and emits a deployable config deterministically", async () => {
11+
const directory = await mkdtemp(join(tmpdir(), "wp-codebox-d1-provision-"))
12+
try {
13+
const wrangler = join(directory, "wrangler")
14+
const state = join(directory, "state")
15+
const calls = join(directory, "calls")
16+
const template = join(directory, "wrangler.d1.jsonc")
17+
const output = join(directory, "production.json")
18+
await writeFile(state, "missing")
19+
await writeFile(calls, "")
20+
await writeFile(template, `// template\n{"name":"worker","main":"src/worker-d1.ts","d1_databases":[{"binding":"WORDPRESS_STATE_DATABASE","database_name":"wp-codebox-runtime-state","database_id":"00000000-0000-0000-0000-000000000000"}],}`)
21+
await writeFile(wrangler, `#!${process.execPath}\nimport { appendFileSync, readFileSync, writeFileSync } from "node:fs"; const args=process.argv.slice(2); appendFileSync(${JSON.stringify(calls)}, JSON.stringify(args)+"\\n"); if(args[1]==="list") process.stdout.write(readFileSync(${JSON.stringify(state)},"utf8")==="created"?JSON.stringify([{name:"wp-codebox-runtime-state",uuid:"11111111-2222-3333-4444-555555555555"}]):"[]"); else if(args[1]==="create") writeFileSync(${JSON.stringify(state)},"created");`)
22+
await chmod(wrangler, 0o755)
23+
24+
const first = await run(["--template", template, "--output", output, "--wrangler", wrangler])
25+
const second = await run(["--template", template, "--output", output, "--wrangler", wrangler])
26+
assert.equal(first.databaseId, "11111111-2222-3333-4444-555555555555")
27+
assert.deepEqual(second, first)
28+
const config = JSON.parse(await readFile(output, "utf8"))
29+
assert.equal(config.d1_databases[0].database_id, first.databaseId)
30+
const invocations = (await readFile(calls, "utf8")).trim().split("\n").map(JSON.parse)
31+
assert.equal(invocations.filter((args) => args[1] === "create").length, 1)
32+
} finally {
33+
await rm(directory, { recursive: true, force: true })
34+
}
35+
})
36+
37+
function run(args) {
38+
return new Promise((resolveRun, reject) => {
39+
const child = spawn(process.execPath, [script, ...args], { stdio: ["ignore", "pipe", "pipe"] })
40+
let stdout = ""
41+
let stderr = ""
42+
child.stdout.setEncoding("utf8")
43+
child.stderr.setEncoding("utf8")
44+
child.stdout.on("data", (chunk) => { stdout += chunk })
45+
child.stderr.on("data", (chunk) => { stderr += chunk })
46+
child.on("error", reject)
47+
child.on("exit", (code) => code === 0 ? resolveRun(JSON.parse(stdout)) : reject(new Error(stderr)))
48+
})
49+
}

0 commit comments

Comments
 (0)