diff --git a/package.json b/package.json index 69e3ec1b7..0010d201a 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "postinstall": "node scripts/apply-development-patches.mjs", "generate:cloudflare-wordpress-runtime-corpus": "tsx scripts/generate-cloudflare-wordpress-runtime-corpus.ts", "provision:cloudflare-wordpress-runtime-corpus": "node scripts/provision-cloudflare-wordpress-runtime-corpus.mjs", + "provision:cloudflare-d1-coordinator": "node scripts/provision-cloudflare-d1-coordinator.mjs", "prepare": "npm run build", "package:wordpress-plugin": "tsx scripts/build-wordpress-plugin-zip.ts", "release:package": "tsx scripts/package-release-artifact.ts", @@ -104,7 +105,7 @@ "generate:cloudflare-canonical-mdi-seed": "npm run generate:cloudflare-mdi-runtime-bundle && php scripts/build-cloudflare-canonical-mdi-seed.php", "smoke": "tsx scripts/run-smoke.ts", "test:redaction": "tsx tests/redaction.test.ts", - "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", + "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", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", "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", diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index ebfd56a63..d294d33cc 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -52,6 +52,7 @@ SSI is extracted only for import requests, so normal browser, mutation, publicat 2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to ` 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. 3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage. 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. + Use `npm run provision:cloudflare-d1-coordinator -- --output ` to idempotently create or resolve the named production database and emit a mode-`0600` deployment config from the checked-in template. 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. This document describes local candidate verification only. It does not claim remote deployment. diff --git a/scripts/provision-cloudflare-d1-coordinator.mjs b/scripts/provision-cloudflare-d1-coordinator.mjs new file mode 100644 index 000000000..82b0351ce --- /dev/null +++ b/scripts/provision-cloudflare-d1-coordinator.mjs @@ -0,0 +1,58 @@ +import { spawn } from "node:child_process" +import { readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" + +const args = process.argv.slice(2) +const option = (name, fallback) => { + const index = args.indexOf(name) + return index === -1 ? fallback : args[index + 1] +} +const databaseName = option("--database-name", "wp-codebox-runtime-state") +const binding = option("--binding", "WORDPRESS_STATE_DATABASE") +const templatePath = resolve(option("--template", "packages/runtime-cloudflare/wrangler.d1.jsonc")) +const outputPath = resolve(option("--output")) +const wrangler = option("--wrangler", resolve("node_modules/.bin/wrangler")) +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.") + +let databases = await listDatabases() +let matches = databases.filter((database) => database.name === databaseName) +if (matches.length > 1) throw new Error(`Multiple D1 databases are named ${databaseName}.`) +if (!matches.length) { + await run(["d1", "create", databaseName]) + databases = await listDatabases() + matches = databases.filter((database) => database.name === databaseName) +} +const database = matches[0] +if (!database || typeof database.uuid !== "string" || !/^[a-f0-9-]{36}$/.test(database.uuid)) throw new Error(`D1 database ${databaseName} was not resolved after provisioning.`) + +const template = parseJsonc(await readFile(templatePath, "utf8")) +const configured = template.d1_databases?.find((candidate) => candidate.binding === binding && candidate.database_name === databaseName) +if (!configured) throw new Error(`D1 template does not declare ${binding} for ${databaseName}.`) +configured.database_id = database.uuid +await writeFile(outputPath, `${JSON.stringify(template, null, 2)}\n`, { mode: 0o600 }) +process.stdout.write(`${JSON.stringify({ schema: "wp-codebox/cloudflare-d1-provision/v1", databaseName, databaseId: database.uuid, binding, config: outputPath })}\n`) + +async function listDatabases() { + const output = await run(["d1", "list", "--json"]) + const parsed = JSON.parse(output) + if (!Array.isArray(parsed)) throw new Error("Wrangler returned an invalid D1 database list.") + return parsed +} + +async function run(command) { + return new Promise((resolveRun, reject) => { + const child = spawn(wrangler, command, { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => { stdout += chunk }) + child.stderr.on("data", (chunk) => { stderr += chunk }) + child.on("error", reject) + child.on("exit", (code) => code === 0 ? resolveRun(stdout) : reject(new Error(`Wrangler ${command.slice(0, 2).join(" ")} failed with status ${code}: ${stderr}`))) + }) +} + +function parseJsonc(value) { + return JSON.parse(value.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,\s*([}\]])/g, "$1")) +} diff --git a/tests/cloudflare-d1-provisioner.test.mjs b/tests/cloudflare-d1-provisioner.test.mjs new file mode 100644 index 000000000..1ca2f42e4 --- /dev/null +++ b/tests/cloudflare-d1-provisioner.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { spawn } from "node:child_process" +import test from "node:test" + +const script = resolve("scripts/provision-cloudflare-d1-coordinator.mjs") + +test("D1 provisioner creates once and emits a deployable config deterministically", async () => { + const directory = await mkdtemp(join(tmpdir(), "wp-codebox-d1-provision-")) + try { + const wrangler = join(directory, "wrangler") + const state = join(directory, "state") + const calls = join(directory, "calls") + const template = join(directory, "wrangler.d1.jsonc") + const output = join(directory, "production.json") + await writeFile(state, "missing") + await writeFile(calls, "") + 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"}],}`) + 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");`) + await chmod(wrangler, 0o755) + + const first = await run(["--template", template, "--output", output, "--wrangler", wrangler]) + const second = await run(["--template", template, "--output", output, "--wrangler", wrangler]) + assert.equal(first.databaseId, "11111111-2222-3333-4444-555555555555") + assert.deepEqual(second, first) + const config = JSON.parse(await readFile(output, "utf8")) + assert.equal(config.d1_databases[0].database_id, first.databaseId) + const invocations = (await readFile(calls, "utf8")).trim().split("\n").map(JSON.parse) + assert.equal(invocations.filter((args) => args[1] === "create").length, 1) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +function run(args) { + return new Promise((resolveRun, reject) => { + const child = spawn(process.execPath, [script, ...args], { stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => { stdout += chunk }) + child.stderr.on("data", (chunk) => { stderr += chunk }) + child.on("error", reject) + child.on("exit", (code) => code === 0 ? resolveRun(JSON.parse(stdout)) : reject(new Error(stderr))) + }) +}