diff --git a/.changeset/c3-frameworks-update-15265.md b/.changeset/c3-frameworks-update-15265.md new file mode 100644 index 00000000000..e72868b97a6 --- /dev/null +++ b/.changeset/c3-frameworks-update-15265.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ------------ | ------ | ------ | +| create-solid | 0.10.0 | 0.11.0 | diff --git a/.changeset/deep-icons-appear.md b/.changeset/deep-icons-appear.md new file mode 100644 index 00000000000..7d17966ae32 --- /dev/null +++ b/.changeset/deep-icons-appear.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/deploy-helpers": patch +--- + +Refactor triggers deploy validation and dry-run + +Move more logic into the deploy-helpers package for easy reuse by `cf`. diff --git a/.changeset/dependabot-update-15260.md b/.changeset/dependabot-update-15260.md new file mode 100644 index 00000000000..8e152785ec3 --- /dev/null +++ b/.changeset/dependabot-update-15260.md @@ -0,0 +1,13 @@ +--- +"miniflare": patch +"wrangler": patch +--- + +Update dependencies of "miniflare", "wrangler" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ------------------------- | ------------- | ------------- | +| @cloudflare/workers-types | ^5.20260815.1 | ^5.20260816.1 | +| workerd | 1.20260815.1 | 1.20260816.1 | diff --git a/.changeset/fix-esm-only-package-resolution.md b/.changeset/fix-esm-only-package-resolution.md new file mode 100644 index 00000000000..43226692a84 --- /dev/null +++ b/.changeset/fix-esm-only-package-resolution.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/workers-utils": patch +--- + +Fix ESM-only packages missing from deploy metadata + +ESM-only package dependencies (such as `@cloudflare/think`) were silently omitted from the package dependency metadata reported during `wrangler deploy` and `wrangler versions upload`. These packages are now correctly detected and included. diff --git a/.changeset/workflows-instances-delete.md b/.changeset/workflows-instances-delete.md new file mode 100644 index 00000000000..90ab8cf6ec8 --- /dev/null +++ b/.changeset/workflows-instances-delete.md @@ -0,0 +1,11 @@ +--- +"@cloudflare/workflows-shared": minor +"wrangler": minor +"miniflare": minor +--- + +Add individual and batch Workflow instance deletion to the runtime and SDK. + +- `WorkflowInstance.delete()` deletes one instance. Self-deletion stops the current execution. +- `env.MY_WORKFLOW.deleteBatch(instanceIds)` deletes up to 100 instances and returns `{ deleted, errors }` per input position. +- `wrangler workflows instances delete [id..]` deletes instances remotely or with `--local`; IDs can also come from a JSON array passed with `--filename`, with a combined limit of 100. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f8759ef7c62..b7a686b6bf3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -38,6 +38,11 @@ updates: schedule: interval: "daily" time: "06:00" + # These packages are published by Cloudflare and can be updated immediately. + cooldown: + exclude: + - "workerd" + - "@cloudflare/workers-types" versioning-strategy: increase labels: - "package:miniflare" diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index 11dac98c9a2..8f13c76eec9 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -11,7 +11,7 @@ "create-qwik": "1.20.0", "create-react-router": "8.3.0", "create-rwsdk": "3.1.3", - "create-solid": "0.10.0", + "create-solid": "0.11.0", "create-vike": "0.0.675", "create-vinext-app": "1.0.0-beta.2", "create-vite": "9.1.2", diff --git a/packages/deploy-helpers/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts index f43d1d15009..127a2608489 100644 --- a/packages/deploy-helpers/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -751,10 +751,11 @@ export default async function deploy( accountId, scriptName, workerTag, - env: props.env, crons: props.triggers, firstDeploy: !workerExists, routes: props.routes, + validated: true, + dryRun: false, }); logger.log("Current Version ID:", versionId); diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index 48a80d91899..94ade226c20 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -163,11 +163,12 @@ export interface TriggerDeployment { export type TriggerProps = { config: Config; - accountId: string; + accountId: string | undefined; scriptName: string; workerTag?: string | null; - env: string | undefined; crons: string[] | undefined; routes: Route[]; firstDeploy: boolean; + dryRun: boolean; + validated: boolean; }; diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index a9df364d071..ae4c9a4e005 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { APIError, formatTime, @@ -16,7 +17,10 @@ import { publishRoutes, renderRoute, } from "./publish-routes"; -import { updateQueueConsumers } from "./queue-consumers"; +import { + ensureQueuesExistByConfig, + updateQueueConsumers, +} from "./queue-consumers"; import { getWorkersDevSubdomain } from "./subdomain"; import { getZoneForRoute } from "./zones"; import type { TriggerDeployment, TriggerProps } from "../shared/types"; @@ -26,8 +30,23 @@ import type { Config, Route } from "@cloudflare/workers-utils"; export async function triggersDeploy( props: TriggerProps ): Promise { - const { config, accountId, scriptName, routes, crons } = props; - validateEventTriggerTargets(config, scriptName); + const { config, scriptName, routes, crons } = props; + + if (props.validated !== true) { + validateEventTriggerTargets(config, scriptName); + } + + if (props.dryRun) { + logger.log(`--dry-run: exiting now.`); + return; + } + + const { accountId } = props; + assert(accountId); + + if (props.validated !== true) { + await ensureQueuesExistByConfig(config, accountId, true, scriptName); + } const routesOnly: Array = []; const customDomainsOnly: Array = []; diff --git a/packages/deploy-helpers/tests/package-dependencies.test.ts b/packages/deploy-helpers/tests/package-dependencies.test.ts index eb14306b802..c3fbe343e74 100644 --- a/packages/deploy-helpers/tests/package-dependencies.test.ts +++ b/packages/deploy-helpers/tests/package-dependencies.test.ts @@ -619,6 +619,55 @@ describe("collectPackageDependencies", () => { }); }); + it("should collect ESM-only package dependencies", async ({ expect }) => { + const nodeModulesPath = path.join(process.cwd(), "node_modules"); + const pkgPath = path.join(nodeModulesPath, "@cloudflare", "think"); + fs.mkdirSync(pkgPath, { recursive: true }); + fs.writeFileSync(path.join(pkgPath, "index.mjs"), "export default {}"); + fs.writeFileSync( + path.join(pkgPath, "package.json"), + JSON.stringify( + { + name: "@cloudflare/think", + version: "0.0.8", + type: "module", + exports: { + ".": { + types: "./dist/think.d.ts", + import: "./dist/think.js", + }, + }, + }, + null, + 2 + ) + ); + + fs.writeFileSync( + "package.json", + JSON.stringify( + { + name: "test-project", + dependencies: { + "@cloudflare/think": "^0.0.8", + }, + }, + null, + 2 + ) + ); + + const result = await collectPackageDependencies(process.cwd()); + + expect(result).toEqual([ + { + name: "@cloudflare/think", + packageJsonVersion: "^0.0.8", + installedVersion: "0.0.8", + }, + ]); + }); + it("should use devDependencies version when duplicate exists in dependencies", async ({ expect, }) => { diff --git a/packages/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts index c65b33a9879..76bc8ebcef9 100644 --- a/packages/deploy-helpers/tests/triggers-email-routing.test.ts +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -84,10 +84,11 @@ describe("triggersDeploy Email Routing integration", () => { accountId: ACCOUNT_ID, scriptName: WORKER_NAME, workerTag: WORKER_TAG, - env: undefined, crons: undefined, routes: [], firstDeploy: false, + dryRun: false, + validated: false, }); expect(planRequests).toBe(1); @@ -101,10 +102,11 @@ describe("triggersDeploy Email Routing integration", () => { config: config(), accountId: ACCOUNT_ID, scriptName: WORKER_NAME, - env: undefined, crons: undefined, routes: [], firstDeploy: false, + dryRun: false, + validated: false, }); expect(planRequests).toBe(1); @@ -122,10 +124,11 @@ describe("triggersDeploy Email Routing integration", () => { accountId: ACCOUNT_ID, scriptName: WORKER_NAME, workerTag: WORKER_TAG, - env: undefined, crons: undefined, routes: [], firstDeploy: false, + dryRun: false, + validated: false, }) ).rejects.toBe(planError); }); @@ -137,13 +140,186 @@ describe("triggersDeploy Email Routing integration", () => { accountId: ACCOUNT_ID, scriptName: WORKER_NAME, workerTag: WORKER_TAG, - env: undefined, crons: ["* * * * *"], routes: [], firstDeploy: false, + dryRun: false, + validated: false, }) ).rejects.toThrow("trigger deployment failed"); expect(planRequests).toBe(1); }); }); + +describe("triggersDeploy preflight", () => { + let fetchResultRequests: string[]; + let fetchPagedListRequests: string[]; + let logs: string[]; + + beforeEach(() => { + fetchResultRequests = []; + fetchPagedListRequests = []; + logs = []; + + initDeployHelpersContext({ + logger: { + debug() {}, + info() {}, + warn() {}, + log(...args: unknown[]) { + logs.push(args.join(" ")); + }, + error() {}, + }, + fetchResult: (async ( + _config: Config, + path: string, + init?: RequestInit + ) => { + fetchResultRequests.push(`${init?.method ?? "GET"} ${path}`); + if (path.endsWith("/subdomain")) { + return { enabled: false, previews_enabled: false }; + } + if (path.endsWith(`/workers/services/${WORKER_NAME}`)) { + return { default_environment: { script: { tag: WORKER_TAG } } }; + } + if ( + init?.method === "POST" && + path.endsWith("/email/routing/rules/plan") + ) { + return { zones: [] }; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + }) as never, + fetchListResult: (() => []) as never, + fetchPagedListResult: (async (_config: Config, path: string) => { + fetchPagedListRequests.push(path); + return []; + }) as never, + fetchKVGetValue: (() => {}) as never, + confirm: (() => {}) as never, + prompt: (() => {}) as never, + select: (() => {}) as never, + }); + }); + + function baseConfig(overrides: Partial = {}): Config { + return { + workers_dev: false, + preview_urls: false, + queues: { producers: [], consumers: [] }, + workflows: [], + ...overrides, + } as unknown as Config; + } + + it("dry-run validates event trigger targets without API calls", async ({ + expect, + }) => { + await triggersDeploy({ + config: baseConfig({ + workflows: [{ name: "workflow", class_name: "Workflow" }], + triggers: { + events: [ + { + type: "send_email", + targets: [{ workflow_name: "workflow" }], + }, + ], + }, + } as unknown as Partial), + scriptName: WORKER_NAME, + crons: undefined, + routes: [], + firstDeploy: false, + dryRun: true, + accountId: undefined, + validated: false, + }); + + expect(logs).toContain("--dry-run: exiting now."); + expect(fetchResultRequests).toEqual([]); + expect(fetchPagedListRequests).toEqual([]); + }); + + it("dry-run fails invalid event trigger targets before API calls", async ({ + expect, + }) => { + await expect( + triggersDeploy({ + config: baseConfig({ + triggers: { + events: [ + { + type: "send_email", + targets: [{ workflow_name: "missing-workflow" }], + }, + ], + }, + } as unknown as Partial), + scriptName: WORKER_NAME, + crons: undefined, + routes: [], + firstDeploy: false, + dryRun: true, + accountId: undefined, + validated: false, + }) + ).rejects.toThrow( + 'Event trigger "send_email" targets Workflow "missing-workflow"' + ); + + expect(fetchResultRequests).toEqual([]); + expect(fetchPagedListRequests).toEqual([]); + }); + + it("runs queue preflight before standalone trigger deployment", async ({ + expect, + }) => { + await expect( + triggersDeploy({ + config: baseConfig({ + queues: { + producers: [{ binding: "QUEUE", queue: "missing-queue" }], + consumers: [], + }, + } as unknown as Partial), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + crons: undefined, + routes: [], + firstDeploy: false, + validated: false, + dryRun: false, + }) + ).rejects.toThrow('Queue "missing-queue" does not exist'); + + expect(fetchPagedListRequests).toEqual([`/accounts/${ACCOUNT_ID}/queues`]); + expect(fetchResultRequests).toEqual([]); + }); + + it("skips queue preflight when already validated", async ({ expect }) => { + await triggersDeploy({ + config: baseConfig({ + queues: { + producers: [{ binding: "QUEUE", queue: "missing-queue" }], + consumers: [], + }, + } as unknown as Partial), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + crons: undefined, + routes: [], + firstDeploy: false, + validated: true, + dryRun: false, + }); + + expect(fetchPagedListRequests).toEqual([]); + expect(fetchResultRequests).toEqual([ + `GET /accounts/${ACCOUNT_ID}/workers/scripts/${WORKER_NAME}/subdomain`, + `POST /accounts/${ACCOUNT_ID}/workers/scripts/${WORKER_NAME}/subdomain`, + ]); + }); +}); diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index d0968087c33..cde10c6e5d5 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -985,6 +985,107 @@ const config = { tags: ["Workflows"], }, }, + "/workflows/{workflow_name}/instances/batch/delete": { + post: { + description: "Deletes multiple workflow instances.", + operationId: "workflows-batch-delete-instances", + parameters: [ + { + in: "path", + name: "workflow_name", + required: true, + schema: { + $ref: "#/components/schemas/workflows_workflow-name", + }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + instances: { + type: "array", + minItems: 1, + maxItems: 100, + items: { + type: "string", + minLength: 1, + maxLength: 271, + pattern: "^[a-zA-Z0-9, */#_-]+$", + }, + }, + }, + required: ["instances"], + }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + type: "object", + properties: { + result: { + type: "object", + properties: { + deleted: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + }, + required: ["id"], + }, + }, + errors: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + code: { type: "number" }, + message: { type: "string" }, + }, + required: ["id", "code", "message"], + }, + }, + }, + required: ["deleted", "errors"], + }, + }, + }, + ], + }, + }, + }, + description: "Batch delete Workflow Instances response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Batch delete Workflow Instances response failure.", + }, + }, + summary: "Batch Delete Workflow Instances", + tags: ["Workflows"], + }, + }, "/workflows/{workflow_name}/instances/{instance_id}": { get: { description: "Returns the status details of a workflow instance.", diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 4b5f71494a3..4a1ea010c75 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -7,6 +7,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { ReadableStream } from "node:stream/web"; +import { setTimeout as wait } from "node:timers/promises"; import util from "node:util"; import zlib from "node:zlib"; import { checkMacOSVersion } from "@cloudflare/cli-shared-helpers"; @@ -736,6 +737,20 @@ export function _initialiseInstanceRegistry() { return (maybeInstanceRegistry = new Map()); } +type PendingWorkflowStorageDelete = { + promise: Promise; + failed: boolean; + deleted: boolean; +}; + +const WORKFLOW_STORAGE_EXTENSIONS = [".sqlite", ".sqlite-shm", ".sqlite-wal"]; +const WORKFLOW_STORAGE_DELETE_RETRY_INTERVAL_MS = 50; +const WORKFLOW_STORAGE_DELETE_TIMEOUT_MS = 2_000; +const WORKFLOW_STORAGE_DELETE_ATTEMPTS = + WORKFLOW_STORAGE_DELETE_TIMEOUT_MS / + WORKFLOW_STORAGE_DELETE_RETRY_INTERVAL_MS + + 1; + export class Miniflare { #previousSharedOpts?: ParsedInstanceOptions; #previousWorkerOpts?: ParsedWorkerOptions[]; @@ -754,6 +769,10 @@ export class Miniflare { string, { browserProcess: Process; wsEndpoint: string } > = new Map(); + #pendingWorkflowStorageDeletes = new Map< + string, + PendingWorkflowStorageDelete + >(); readonly #runtime?: Runtime; readonly #removeExitHook?: () => void; @@ -1210,6 +1229,135 @@ export class Miniflare { } } + /** Removes an instance's SQLite files, retrying transient Windows locks. */ + async #deleteWorkflowStorageFiles( + instancePath: string, + pendingDelete: PendingWorkflowStorageDelete + ): Promise { + let firstError: unknown; + let failed = false; + for (const ext of WORKFLOW_STORAGE_EXTENSIONS) { + const filePath = `${instancePath}${ext}`; + for ( + let attempt = 0; + attempt < WORKFLOW_STORAGE_DELETE_ATTEMPTS; + attempt++ + ) { + try { + await fs.promises.unlink(filePath); + if (ext === ".sqlite") { + pendingDelete.deleted = true; + } + break; + } catch (error) { + if (isFileNotFoundError(error)) { + break; + } + const code = + typeof error === "object" && error !== null && "code" in error + ? error.code + : undefined; + if ( + (code !== "EBUSY" && code !== "EPERM") || + attempt === WORKFLOW_STORAGE_DELETE_ATTEMPTS - 1 + ) { + if (!failed) { + firstError = error; + failed = true; + } + break; + } + await wait(WORKFLOW_STORAGE_DELETE_RETRY_INTERVAL_MS); + } + } + } + if (failed) { + throw firstError; + } + } + + /** Runs a storage deletion after any earlier deletion for the same instance. */ + async #runWorkflowStorageDelete( + instancePath: string, + defer: boolean, + pendingDelete: PendingWorkflowStorageDelete, + previousDelete?: PendingWorkflowStorageDelete + ): Promise { + await previousDelete?.promise; + pendingDelete.deleted = previousDelete?.deleted ?? false; + if (defer) { + await wait(100); + } + try { + await this.#deleteWorkflowStorageFiles(instancePath, pendingDelete); + } catch (error) { + pendingDelete.failed = true; + this.#log.error( + error instanceof Error ? error : new Error(String(error)) + ); + } + if ( + !pendingDelete.failed && + this.#pendingWorkflowStorageDeletes.get(instancePath) === pendingDelete + ) { + this.#pendingWorkflowStorageDeletes.delete(instancePath); + } + } + + /** Serializes storage deletions for one instance path. */ + #queueWorkflowStorageDelete( + instancePath: string, + defer: boolean + ): PendingWorkflowStorageDelete { + const previousDelete = + this.#pendingWorkflowStorageDeletes.get(instancePath); + const pendingDelete: PendingWorkflowStorageDelete = { + deleted: false, + failed: false, + promise: Promise.resolve(), + }; + this.#pendingWorkflowStorageDeletes.set(instancePath, pendingDelete); + pendingDelete.promise = this.#runWorkflowStorageDelete( + instancePath, + defer, + pendingDelete, + previousDelete + ); + return pendingDelete; + } + + /** Waits for a queued storage deletion, retrying one failed deletion. */ + async #waitForWorkflowStorageDelete( + instancePath: string, + retried = false + ): Promise { + const pendingDelete = this.#pendingWorkflowStorageDeletes.get(instancePath); + if (pendingDelete === undefined) { + return new Response(null, { status: 204 }); + } + await pendingDelete.promise; + + const latestDelete = this.#pendingWorkflowStorageDeletes.get(instancePath); + if (latestDelete === undefined) { + return new Response(null, { status: 204 }); + } + if (latestDelete !== pendingDelete) { + return this.#waitForWorkflowStorageDelete(instancePath, retried); + } + if (!pendingDelete.failed) { + this.#pendingWorkflowStorageDeletes.delete(instancePath); + return new Response(null, { status: 204 }); + } + if (retried || this.#disposeController.signal.aborted) { + return new Response("Failed to delete workflow instance", { + status: 500, + }); + } + + this.#queueWorkflowStorageDelete(instancePath, false); + return this.#waitForWorkflowStorageDelete(instancePath, true); + } + /** * Deletes a Workflow Engine DO instance by removing its .sqlite file * (and any associated -shm/-wal files) from the persistence directory. @@ -1233,6 +1381,9 @@ export class Miniflare { : decodeURIComponent(pathAfterPrefix.slice(slashIndex + 1)); assert(workflowName, "Workflow name is required"); + if (url.searchParams.has("waitForPendingDelete") && !hexId) { + return new Response("Instance ID is required", { status: 400 }); + } const coreSharedOpts = this.#sharedOpts; const workflowsPersistPath = getPersistPath( @@ -1251,28 +1402,30 @@ export class Miniflare { return new Response("Invalid workflow name", { status: 400 }); } - const extensions = [".sqlite", ".sqlite-shm", ".sqlite-wal"]; - if (hexId) { - // Delete a single instance - let deleted = false; - for (const ext of extensions) { - const filePath = path.join(namespacePath, `${hexId}${ext}`); - if (!filePath.startsWith(namespacePath + path.sep)) { - return new Response("Invalid instance ID", { status: 400 }); - } - try { - await fs.promises.unlink(filePath); - if (ext === ".sqlite") { - deleted = true; - } - } catch (e) { - if (!isFileNotFoundError(e)) { - throw e; - } - } + const instancePath = path.join(namespacePath, hexId); + if (!instancePath.startsWith(namespacePath + path.sep)) { + return new Response("Invalid instance ID", { status: 400 }); + } + + if (url.searchParams.has("waitForPendingDelete")) { + return this.#waitForWorkflowStorageDelete(instancePath); + } + + const pendingDelete = this.#queueWorkflowStorageDelete( + instancePath, + url.searchParams.has("defer") + ); + if (url.searchParams.has("defer")) { + return new Response("Accepted", { status: 202 }); + } + await pendingDelete.promise; + if (pendingDelete.failed) { + return new Response("Failed to delete workflow instance", { + status: 500, + }); } - if (!deleted) { + if (!pendingDelete.deleted) { return new Response("Not Found", { status: 404 }); } } else { @@ -1281,7 +1434,9 @@ export class Miniflare { const dirEntries = await fs.promises.readdir(namespacePath); await Promise.all( dirEntries - .filter((name) => extensions.some((ext) => name.endsWith(ext))) + .filter((name) => + WORKFLOW_STORAGE_EXTENSIONS.some((ext) => name.endsWith(ext)) + ) .map((name) => fs.promises.unlink(path.join(namespacePath, name)).catch(() => {}) ) @@ -1462,7 +1617,10 @@ export class Miniflare { } else if (url.pathname.startsWith("/core/do-storage/")) { response = await this.#handleLoopbackDOStorageRequest(url); } else if (url.pathname.startsWith("/core/workflow-storage/")) { - if (request.method === "DELETE") { + if ( + request.method === "DELETE" || + url.searchParams.has("waitForPendingDelete") + ) { response = await this.#handleLoopbackWorkflowStorageDeleteRequest(url); } else { @@ -3206,6 +3364,18 @@ export class Miniflare { } const runtimeCleanupOutcome = await runtimeDisposeOutcome; + try { + await Promise.all( + [...this.#pendingWorkflowStorageDeletes.values()].map( + ({ promise }) => promise + ) + ); + } catch (error) { + if (!independentCleanupFailed) { + independentCleanupFailed = true; + independentCleanupError = error; + } + } // Close the undici Pool used for dispatching fetch requests to the // runtime. This must happen after the runtime is disposed, so that // in-flight connections are broken and close immediately. Without this, diff --git a/packages/miniflare/src/plugins/workflows/index.ts b/packages/miniflare/src/plugins/workflows/index.ts index 85838c1a3db..a9e25b9b392 100644 --- a/packages/miniflare/src/plugins/workflows/index.ts +++ b/packages/miniflare/src/plugins/workflows/index.ts @@ -13,6 +13,7 @@ import { getUserBindingServiceName, ProxyNodeBinding, SERVICE_DEV_REGISTRY_PROXY, + WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service } from "../../runtime"; import type { Plugin } from "../shared"; @@ -189,6 +190,8 @@ export const WORKFLOWS_PLUGIN: Plugin = { name: "WORKFLOW_NAME", json: JSON.stringify(binding.name), }, + // Workflow deletion needs the Node.js host to remove its SQLite files. + WORKER_BINDING_SERVICE_LOOPBACK, ...(stepLimit !== undefined ? [ { diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index e9b4b127867..3258ee6f6c5 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -18,6 +18,7 @@ import { zWorkersKvNamespaceListANamespaceSKeysData, zWorkersKvNamespaceListNamespacesData, zObservabilityQueryData, + zWorkflowsBatchDeleteInstancesData, zWorkflowsChangeInstanceStatusData, zWorkflowsListInstancesData, } from "./generated/zod.gen"; @@ -45,6 +46,7 @@ import { createWorkflowInstance, deleteWorkflow, deleteWorkflowInstance, + deleteWorkflowInstances, getWorkflowDetails, getWorkflowInstanceDetails, listWorkflowInstances, @@ -315,6 +317,17 @@ app.post("/api/workflows/:workflow_name/instances", (c) => createWorkflowInstance(c, c.req.param("workflow_name")) ); +app.post( + "/api/workflows/:workflow_name/instances/batch/delete", + validateRequestBody(zWorkflowsBatchDeleteInstancesData.shape.body), + (c) => + deleteWorkflowInstances( + c, + c.req.param("workflow_name"), + c.req.valid("json") + ) +); + app.get("/api/workflows/:workflow_name/instances/:instance_id", (c) => getWorkflowInstanceDetails( c, diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index 4533d808860..92472cf8f41 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -174,6 +174,11 @@ export type { WorkersNamespaceWritable, WorkersObject, WorkersSchemasId, + WorkflowsBatchDeleteInstancesData, + WorkflowsBatchDeleteInstancesError, + WorkflowsBatchDeleteInstancesErrors, + WorkflowsBatchDeleteInstancesResponse, + WorkflowsBatchDeleteInstancesResponses, WorkflowsChangeInstanceStatusData, WorkflowsChangeInstanceStatusError, WorkflowsChangeInstanceStatusErrors, diff --git a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts index 3bf15682d9f..5d017920996 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -1666,6 +1666,48 @@ export type WorkflowsCreateInstanceResponses = { export type WorkflowsCreateInstanceResponse = WorkflowsCreateInstanceResponses[keyof WorkflowsCreateInstanceResponses]; +export type WorkflowsBatchDeleteInstancesData = { + body: { + instances: Array; + }; + path: { + workflow_name: WorkflowsWorkflowName; + }; + query?: never; + url: "/workflows/{workflow_name}/instances/batch/delete"; +}; + +export type WorkflowsBatchDeleteInstancesErrors = { + /** + * Batch delete Workflow Instances response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type WorkflowsBatchDeleteInstancesError = + WorkflowsBatchDeleteInstancesErrors[keyof WorkflowsBatchDeleteInstancesErrors]; + +export type WorkflowsBatchDeleteInstancesResponses = { + /** + * Batch delete Workflow Instances response. + */ + 200: WorkersApiResponseCommon & { + result?: { + deleted: Array<{ + id: string; + }>; + errors: Array<{ + id: string; + code: number; + message: string; + }>; + }; + }; +}; + +export type WorkflowsBatchDeleteInstancesResponse = + WorkflowsBatchDeleteInstancesResponses[keyof WorkflowsBatchDeleteInstancesResponses]; + export type WorkflowsDeleteInstanceData = { body?: never; path: { diff --git a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts index 998064d0c3d..9481bbb513a 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -1056,6 +1056,50 @@ export const zWorkflowsCreateInstanceResponse = zWorkersApiResponseCommon.and( }) ); +export const zWorkflowsBatchDeleteInstancesData = z.object({ + body: z.object({ + instances: z + .array( + z + .string() + .min(1) + .max(271) + .regex(/^[a-zA-Z0-9, *\/#_-]+$/) + ) + .min(1) + .max(100), + }), + path: z.object({ + workflow_name: zWorkflowsWorkflowName, + }), + query: z.never().optional(), +}); + +/** + * Batch delete Workflow Instances response. + */ +export const zWorkflowsBatchDeleteInstancesResponse = + zWorkersApiResponseCommon.and( + z.object({ + result: z + .object({ + deleted: z.array( + z.object({ + id: z.string(), + }) + ), + errors: z.array( + z.object({ + id: z.string(), + code: z.number(), + message: z.string(), + }) + ), + }) + .optional(), + }) + ); + export const zWorkflowsDeleteInstanceData = z.object({ body: z.never().optional(), path: z.object({ diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index bdff2fa1e49..54f2b65389f 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1632,6 +1632,115 @@ "tags": ["Workflows"] } }, + "/workflows/{workflow_name}/instances/batch/delete": { + "post": { + "description": "Deletes multiple workflow instances.", + "operationId": "workflows-batch-delete-instances", + "parameters": [ + { + "in": "path", + "name": "workflow_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/workflows_workflow-name" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "instances": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 271, + "pattern": "^[a-zA-Z0-9, */#_-]+$" + } + } + }, + "required": ["instances"] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "deleted": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"] + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "number" + }, + "message": { + "type": "string" + } + }, + "required": ["id", "code", "message"] + } + } + }, + "required": ["deleted", "errors"] + } + } + } + ] + } + } + }, + "description": "Batch delete Workflow Instances response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Batch delete Workflow Instances response failure." + } + }, + "summary": "Batch Delete Workflow Instances", + "tags": ["Workflows"] + } + }, "/workflows/{workflow_name}/instances/{instance_id}": { "get": { "description": "Returns the status details of a workflow instance.", diff --git a/packages/miniflare/src/workers/local-explorer/resources/workflows.ts b/packages/miniflare/src/workers/local-explorer/resources/workflows.ts index f626a48d5a8..7c1008850b2 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/workflows.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/workflows.ts @@ -7,6 +7,7 @@ import { errorResponse, wrapResponse } from "../common"; import type { AppContext } from "../common"; import type { Env } from "../explorer.worker"; import type { + WorkflowsBatchDeleteInstancesData, WorkflowsChangeInstanceStatusData, WorkflowsWorkflow, } from "../generated"; @@ -945,6 +946,51 @@ export async function changeWorkflowInstanceStatus( } } +/** + * Delete multiple workflow instances through the local Workflow binding. + */ +export async function deleteWorkflowInstances( + c: AppContext, + workflowName: string, + body: WorkflowsBatchDeleteInstancesData["body"] +): Promise { + const workflow = getWorkflowBinding(c.env, workflowName); + + if (!workflow) { + const ownerMiniflare = await findWorkflowOwner(c, workflowName); + if (ownerMiniflare) { + const response = await fetchFromPeer( + ownerMiniflare, + `/workflows/${encodeURIComponent(workflowName)}/instances/batch/delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); + if (response) { + return response; + } + } + + return errorResponse( + 404, + WORKFLOW_ERROR_NOT_FOUND, + `Workflow '${workflowName}' not found.` + ); + } + + try { + const result = await workflow.deleteBatch(body.instances); + statusCountsCache.delete(workflowName); + return c.json(wrapResponse(result)); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to delete instances"; + return errorResponse(500, 10001, message); + } +} + /** * Delete a workflow instance by removing its .sqlite persistence files. * diff --git a/packages/miniflare/src/workers/local-explorer/route-names.ts b/packages/miniflare/src/workers/local-explorer/route-names.ts index 1aa3c0464ff..5e1e2be3b32 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -16,6 +16,10 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/r2\/buckets\/[^/]+\/objects$/, "r2.objects"], [/^\/r2\/buckets\/[^/]+$/, "r2.bucket"], [/^\/r2\/buckets$/, "r2.buckets"], + [ + /^\/workflows\/[^/]+\/instances\/batch\/delete$/, + "workflows.instances.batch_delete", + ], [ /^\/workflows\/[^/]+\/instances\/[^/]+\/events\/[^/]+$/, "workflows.instance.event", diff --git a/packages/miniflare/src/workers/workflows/wrapped-binding.worker.ts b/packages/miniflare/src/workers/workflows/wrapped-binding.worker.ts index 49b38b13a69..873bb7988ba 100644 --- a/packages/miniflare/src/workers/workflows/wrapped-binding.worker.ts +++ b/packages/miniflare/src/workers/workflows/wrapped-binding.worker.ts @@ -35,7 +35,7 @@ class WorkflowImpl implements Workflow { } async deleteBatch(instanceIds: string[]): Promise { - return this.binding.deleteBatch(instanceIds); + return this.binding.deleteBatch({ instances: instanceIds }); } async unsafeGetBindingName(): Promise { @@ -128,17 +128,16 @@ class InstanceImpl implements WorkflowInstance { await instance.restart(options); } + public async delete(): Promise { + await this.binding.deleteInstance(this.id); + } + public async status(): Promise { using instance = await this.getInstance(); using res = (await instance.status()) as InstanceStatus & Disposable; return structuredClone(res); } - public async delete(): Promise { - using instance = await this.getInstance(); - await instance.delete(); - } - public async sendEvent(args: { payload: unknown; type: string; diff --git a/packages/miniflare/test/plugins/workflows/index.spec.ts b/packages/miniflare/test/plugins/workflows/index.spec.ts index 633ad752e51..68cc9f8d3e9 100644 --- a/packages/miniflare/test/plugins/workflows/index.spec.ts +++ b/packages/miniflare/test/plugins/workflows/index.spec.ts @@ -2,7 +2,8 @@ import * as fs from "node:fs/promises"; import path from "node:path"; import { scheduler } from "node:timers/promises"; import { Miniflare, WORKFLOWS_PLUGIN_NAME } from "miniflare"; -import { describe, test } from "vitest"; +import { describe, test, vi } from "vitest"; +import { CorePaths } from "../../../src/workers/core/constants"; import { singleModuleManifest, useDispose, useTmp } from "../../test-shared"; import type { MiniflareOptions } from "miniflare"; @@ -134,6 +135,13 @@ const LIFECYCLE_WORKFLOW_SCRIPT = () => ` import { WorkflowEntrypoint } from "cloudflare:workers"; export class LifecycleWorkflow extends WorkflowEntrypoint { async run(event, step) { + if (event.payload?.selfDelete) { + await step.waitForEvent("self-delete", { type: "self-delete" }); + const instance = await this.env.LIFECYCLE_WORKFLOW.get(event.instanceId); + await instance.delete(); + throw new Error("continued after self-delete"); + } + const first = await step.do("first step", async () => "step-1-done"); await step.do("long step", async () => { @@ -151,8 +159,11 @@ export default { const url = new URL(request.url); const id = url.searchParams.get("id") || "lifecycle-test"; - if (url.pathname === "/create") { - const instance = await env.LIFECYCLE_WORKFLOW.create({ id }); + if (url.pathname === "/create" || url.pathname === "/selfDelete") { + const instance = await env.LIFECYCLE_WORKFLOW.create({ + id, + params: { selfDelete: url.pathname === "/selfDelete" }, + }); const status = await instance.status(); return Response.json({ id: instance.id, status }); } @@ -186,9 +197,24 @@ export default { return Response.json(await instance.status()); } + if (url.pathname === "/delete") { + const instance = await env.LIFECYCLE_WORKFLOW.get(id); + await instance.delete(); + return Response.json({ ok: true }); + } + + if (url.pathname === "/deleteBatch") { + return Response.json( + await env.LIFECYCLE_WORKFLOW.deleteBatch(url.searchParams.getAll("id")) + ); + } + if (url.pathname === "/sendEvent") { const instance = await env.LIFECYCLE_WORKFLOW.get(id); - await instance.sendEvent({ type: "continue", payload: { sent: true } }); + await instance.sendEvent({ + type: url.searchParams.get("type") || "continue", + payload: { sent: true }, + }); return Response.json({ ok: true }); } @@ -220,6 +246,31 @@ function lifecycleMiniflareOpts(tmp: string): MiniflareOptions { }; } +async function getPersistedInstanceFiles(tmp: string): Promise { + try { + const files = await fs.readdir( + path.join( + tmp, + WORKFLOWS_PLUGIN_NAME, + "miniflare-workflows-LIFECYCLE_WORKFLOW" + ) + ); + return files.filter( + (file) => file.endsWith(".sqlite") && file !== "metadata.sqlite" + ); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return []; + } + throw error; + } +} + async function waitForStatus( mf: Miniflare, id: string, @@ -330,6 +381,208 @@ describe("workflow instance lifecycle methods", () => { await waitForStatus(mf, "terminate-test", "terminated"); }); + test("delete a workflow", async ({ expect }) => { + const tmp = await useTmp(); + const mf = new Miniflare(lifecycleMiniflareOpts(tmp)); + useDispose(mf); + + const createResponse = await mf.dispatchFetch( + "http://localhost/create?id=delete-one" + ); + await createResponse.text(); + + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(1); + const deleteResponse = await mf.dispatchFetch( + "http://localhost/delete?id=delete-one" + ); + expect(await deleteResponse.json()).toEqual({ ok: true }); + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(0); + + const statusResponse = await mf.dispatchFetch( + "http://localhost/status?id=delete-one" + ); + expect(statusResponse.status).toBe(500); + expect(await statusResponse.text()).toContain("instance.not_found"); + + const cronId = "*/30 * * * *-1786001400000"; + const cronDeleteResponse = await mf.dispatchFetch( + `http://localhost/delete?id=${encodeURIComponent(cronId)}` + ); + expect(cronDeleteResponse.status).toBe(500); + expect(await cronDeleteResponse.text()).toContain("instance.not_found"); + }); + + test("reports overlapping storage deletion as successful", async ({ + expect, + }) => { + const tmp = await useTmp(); + const mf = new Miniflare({ + ...lifecycleMiniflareOpts(tmp), + unsafeLocalExplorer: true, + }); + useDispose(mf); + + const createResponse = await mf.dispatchFetch( + "http://localhost/create?id=overlapping-delete" + ); + await createResponse.text(); + + const bindingDelete = mf + .dispatchFetch("http://localhost/delete?id=overlapping-delete") + .then((response) => response.json()); + await scheduler.wait(25); + const explorerDelete = await mf.dispatchFetch( + `http://localhost${CorePaths.EXPLORER}/api/workflows/LIFECYCLE_WORKFLOW/instances/overlapping-delete`, + { method: "DELETE" } + ); + expect(explorerDelete.status).toBe(200); + await explorerDelete.text(); + expect(await bindingDelete).toEqual({ ok: true }); + }); + + test("continues deleting storage files after an unlink error", async ({ + expect, + }) => { + const tmp = await useTmp(); + const mf = new Miniflare({ + ...lifecycleMiniflareOpts(tmp), + unsafeLocalExplorer: true, + }); + useDispose(mf); + + const hexId = "a".repeat(64); + const instancePath = path.join( + tmp, + WORKFLOWS_PLUGIN_NAME, + "miniflare-workflows-LIFECYCLE_WORKFLOW", + hexId + ); + await fs.mkdir(path.dirname(instancePath), { recursive: true }); + await fs.writeFile(`${instancePath}.sqlite`, ""); + await fs.mkdir(`${instancePath}.sqlite-shm`); + await fs.writeFile(`${instancePath}.sqlite-wal`, ""); + + const response = await mf.dispatchFetch( + `http://localhost${CorePaths.EXPLORER}/api/workflows/LIFECYCLE_WORKFLOW/instances/${hexId}`, + { method: "DELETE" } + ); + const body = await response.text(); + expect(response.status, body).toBe(500); + await expect(fs.stat(`${instancePath}.sqlite`)).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(`${instancePath}.sqlite-wal`)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect((await fs.stat(`${instancePath}.sqlite-shm`)).isDirectory()).toBe( + true + ); + }); + + test("recreates a workflow immediately after deletion", async ({ + expect, + }) => { + const tmp = await useTmp(); + const mf = new Miniflare(lifecycleMiniflareOpts(tmp)); + useDispose(mf); + + let response = await mf.dispatchFetch( + "http://localhost/create?id=delete-recreate" + ); + await response.text(); + await waitForStatus(mf, "delete-recreate", "complete"); + response = await mf.dispatchFetch( + "http://localhost/delete?id=delete-recreate" + ); + await response.text(); + response = await mf.dispatchFetch( + "http://localhost/create?id=delete-recreate" + ); + await response.text(); + + await waitForStatus(mf, "delete-recreate", "complete"); + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(1); + }); + + test("delete a workflow from its own execution", async ({ expect }) => { + const tmp = await useTmp(); + const mf = new Miniflare(lifecycleMiniflareOpts(tmp)); + useDispose(mf); + + const createResponse = await mf.dispatchFetch( + "http://localhost/selfDelete?id=self-delete" + ); + await createResponse.text(); + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(1); + + const eventResponse = await mf.dispatchFetch( + "http://localhost/sendEvent?id=self-delete&type=self-delete" + ); + await eventResponse.text(); + await vi.waitUntil( + async () => (await getPersistedInstanceFiles(tmp)).length === 0, + { timeout: 5000 } + ); + + const statusResponse = await mf.dispatchFetch( + "http://localhost/status?id=self-delete" + ); + expect(statusResponse.status).toBe(500); + expect(await statusResponse.text()).toContain("instance.not_found"); + }); + + test("delete multiple workflows", async ({ expect }) => { + const tmp = await useTmp(); + const mf = new Miniflare({ + ...lifecycleMiniflareOpts(tmp), + unsafeLocalExplorer: true, + }); + useDispose(mf); + const cronId = "*/30 * * * *-1786001400000"; + + for (const id of ["delete-1", "delete-2"]) { + const response = await mf.dispatchFetch( + `http://localhost/create?id=${id}` + ); + await response.text(); + } + + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(2); + const response = await mf.dispatchFetch( + `http://localhost/deleteBatch?id=delete-1&id=${encodeURIComponent(cronId)}&id=delete-2&id=delete-1` + ); + expect(await response.json()).toEqual({ + deleted: [{ id: "delete-1" }, { id: "delete-2" }, { id: "delete-1" }], + errors: [ + { + id: cronId, + code: 10400, + message: "workflows.api.error.instance.not_found", + }, + ], + }); + + const explorerResponse = await mf.dispatchFetch( + `http://localhost${CorePaths.EXPLORER}/api/workflows/LIFECYCLE_WORKFLOW/instances/batch/delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: [cronId] }), + } + ); + expect(explorerResponse.status).toBe(200); + await explorerResponse.text(); + expect(await getPersistedInstanceFiles(tmp)).toHaveLength(0); + + for (const id of ["delete-1", "delete-2"]) { + const statusResponse = await mf.dispatchFetch( + `http://localhost/status?id=${id}` + ); + expect(statusResponse.status).toBe(500); + await statusResponse.text(); + } + }); + test("restart a running workflow", async ({ expect }) => { const tmp = await useTmp(); const mf = new Miniflare(lifecycleMiniflareOpts(tmp)); diff --git a/packages/workers-utils/src/default-compat-date.ts b/packages/workers-utils/src/default-compat-date.ts index 9110557f6d5..d8b2341863c 100644 --- a/packages/workers-utils/src/default-compat-date.ts +++ b/packages/workers-utils/src/default-compat-date.ts @@ -19,4 +19,4 @@ import type { CompatDate } from "./compatibility-date"; * @see https://github.com/cloudflare/workerd/blob/main/src/workerd/io/BUILD.bazel * @see https://github.com/cloudflare/workerd/blob/main/src/workerd/io/compatibility-date.c%2B%2B */ -export const DEFAULT_COMPAT_DATE: CompatDate = "2026-08-15"; +export const DEFAULT_COMPAT_DATE: CompatDate = "2026-08-16"; diff --git a/packages/workers-utils/src/package-resolution.ts b/packages/workers-utils/src/package-resolution.ts index 7e1ad78752b..c2eebb58197 100644 --- a/packages/workers-utils/src/package-resolution.ts +++ b/packages/workers-utils/src/package-resolution.ts @@ -5,11 +5,14 @@ import { parsePackageJSON, readFileSync } from "./parse"; /** * Resolves the filesystem path for an installed npm package. * - * Tries two strategies: - * 1. `require.resolve("/package.json")` -- works when the package exports its package.json - * 2. `require.resolve("")` -- fallback for packages that don't export package.json + * Tries three strategies in order: + * 1. `require.resolve("/package.json")` — works when the package exports its `package.json` + * 2. `require.resolve("")` — fallback for packages that don't export `package.json` + * 3. Direct `node_modules` filesystem lookup — fallback for ESM-only packages whose exports + * map has no `"require"` or `"default"` condition (and no `"./package.json"` export), + * which makes them invisible to `require.resolve` * - * @param packageName - The npm package name to resolve + * @param packageName - The npm package name to resolve (supports scoped packages like `@scope/pkg`) * @param projectPath - The project directory to resolve from * @returns The resolved directory path, or `undefined` if the package is not installed */ @@ -35,6 +38,21 @@ export function getPackagePath( ); } catch {} + try { + // Fallback: direct node_modules lookup for ESM-only packages that aren't + // resolvable via require.resolve (e.g. packages whose exports map only has + // an "import" condition with no "require" or "default"). + const candidate = path.join( + projectPath, + "node_modules", + packageName, + "package.json" + ); + if (statSync(candidate).isFile()) { + return path.dirname(candidate); + } + } catch {} + return undefined; } diff --git a/packages/workers-utils/tests/package-resolution.test.ts b/packages/workers-utils/tests/package-resolution.test.ts new file mode 100644 index 00000000000..71578555b40 --- /dev/null +++ b/packages/workers-utils/tests/package-resolution.test.ts @@ -0,0 +1,160 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { + getInstalledPackageVersion, + getPackagePath, + isPackageInstalled, +} from "../src/package-resolution"; + +/** + * Creates a minimal CJS package in `node_modules/` under the current directory. + * + * @param name - Package name (supports scoped like `@scope/pkg`) + * @param version - Version string to write into `package.json` + */ +function createCjsPackage(name: string, version: string): void { + const pkgDir = path.join(process.cwd(), "node_modules", name); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "index.js"), "module.exports = {}"); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name, version }, null, 2) + ); +} + +/** + * Creates a minimal ESM-only package in `node_modules/` under the current + * directory. The package has `"type": "module"` and an exports map that only + * provides an `"import"` condition — no `"require"`, no `"default"`, and no + * `"./package.json"` export. This makes it invisible to `require.resolve`. + * + * @param name - Package name (supports scoped like `@scope/pkg`) + * @param version - Version string to write into `package.json` + */ +function createEsmOnlyPackage(name: string, version: string): void { + const pkgDir = path.join(process.cwd(), "node_modules", name); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "index.mjs"), "export default {}"); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify( + { + name, + version, + type: "module", + exports: { + ".": { + types: "./dist/index.d.ts", + import: "./index.mjs", + }, + }, + }, + null, + 2 + ) + ); +} + +describe("getPackagePath", () => { + runInTempDir(); + + it("should resolve a CJS package", ({ expect }) => { + createCjsPackage("cjs-pkg", "1.0.0"); + + const result = getPackagePath("cjs-pkg", process.cwd()); + + expect(result).toBeDefined(); + expect(result).toContain(path.join("node_modules", "cjs-pkg")); + }); + + it("should resolve an ESM-only package via filesystem fallback", ({ + expect, + }) => { + createEsmOnlyPackage("esm-only-pkg", "2.0.0"); + + const result = getPackagePath("esm-only-pkg", process.cwd()); + + expect(result).toBe( + path.join(process.cwd(), "node_modules", "esm-only-pkg") + ); + }); + + it("should resolve a scoped ESM-only package", ({ expect }) => { + createEsmOnlyPackage("@cloudflare/think", "0.1.0"); + + const result = getPackagePath("@cloudflare/think", process.cwd()); + + expect(result).toBe( + path.join(process.cwd(), "node_modules", "@cloudflare", "think") + ); + }); + + it("should return undefined for a non-existent package", ({ expect }) => { + const result = getPackagePath("nonexistent-package", process.cwd()); + + expect(result).toBeUndefined(); + }); +}); + +describe("getInstalledPackageVersion", () => { + runInTempDir(); + + it("should return the version for a CJS package", ({ expect }) => { + createCjsPackage("cjs-pkg", "3.2.1"); + + const result = getInstalledPackageVersion("cjs-pkg", process.cwd()); + + expect(result).toBe("3.2.1"); + }); + + it("should return the version for an ESM-only package", ({ expect }) => { + createEsmOnlyPackage("esm-only-pkg", "4.5.6"); + + const result = getInstalledPackageVersion("esm-only-pkg", process.cwd()); + + expect(result).toBe("4.5.6"); + }); + + it("should return the version for a scoped ESM-only package", ({ + expect, + }) => { + createEsmOnlyPackage("@scope/esm-pkg", "0.3.0"); + + const result = getInstalledPackageVersion("@scope/esm-pkg", process.cwd()); + + expect(result).toBe("0.3.0"); + }); + + it("should return undefined for a non-existent package", ({ expect }) => { + const result = getInstalledPackageVersion( + "nonexistent-package", + process.cwd() + ); + + expect(result).toBeUndefined(); + }); +}); + +describe("isPackageInstalled", () => { + runInTempDir(); + + it("should return true for a CJS package", ({ expect }) => { + createCjsPackage("cjs-pkg", "1.0.0"); + + expect(isPackageInstalled("cjs-pkg", process.cwd())).toBe(true); + }); + + it("should return true for an ESM-only package", ({ expect }) => { + createEsmOnlyPackage("esm-only-pkg", "1.0.0"); + + expect(isPackageInstalled("esm-only-pkg", process.cwd())).toBe(true); + }); + + it("should return false for a non-existent package", ({ expect }) => { + expect(isPackageInstalled("nonexistent-package", process.cwd())).toBe( + false + ); + }); +}); diff --git a/packages/workflows-shared/src/binding.ts b/packages/workflows-shared/src/binding.ts index 2071dc1cc8a..0b801fba28e 100644 --- a/packages/workflows-shared/src/binding.ts +++ b/packages/workflows-shared/src/binding.ts @@ -1,12 +1,17 @@ import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; import { InstanceEvent, instanceStatusName } from "./instance"; import { + isUserTriggeredDelete, isUserTriggeredPause, isUserTriggeredRestart, + createWorkflowError, isUserTriggeredTerminate, WorkflowError, } from "./lib/errors"; -import { isValidWorkflowInstanceId } from "./lib/validators"; +import { + isValidAddressableWorkflowInstanceId, + isValidWorkflowInstanceId, +} from "./lib/validators"; import type { DatabaseInstance, DatabaseVersion, @@ -25,8 +30,52 @@ type Env = { ENGINE: DurableObjectNamespace; BINDING_NAME: string; WORKFLOW_NAME: string; + MINIFLARE_LOOPBACK?: Fetcher; }; +/** Waits for Miniflare to finish deleting an instance's persistence files. */ +async function waitForPersistedInstanceDelete( + env: Env, + id: string | undefined +): Promise { + if (id === undefined || env.MINIFLARE_LOOPBACK === undefined) { + return; + } + + const hexId = env.ENGINE.idFromName(id).toString(); + const response = await env.MINIFLARE_LOOPBACK.fetch( + `http://localhost/core/workflow-storage/${encodeURIComponent(env.WORKFLOW_NAME)}/${hexId}?waitForPendingDelete=1` + ); + if (!response.ok) { + throw new Error( + `Failed to wait for persisted workflow instance '${id}' deletion` + ); + } +} + +/** Aborts an Engine object before removing its persistence files. */ +async function deletePersistedInstance(env: Env, id: string): Promise { + if (env.MINIFLARE_LOOPBACK === undefined) { + return; + } + + const stub = env.ENGINE.get(env.ENGINE.idFromName(id)); + try { + await stub.unsafeAbort(); + } catch { + // Aborting the Durable Object rejects its RPC. + } + + const response = await env.MINIFLARE_LOOPBACK.fetch( + `http://localhost/core/workflow-storage/${encodeURIComponent(env.WORKFLOW_NAME)}/${stub.id.toString()}?defer=1`, + { method: "DELETE" } + ); + if (!response.ok) { + throw new Error(`Failed to delete persisted workflow instance '${id}'`); + } + await waitForPersistedInstanceDelete(env, id); +} + type WorkflowIntrospectionSession = { id: string; operations: WorkflowIntrospectionOperation[]; @@ -151,6 +200,7 @@ export class WorkflowBinding extends WorkerEntrypoint { throw new WorkflowError("Workflow instance has invalid id"); } + await waitForPersistedInstanceDelete(this.env, id); const stubId = this.env.ENGINE.idFromName(id); const stub = this.env.ENGINE.get(stubId); const introspectionSession = workflowIntrospectionSessions.get( @@ -240,72 +290,117 @@ export class WorkflowBinding extends WorkerEntrypoint { ); } - public async deleteBatch( - instanceIds: string[] - ): Promise { - if (instanceIds.length === 0) { - throw new Error( - "WorkflowError: deleteBatch should have at least 1 instance" + /** + * Deletes an instance. Named `deleteInstance` because `Fetcher.delete()` shadows + * a same-named JSRPC method. + */ + public async deleteInstance(id: string): Promise { + if (!isValidAddressableWorkflowInstanceId(id)) { + throw createWorkflowError( + "Instance ID is invalid", + "instance.invalid_id" ); } + const stub = this.env.ENGINE.get(this.env.ENGINE.idFromName(id)); + try { + await stub.deleteInstance(); + } catch (error) { + // delete aborts the instance + if (!isUserTriggeredDelete(error)) { + throw error; + } + } + await waitForPersistedInstanceDelete(this.env, id); + } + + /** Deletes each unique instance once while preserving duplicate results. */ + public async deleteBatch(options: { + instances: string[]; + }): Promise { + const instanceIds = options?.instances; + if (!Array.isArray(instanceIds)) { + throw createWorkflowError("Provided argument is invalid", "body"); + } if (instanceIds.length > 100) { - throw new Error( - "WorkflowError: deleteBatch is limited to 100 instances at a time" + throw createWorkflowError( + "batchDeleteInstances only supports 100 instances at a time", + "body" + ); + } + if (instanceIds.length === 0) { + throw createWorkflowError( + "batchDeleteInstances should have at least 1 instance", + "body" + ); + } + if (!instanceIds.every(isValidAddressableWorkflowInstanceId)) { + throw createWorkflowError( + "Instance ID is invalid", + "instance.invalid_id" ); } const uniqueIds = [...new Set(instanceIds)]; - const resultMap = new Map< - string, - { ok: true } | { ok: false; code: number; message: string } - >(); - const settled = await Promise.allSettled( - uniqueIds.map(async (id) => { - const stubId = this.env.ENGINE.idFromName(id); - const stub = this.env.ENGINE.get(stubId); - try { - await stub.unsafeAbort("User called delete"); - } catch { - // unsafeAbort clears storage and aborts; swallow error - } - - try { - await stub.getStatus(); - resultMap.set(id, { ok: true }); - } catch { - resultMap.set(id, { ok: true }); - } - }) + uniqueIds.map((id) => this.deleteInstance(id)) ); - - for (let i = 0; i < uniqueIds.length; i++) { - const result = settled[i]; - if (result.status === "rejected") { - resultMap.set(uniqueIds[i], { - ok: false, - code: 10001, - message: "workflows.api.error.internal_server", - }); + const resultsById = new Map( + uniqueIds.map((id, index) => [id, settled[index]]) + ); + const result: WorkflowBatchDeleteResult = { deleted: [], errors: [] }; + for (const id of instanceIds) { + const deletion = resultsById.get(id); + if (deletion === undefined) { + throw new Error("Missing batch deletion result"); + } + if ( + deletion.status === "fulfilled" || + isUserTriggeredDelete(deletion.reason) + ) { + result.deleted.push({ id }); + continue; } - } - const deleted: { id: string }[] = []; - const errors: { id: string; code: number; message: string }[] = []; + const isNotFound = + deletion.reason instanceof Error && + deletion.reason.message.includes("(instance.not_found)"); + result.errors.push({ + id, + code: isNotFound ? 10400 : 10001, + message: isNotFound + ? "workflows.api.error.instance.not_found" + : "workflows.api.error.internal_server", + }); + } - for (const id of instanceIds) { - const result = resultMap.get(id); - if (result && result.ok) { - deleted.push({ id }); - } else if (result && !result.ok) { - errors.push({ id, code: result.code, message: result.message }); - } else { - deleted.push({ id }); - } + const missingIds = new Set( + result.errors.filter(({ code }) => code === 10400).map(({ id }) => id) + ); + const cleanupIds = [...missingIds]; + const cleanups = await Promise.allSettled( + cleanupIds.map((id) => deletePersistedInstance(this.env, id)) + ); + const failedCleanupIds = new Set( + cleanupIds.filter((_, index) => cleanups[index]?.status === "rejected") + ); + if (failedCleanupIds.size === 0) { + return result; } - return { deleted, errors }; + const errorsById = new Map(result.errors.map((error) => [error.id, error])); + return { + deleted: result.deleted.filter(({ id }) => !failedCleanupIds.has(id)), + errors: instanceIds.flatMap((id) => { + if (failedCleanupIds.has(id)) { + return [ + { id, code: 10001, message: "workflows.api.error.internal_server" }, + ]; + } + const error = errorsById.get(id); + return error === undefined ? [] : [error]; + }), + }; } public async unsafeGetBindingName(): Promise { @@ -445,6 +540,17 @@ export class WorkflowHandle extends RpcTarget implements WorkflowInstance { } } + public async delete(): Promise { + try { + await this.stub.deleteInstance(); + } catch (e) { + // delete aborts the instance + if (!isUserTriggeredDelete(e)) { + throw e; + } + } + } + public async restart( options?: WorkflowInstanceRestartOptions ): Promise { @@ -519,15 +625,6 @@ export class WorkflowHandle extends RpcTarget implements WorkflowInstance { }; } - public async delete(): Promise { - try { - await this.stub.unsafeAbort("User called delete"); - } catch { - // unsafeAbort clears storage and aborts the DO; swallow the - // resulting error so the caller sees a clean resolution. - } - } - public async sendEvent(args: { payload: unknown; type: string; diff --git a/packages/workflows-shared/src/engine.ts b/packages/workflows-shared/src/engine.ts index a6e96855e1f..cbc7f8927ba 100644 --- a/packages/workflows-shared/src/engine.ts +++ b/packages/workflows-shared/src/engine.ts @@ -64,6 +64,8 @@ import type { interface Env { ENGINE: DurableObjectNamespace; USER_WORKFLOW: WorkflowEntrypoint; + MINIFLARE_LOOPBACK?: Fetcher; + WORKFLOW_NAME?: string; STEP_LIMIT?: string; // JSON-encoded number from miniflare binding } @@ -1037,6 +1039,37 @@ export class Engine extends DurableObject { await this.abort(ABORT_REASONS.USER_TERMINATE); } + /** Deletes all instance state and aborts its current execution. */ + async deleteInstance(): Promise { + if ((await this.ctx.storage.get(INSTANCE_METADATA)) === undefined) { + throw createWorkflowError( + "Instance does not exist", + "instance.not_found" + ); + } + + await this.ctx.storage.deleteAll(); + + if ( + this.env.MINIFLARE_LOOPBACK !== undefined && + this.env.WORKFLOW_NAME !== undefined + ) { + try { + const response = await this.env.MINIFLARE_LOOPBACK.fetch( + `http://localhost/core/workflow-storage/${encodeURIComponent(this.env.WORKFLOW_NAME)}/${this.ctx.id.toString()}?defer=1`, + { method: "DELETE" } + ); + if (!response.ok && response.status !== 404) { + console.error("Failed to delete persisted workflow instance"); + } + } catch (error) { + console.error("Failed to delete persisted workflow instance", error); + } + } + + await this.abort(ABORT_REASONS.USER_DELETE); + } + async userTriggeredPause() { const status = await this.getStatus(); diff --git a/packages/workflows-shared/src/lib/errors.ts b/packages/workflows-shared/src/lib/errors.ts index 25614b3cd46..735a40828b3 100644 --- a/packages/workflows-shared/src/lib/errors.ts +++ b/packages/workflows-shared/src/lib/errors.ts @@ -70,6 +70,7 @@ export const ABORT_REASONS = { USER_PAUSE: `${ABORT_PREFIX} User called pause`, USER_RESTART: `${ABORT_PREFIX} User called restart`, USER_TERMINATE: `${ABORT_PREFIX} User called terminate`, + USER_DELETE: `${ABORT_PREFIX} User called delete`, NON_RETRYABLE_ERROR: `${ABORT_PREFIX} A step threw a NonRetryableError`, NOT_SERIALISABLE: `${ABORT_PREFIX} Value is not serialisable`, STORAGE_LIMIT_EXCEEDED: `${ABORT_PREFIX} Storage limit exceeded`, @@ -110,6 +111,11 @@ export function isUserTriggeredTerminate(e: unknown): boolean { return getErrorMessage(e) === ABORT_REASONS.USER_TERMINATE; } +/** Checks whether an Engine RPC ended because deletion intentionally aborted it. */ +export function isUserTriggeredDelete(e: unknown): boolean { + return getErrorMessage(e) === ABORT_REASONS.USER_DELETE; +} + function getCompatFlag(name: string): boolean { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- safe globalThis access for environments where cloudflare global may not exist return (globalThis as any).Cloudflare?.compatibilityFlags?.[name] ?? false; diff --git a/packages/workflows-shared/src/lib/validators.ts b/packages/workflows-shared/src/lib/validators.ts index 6818581abb3..3cdb7e458df 100644 --- a/packages/workflows-shared/src/lib/validators.ts +++ b/packages/workflows-shared/src/lib/validators.ts @@ -7,12 +7,19 @@ export const MAX_WORKFLOW_NAME_LENGTH = 64; export const MAX_WORKFLOW_INSTANCE_ID_LENGTH = 100; +export const MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH = 271; + export const MAX_STEP_NAME_LENGTH = 256; export const ALLOWED_STRING_ID_PATTERN = "^[a-zA-Z0-9_][a-zA-Z0-9-_]*$"; +export const ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_PATTERN = + "^[a-zA-Z0-9, */#_-]+$"; const ALLOWED_WORKFLOW_INSTANCE_ID_REGEX = new RegExp( ALLOWED_STRING_ID_PATTERN ); +const ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_REGEX = new RegExp( + ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_PATTERN +); const ALLOWED_WORKFLOW_NAME_REGEX = ALLOWED_WORKFLOW_INSTANCE_ID_REGEX; // eslint-disable-next-line no-control-regex -- intentional use of control character range to detect invalid characters in workflow names @@ -41,6 +48,16 @@ export function isValidWorkflowInstanceId(id: string): boolean { return ALLOWED_WORKFLOW_INSTANCE_ID_REGEX.test(id); } +/** Validates IDs that address existing instances, including generated cron IDs. */ +export function isValidAddressableWorkflowInstanceId(id: string): boolean { + return ( + typeof id === "string" && + id.length > 0 && + id.length <= MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH && + ALLOWED_ADDRESSABLE_WORKFLOW_INSTANCE_ID_REGEX.test(id) + ); +} + export function isValidStepName(name: string): boolean { if (name.length > MAX_STEP_NAME_LENGTH) { return false; diff --git a/packages/workflows-shared/tests/binding.test.ts b/packages/workflows-shared/tests/binding.test.ts index 03417019660..75a0c0aee97 100644 --- a/packages/workflows-shared/tests/binding.test.ts +++ b/packages/workflows-shared/tests/binding.test.ts @@ -108,6 +108,23 @@ describe("WorkflowBinding", () => { "Workflow instance has invalid id" ); }); + + it("should block creation when pending persistence deletion fails", async ({ + expect, + }) => { + const binding = new WorkflowBinding(createExecutionContext(), { + ENGINE: env.ENGINE, + BINDING_NAME: "TEST_WORKFLOW", + WORKFLOW_NAME: "test-workflow", + MINIFLARE_LOOPBACK: { + fetch: () => Promise.resolve(new Response(null, { status: 500 })), + } as unknown as Fetcher, + }); + + await expect(binding.create({ id: "cleanup-failed" })).rejects.toThrow( + "Failed to wait for persisted workflow instance 'cleanup-failed' deletion" + ); + }); }); describe("get()", () => { @@ -130,6 +147,7 @@ describe("WorkflowBinding", () => { resume: expect.any(Function), terminate: expect.any(Function), restart: expect.any(Function), + delete: expect.any(Function), }); // Wait for the workflow to complete before the test ends so @@ -144,6 +162,245 @@ describe("WorkflowBinding", () => { }); }); + describe("instance deletion", () => { + it("deleteInstance should delete an instance and wipe its stored state", async ({ + expect, + }) => { + const id = uniqueId(); + const binding = createBinding(); + + setTestWorkflowCallback(async () => "done"); + await binding.create({ id }); + + const instance = await binding.get(id); + await vi.waitUntil( + async () => { + const status = await instance.status(); + return status.status === "complete"; + }, + { timeout: 5000 } + ); + + await expect(binding.deleteInstance(id)).resolves.toBeUndefined(); + await expect(binding.get(id)).rejects.toThrow("instance.not_found"); + }); + + it("should reject an invalid instance ID", async ({ expect }) => { + await expect(createBinding().deleteInstance("")).rejects.toThrow( + "(instance.invalid_id) Instance ID is invalid" + ); + }); + + it("should accept a cron-generated instance ID", async ({ expect }) => { + await expect( + createBinding().deleteInstance("*/30 * * * *-1786001400000") + ).rejects.toThrow("instance.not_found"); + }); + + it("should let a running instance delete itself and stop execution", async ({ + expect, + }) => { + const id = uniqueId(); + const binding = createBinding(); + let deleteStarted = false; + let continuedAfterDelete = false; + + setTestWorkflowCallback(async () => { + deleteStarted = true; + const instance = await binding.get(id); + await (instance as unknown as { delete(): Promise }).delete(); + continuedAfterDelete = true; + }); + await binding.create({ id }); + await vi.waitUntil(() => deleteStarted, { timeout: 5000 }); + + await vi.waitUntil( + async () => { + try { + await binding.get(id); + return false; + } catch { + return true; + } + }, + { timeout: 5000 } + ); + + await scheduler.wait(50); + expect(continuedAfterDelete).toBe(false); + await expect(binding.get(id)).rejects.toThrow("instance.not_found"); + }); + }); + + describe("deleteBatch()", () => { + it("should delete instances and wipe their stored state", async ({ + expect, + }) => { + const ids = [uniqueId(), uniqueId()]; + const binding = createBinding(); + + setTestWorkflowCallback(async () => "done"); + await binding.createBatch(ids.map((id) => ({ id }))); + + for (const id of ids) { + const instance = await binding.get(id); + await vi.waitUntil( + async () => { + const status = await instance.status(); + return status.status === "complete"; + }, + { timeout: 5000 } + ); + } + + await expect(binding.deleteBatch({ instances: ids })).resolves.toEqual({ + deleted: ids.map((id) => ({ id })), + errors: [], + }); + for (const id of ids) { + await expect(binding.get(id)).rejects.toThrow("instance.not_found"); + } + }); + + it("should report each duplicate missing cron-generated ID", async ({ + expect, + }) => { + const binding = createBinding(); + const cronId = "*/30 * * * *-1786001400000"; + await expect( + binding.deleteBatch({ + instances: [cronId, cronId], + }) + ).resolves.toEqual({ + deleted: [], + errors: [ + { + id: cronId, + code: 10400, + message: "workflows.api.error.instance.not_found", + }, + { + id: cronId, + code: 10400, + message: "workflows.api.error.instance.not_found", + }, + ], + }); + }); + + it("should normalize unexpected deletion errors", async ({ expect }) => { + const deleteInstance = vi + .fn() + .mockRejectedValue(new Error("sensitive failure")); + const binding = new WorkflowBinding(createExecutionContext(), { + ENGINE: { + idFromName: (id: string) => id, + get: () => ({ deleteInstance }), + } as unknown as DurableObjectNamespace, + BINDING_NAME: "TEST_WORKFLOW", + WORKFLOW_NAME: "test-workflow", + }); + + await expect( + binding.deleteBatch({ instances: ["broken-instance"] }) + ).resolves.toEqual({ + deleted: [], + errors: [ + { + id: "broken-instance", + code: 10001, + message: "workflows.api.error.internal_server", + }, + ], + }); + expect(deleteInstance).toHaveBeenCalledOnce(); + }); + + it("should report persistence cleanup failures per instance", async ({ + expect, + }) => { + const abort = vi.fn(() => + Promise.reject(new Error("Durable Object aborted")) + ); + const loopbackFetch = vi.fn((url: string) => + Promise.resolve( + new Response(null, { + status: url.includes("/cleanup-failed") ? 500 : 204, + }) + ) + ); + const binding = new WorkflowBinding(createExecutionContext(), { + ENGINE: { + idFromName: (id: string) => ({ toString: () => id }), + get: (id: { toString(): string }) => ({ + id, + deleteInstance: () => { + if (id.toString() === "missing") { + return Promise.reject( + new Error("(instance.not_found) Instance does not exist") + ); + } + return Promise.resolve(); + }, + unsafeAbort: abort, + }), + } as unknown as DurableObjectNamespace, + BINDING_NAME: "TEST_WORKFLOW", + WORKFLOW_NAME: "test-workflow", + MINIFLARE_LOOPBACK: { fetch: loopbackFetch } as unknown as Fetcher, + }); + + await expect( + binding.deleteBatch({ + instances: ["cleanup-failed", "missing", "deleted", "cleanup-failed"], + }) + ).resolves.toEqual({ + deleted: [{ id: "deleted" }], + errors: [ + { + id: "cleanup-failed", + code: 10001, + message: "workflows.api.error.internal_server", + }, + { + id: "missing", + code: 10400, + message: "workflows.api.error.instance.not_found", + }, + { + id: "cleanup-failed", + code: 10001, + message: "workflows.api.error.internal_server", + }, + ], + }); + expect(abort).toHaveBeenCalledOnce(); + expect(loopbackFetch.mock.calls.map(([url]) => url)).toEqual( + expect.arrayContaining([ + expect.stringContaining("/missing?defer=1"), + expect.stringContaining("/missing?waitForPendingDelete=1"), + ]) + ); + }); + + it("should reject invalid batches", async ({ expect }) => { + const binding = createBinding(); + await expect(binding.deleteBatch({ instances: [] })).rejects.toThrow( + "(body) batchDeleteInstances should have at least 1 instance" + ); + await expect( + binding.deleteBatch({ + instances: Array.from({ length: 101 }, (_, i) => `instance-${i}`), + }) + ).rejects.toThrow( + "(body) batchDeleteInstances only supports 100 instances at a time" + ); + await expect(binding.deleteBatch({ instances: [""] })).rejects.toThrow( + "(instance.invalid_id) Instance ID is invalid" + ); + }); + }); + describe("createBatch()", () => { it("should create multiple instances in a batch", async ({ expect }) => { const binding = createBinding(); diff --git a/packages/workflows-shared/tests/validators.test.ts b/packages/workflows-shared/tests/validators.test.ts index deb6ac736c6..13b809a3dbd 100644 --- a/packages/workflows-shared/tests/validators.test.ts +++ b/packages/workflows-shared/tests/validators.test.ts @@ -1,9 +1,11 @@ import { describe, it } from "vitest"; import { + isValidAddressableWorkflowInstanceId, isValidStepConfig, isValidStepName, isValidWorkflowInstanceId, isValidWorkflowName, + MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH, MAX_STEP_NAME_LENGTH, MAX_WORKFLOW_INSTANCE_ID_LENGTH, MAX_WORKFLOW_NAME_LENGTH, @@ -56,6 +58,30 @@ describe("Workflow instance ID validation", () => { }); }); +describe("Addressable Workflow instance ID validation", () => { + it.for([ + "", + NaN, + undefined, + "w".repeat(MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH + 1), + "invalid!", + "0 0 * * MON?2-1786001400000", + ])("should reject invalid IDs", (value, { expect }) => { + expect(isValidAddressableWorkflowInstanceId(value as string)).toBe(false); + }); + + it.for([ + "abc", + "*/30 * * * *-1786001400000", + "0 0 * * MON#2-1786001400000", + "0 0 1,15 * *-1786001400000", + "NAME_123-/cron", + "w".repeat(MAX_ADDRESSABLE_WORKFLOW_INSTANCE_ID_LENGTH), + ])("should accept valid IDs", (value, { expect }) => { + expect(isValidAddressableWorkflowInstanceId(value)).toBe(true); + }); +}); + describe("Workflow instance step name validation", () => { it.for(["\x00", "w".repeat(MAX_STEP_NAME_LENGTH + 1)])( "should reject invalid names", diff --git a/packages/wrangler/src/__tests__/workflows.test.ts b/packages/wrangler/src/__tests__/workflows.test.ts index 89fdb23c676..959d1841f81 100644 --- a/packages/wrangler/src/__tests__/workflows.test.ts +++ b/packages/wrangler/src/__tests__/workflows.test.ts @@ -199,6 +199,7 @@ describe("wrangler workflows", () => { wrangler workflows instances restart Restart a workflow instance wrangler workflows instances pause Pause a workflow instance wrangler workflows instances resume Resume a workflow instance + wrangler workflows instances delete [id..] Delete workflow instances GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -687,6 +688,106 @@ describe("wrangler workflows", () => { }); }); + describe("instances delete", () => { + const mockDeleteInstances = ( + expect: ExpectStatic, + expectedIds: string[], + result: { + deleted: { id: string }[]; + errors: { id: string; code: number; message: string }[]; + } = { deleted: expectedIds.map((id) => ({ id })), errors: [] } + ) => { + msw.use( + http.post( + `*/accounts/:accountId/workflows/:workflowName/instances/batch/delete`, + async ({ request }) => { + expect(await request.json()).toEqual({ instances: expectedIds }); + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result, + }); + }, + { once: true } + ) + ); + }; + + it("should delete multiple instances, including cron-generated IDs", async ({ + expect, + }) => { + writeWranglerConfig(); + const cronId = "*/30 * * * *-1786001400000"; + mockDeleteInstances(expect, ["foo", cronId]); + + await runWrangler( + `workflows instances delete some-workflow foo "${cronId}"` + ); + expect(std.info).toContain(`"foo", "${cronId}"`); + }); + + it("should report per-instance errors after logging deletions", async ({ + expect, + }) => { + writeWranglerConfig(); + mockDeleteInstances(expect, ["foo", "bar"], { + deleted: [{ id: "foo" }], + errors: [{ id: "bar", code: 500, message: "delete failed" }], + }); + + await expect( + runWrangler(`workflows instances delete some-workflow foo bar`) + ).rejects.toThrow( + "Failed to delete 1 workflow instance(s):\n - bar: delete failed" + ); + expect(std.info).toContain('"foo"'); + }); + + it("should read instance IDs from a JSON file", async ({ expect }) => { + writeWranglerConfig(); + fs.writeFileSync("instance-ids.json", JSON.stringify(["bar"])); + mockDeleteInstances(expect, ["foo", "bar"]); + + await runWrangler( + "workflows instances delete some-workflow foo --filename instance-ids.json" + ); + expect(std.info).toContain('"foo", "bar"'); + }); + + it("should require at least one instance ID", async ({ expect }) => { + writeWranglerConfig(); + await expect( + runWrangler("workflows instances delete some-workflow") + ).rejects.toThrow("Provide at least one workflow instance ID"); + }); + + it("should reject an invalid IDs file", async ({ expect }) => { + writeWranglerConfig(); + fs.writeFileSync("instance-ids.json", JSON.stringify(["foo", 1])); + await expect( + runWrangler( + "workflows instances delete some-workflow --filename instance-ids.json" + ) + ).rejects.toThrow( + 'Unexpected JSON input from "instance-ids.json". Expected an array of strings.' + ); + }); + + it("should reject more than 100 combined instances", async ({ expect }) => { + writeWranglerConfig(); + const ids = Array.from({ length: 100 }, (_, i) => `instance-${i}`); + fs.writeFileSync("instance-ids.json", JSON.stringify(["overflow"])); + await expect( + runWrangler( + `workflows instances delete some-workflow ${ids.join(" ")} --filename instance-ids.json` + ) + ).rejects.toThrow( + "You can delete at most 100 workflow instances at a time" + ); + }); + }); + describe("instances restart", () => { const mockInstances: Instance[] = [ { @@ -1807,6 +1908,55 @@ describe("wrangler workflows", () => { }); }); + describe("workflows instances delete --local", () => { + it("should resolve latest once before local deletion", async ({ + expect, + }) => { + writeWranglerConfig(); + let listRequests = 0; + const ids = ["newest-instance", "newest-instance", "explicit-instance"]; + msw.use( + http.get(`${LOCAL_BASE}/workflows/:workflowName/instances`, () => { + listRequests++; + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: [ + { + id: "newest-instance", + created_on: "2024-06-01T00:00:00Z", + }, + ], + }); + }), + http.post( + `${LOCAL_BASE}/workflows/:workflowName/instances/batch/delete`, + async ({ request }) => { + expect(await request.json()).toEqual({ instances: ids }); + return HttpResponse.json({ + success: true, + errors: [], + messages: [], + result: { + deleted: ids.map((id) => ({ id })), + errors: [], + }, + }); + } + ) + ); + + await runWrangler( + "workflows instances delete my-workflow latest latest explicit-instance --local" + ); + expect(listRequests).toBe(1); + expect(std.info).toContain( + '"newest-instance", "newest-instance", "explicit-instance"' + ); + }); + }); + describe("workflows instances restart --local", () => { it("should restart an instance in local dev session", async ({ expect, diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 2dea1aefd75..710956e00ac 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -542,6 +542,7 @@ import { websearchSearchCommand } from "./websearch/search"; import { workflowsInstanceNamespace, workflowsNamespace } from "./workflows"; import { workflowsDeleteCommand } from "./workflows/commands/delete"; import { workflowsDescribeCommand } from "./workflows/commands/describe"; +import { workflowsInstancesDeleteCommand } from "./workflows/commands/instances/delete"; import { workflowsInstancesDescribeCommand } from "./workflows/commands/instances/describe"; import { workflowsInstancesListCommand } from "./workflows/commands/instances/list"; import { workflowsInstancesPauseCommand } from "./workflows/commands/instances/pause"; @@ -2299,6 +2300,10 @@ export function createCLIParser(argv: string[]) { command: "wrangler workflows instances resume", definition: workflowsInstancesResumeCommand, }, + { + command: "wrangler workflows instances delete", + definition: workflowsInstancesDeleteCommand, + }, ]); registry.registerNamespace("workflows"); diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index 8ca8e896d0d..88597293345 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -1,12 +1,7 @@ -import { - triggersDeploy, - validateEventTriggerTargets, -} from "@cloudflare/deploy-helpers"; +import { triggersDeploy } from "@cloudflare/deploy-helpers"; import { createCommand, createNamespace } from "../core/create-command"; import { resolveTriggersInput } from "../deployment-bundle/resolve-config-args"; -import { logger } from "../logger"; import * as metrics from "../metrics"; -import { ensureQueuesExistByConfig } from "../queues/client"; import { requireAuth } from "../user"; export const triggersNamespace = createNamespace({ @@ -47,6 +42,7 @@ export const triggersDeployCommand = createCommand({ "dry-run": { describe: "Don't actually deploy", type: "boolean", + default: false, }, "experimental-deploy-helpers": { describe: "Experimental: Gates refactored deploy/upload path", @@ -67,22 +63,14 @@ export const triggersDeployCommand = createCommand({ sendMetrics: config.send_metrics, }); const props = resolveTriggersInput(args, config); - validateEventTriggerTargets(config, props.scriptName); - - if (args.dryRun) { - logger.log(`--dry-run: exiting now.`); - return; - } - - // Any validation that requires auth goes below - const accountId = await requireAuth(config); - await ensureQueuesExistByConfig(config); + const accountId = args.dryRun ? undefined : await requireAuth(config); await triggersDeploy({ config, accountId, - env: args.env, firstDeploy: false, + dryRun: args.dryRun, + validated: false, ...props, }); }, diff --git a/packages/wrangler/src/workflows/commands/instances/delete.ts b/packages/wrangler/src/workflows/commands/instances/delete.ts new file mode 100644 index 00000000000..ac580b91006 --- /dev/null +++ b/packages/wrangler/src/workflows/commands/instances/delete.ts @@ -0,0 +1,129 @@ +import { parseJSON, readFileSync, UserError } from "@cloudflare/workers-utils"; +import { fetchResult } from "../../../cfetch"; +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { requireAuth } from "../../../user"; +import { + fetchLocalResult, + getLocalInstanceIdFromArgs, + localWorkflowArgs, +} from "../../local"; +import { getInstanceIdFromArgs } from "../../utils"; + +type WorkflowBatchDeleteResult = { + deleted: { id: string }[]; + errors: { id: string; code: number; message: string }[]; +}; + +export const workflowsInstancesDeleteCommand = createCommand({ + metadata: { + description: "Delete workflow instances", + owner: "Product: Workflows", + status: "stable", + }, + positionalArgs: ["name", "id"], + args: { + ...localWorkflowArgs, + name: { + describe: "Name of the workflow", + type: "string", + demandOption: true, + }, + id: { + describe: + "IDs of the instances - you can type 'latest' to get the latest instance and delete it", + type: "string", + array: true, + }, + filename: { + describe: "Path to a JSON file containing an array of instance IDs", + type: "string", + }, + }, + + async handler(args, { config }) { + let fileIds: string[] = []; + if (args.filename) { + const parsed = parseJSON(readFileSync(args.filename), args.filename); + if ( + !Array.isArray(parsed) || + !parsed.every((id) => typeof id === "string") + ) { + throw new UserError( + `Unexpected JSON input from "${args.filename}". Expected an array of strings.`, + { telemetryMessage: "workflows batch delete invalid filename" } + ); + } + fileIds = parsed; + } + + const requestedIds = [...(args.id ?? []), ...fileIds]; + if (requestedIds.length === 0) { + throw new UserError("Provide at least one workflow instance ID", { + telemetryMessage: "workflows batch delete no ids", + }); + } + if (requestedIds.length > 100) { + throw new UserError( + "You can delete at most 100 workflow instances at a time", + { + telemetryMessage: "workflows batch delete too large", + } + ); + } + + let ids = requestedIds; + let result: WorkflowBatchDeleteResult; + + if (args.local) { + if (ids.includes("latest")) { + const latestId = await getLocalInstanceIdFromArgs(args.port, { + id: "latest", + name: args.name, + }); + ids = ids.map((id) => (id === "latest" ? latestId : id)); + } + result = await fetchLocalResult( + args.port, + `/workflows/${encodeURIComponent(args.name)}/instances/batch/delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: ids }), + } + ); + } else { + const accountId = await requireAuth(config); + if (ids.includes("latest")) { + const latestId = await getInstanceIdFromArgs( + accountId, + { id: "latest", name: args.name }, + config + ); + ids = ids.map((id) => (id === "latest" ? latestId : id)); + } + result = await fetchResult( + config, + `/accounts/${accountId}/workflows/${args.name}/instances/batch/delete`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instances: ids }), + } + ); + } + + if (result.deleted.length > 0) { + logger.info( + `🗑️ Deleted workflow instances from "${args.name}": ${result.deleted.map(({ id }) => `"${id}"`).join(", ")}` + ); + } + + if (result.errors.length > 0) { + throw new UserError( + `Failed to delete ${result.errors.length} workflow instance(s):\n${result.errors.map(({ id, message }) => ` - ${id}: ${message}`).join("\n")}`, + { telemetryMessage: "workflows batch delete partial failure" } + ); + } + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a473fcdac0..cfae6696741 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ catalogs: specifier: 0.13.3 version: 0.13.3 '@cloudflare/workers-types': - specifier: ^5.20260815.1 + specifier: ^5.20260816.1 version: 5.20260817.1 '@hey-api/openapi-ts': specifier: 0.94.0 @@ -85,8 +85,8 @@ catalogs: specifier: 4.1.0 version: 4.1.0 workerd: - specifier: 1.20260815.1 - version: 1.20260815.1 + specifier: 1.20260816.1 + version: 1.20260816.1 ws: specifier: 8.21.0 version: 8.21.0 @@ -1737,7 +1737,7 @@ importers: version: 5.8.3 vitest: specifier: catalog:default - version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.23.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) packages/build-output-utils: dependencies: @@ -1823,7 +1823,7 @@ importers: version: 5.8.3 vitest: specifier: catalog:default - version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.23.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) packages/config: dependencies: @@ -2333,7 +2333,7 @@ importers: version: 7.29.0 workerd: specifier: catalog:default - version: 1.20260815.1 + version: 1.20260816.1 ws: specifier: catalog:default version: 8.21.0 @@ -2732,7 +2732,7 @@ importers: version: link:../miniflare workerd: specifier: catalog:default - version: 1.20260815.1 + version: 1.20260816.1 devDependencies: '@cloudflare/workers-tsconfig': specifier: workspace:* @@ -2839,7 +2839,7 @@ importers: version: 2.0.0-rc.24 workerd: specifier: catalog:default - version: 1.20260815.1 + version: 1.20260816.1 wrangler: specifier: workspace:* version: link:../wrangler @@ -4556,7 +4556,7 @@ importers: version: 2.0.0-rc.24 workerd: specifier: catalog:default - version: 1.20260815.1 + version: 1.20260816.1 devDependencies: '@aws-sdk/client-s3': specifier: ^3.721.0 @@ -5781,7 +5781,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/intl-types@1.5.7': - resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==, tarball: https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.7.tgz} + resolution: {integrity: sha512-5p+NqAoM3rOMsZsAS6RMWvClhuxWA3YqRkfIxkTcc6uYNsays90GuyzdXmN/v+T7UiSkmzRa7Atu75tD/245MQ==} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5832,7 +5832,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/style-const@6.1.3': - resolution: {integrity: sha512-kwKNttljHfLMY9iVY4r4P00L9TrIc3xWvFoFte/ImLIOq13MH1sDXkQbA8nLC1qcFq7Liv/gbGrtgouPW2lO5Q==, tarball: https://registry.npmjs.org/@cloudflare/style-const/-/style-const-6.1.3.tgz} + resolution: {integrity: sha512-kwKNttljHfLMY9iVY4r4P00L9TrIc3xWvFoFte/ImLIOq13MH1sDXkQbA8nLC1qcFq7Liv/gbGrtgouPW2lO5Q==} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5860,7 +5860,7 @@ packages: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 '@cloudflare/types@6.29.1': - resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==, tarball: https://registry.npmjs.org/@cloudflare/types/-/types-6.29.1.tgz} + resolution: {integrity: sha512-3AfpWx3G47NWgrkTMMIxcDxl/JpS8K4a5w28+4afK8Eyzd2Mnh7+JRB3C59A6mUjo6e+KTJ/cEvyVIHYO6FQDA==} peerDependencies: react: ^15.0.0-0 || ^16.0.0-0 || ^17.0.0-0 @@ -5877,7 +5877,7 @@ packages: resolution: {integrity: sha512-qdCFf90hoZzT4o4xEmxOKUf9+bEJNGh4ANnRYApo6BMyVnHoHEHAQ3nWmGSHBmo+W9hOk2Ik7r1oHLbI0O/RRg==} '@cloudflare/util-en-garde@8.0.15': - resolution: {integrity: sha512-wFs0Ug1TGhGcYZZ2JfPM4g0fNTIkBKSp8XRdZHZ3iq+2B/RdP7fYbzZxWpdUKzphVt+S2gVpMbo3nwn+dRhw+g==, tarball: https://registry.npmjs.org/@cloudflare/util-en-garde/-/util-en-garde-8.0.15.tgz} + resolution: {integrity: sha512-wFs0Ug1TGhGcYZZ2JfPM4g0fNTIkBKSp8XRdZHZ3iq+2B/RdP7fYbzZxWpdUKzphVt+S2gVpMbo3nwn+dRhw+g==} '@cloudflare/util-hooks@1.3.1': resolution: {integrity: sha512-gIsPlzgUbMswIE1h8vGK6LZr/Io5yocUl01WCLy5fxEajhCQ0mNLixkD2Uqne+WPTfqzu4jgC5NxYXgl+Hf6yQ==} @@ -5895,91 +5895,91 @@ packages: vitest: ^4.1.0 '@cloudflare/workerd-darwin-64@1.20260317.1': - resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260317.1.tgz} + resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-64@1.20260423.1': - resolution: {integrity: sha512-+1vfsTa/fyE/9GCrNHBkWdIYj4gXSjWSH7ATdy7/FHs07iB8Mapuk8/sW8Fxs2oXpaNeJ3CN83Ux5i4nQDlwNw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260423.1.tgz} + resolution: {integrity: sha512-+1vfsTa/fyE/9GCrNHBkWdIYj4gXSjWSH7ATdy7/FHs07iB8Mapuk8/sW8Fxs2oXpaNeJ3CN83Ux5i4nQDlwNw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-64@1.20260815.1': - resolution: {integrity: sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz} + '@cloudflare/workerd-darwin-64@1.20260816.1': + resolution: {integrity: sha512-yi7HfTICQn2X5udgIN8hIKnvl37oJsyHkUV9Nkzf3uPZBQbF2E64CZS9eezdu3l0oBcm9awFJyNvfLGI01LpGg==} engines: {node: '>=16'} cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260317.1': - resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260317.1.tgz} + resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260423.1': - resolution: {integrity: sha512-plI6RlUg+hPZ83DUbeWCNG+JNeVHLysinowtYT8O02LKm9Dy2GMYgnosOe3xDFhKE7ou+9SI8KJiNfSnwgHSIg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260423.1.tgz} + resolution: {integrity: sha512-plI6RlUg+hPZ83DUbeWCNG+JNeVHLysinowtYT8O02LKm9Dy2GMYgnosOe3xDFhKE7ou+9SI8KJiNfSnwgHSIg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260815.1': - resolution: {integrity: sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz} + '@cloudflare/workerd-darwin-arm64@1.20260816.1': + resolution: {integrity: sha512-K4iTuQLKe1bKy9PbqAI26mkFJkQbdZjyUEkcsXZqvt0NJnWv89wrlWHZsp56sH9W3Z72qi7pNAWq8Ew3c5zCxg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] '@cloudflare/workerd-linux-64@1.20260317.1': - resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260317.1.tgz} + resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-64@1.20260423.1': - resolution: {integrity: sha512-Ud5xtpXhCB3/XtgJK4ac6VYlQAb/LPOflvhi0/ncndx8dSyJ8iXHGXd7VOOikGXGWdRsttGpstlXYUEN6hU8TQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260423.1.tgz} + resolution: {integrity: sha512-Ud5xtpXhCB3/XtgJK4ac6VYlQAb/LPOflvhi0/ncndx8dSyJ8iXHGXd7VOOikGXGWdRsttGpstlXYUEN6hU8TQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-64@1.20260815.1': - resolution: {integrity: sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz} + '@cloudflare/workerd-linux-64@1.20260816.1': + resolution: {integrity: sha512-0vfYLolUFnsqqGHGQrbN0jT1198Qn+P/NS7DCIJ2G5An/fFLDjY14N8MYUuU1Jhkym/0FCz5m1vihRyFqCuUEg==} engines: {node: '>=16'} cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260317.1': - resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260317.1.tgz} + resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260423.1': - resolution: {integrity: sha512-xI2SqbkRDOwPQUUGd8N6qb2wuxFlu6GWi7qz6OFolZIDGu6m5Q/oMzMtV0txkXVClw1puLWYlc/wcURxAi+qsg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260423.1.tgz} + resolution: {integrity: sha512-xI2SqbkRDOwPQUUGd8N6qb2wuxFlu6GWi7qz6OFolZIDGu6m5Q/oMzMtV0txkXVClw1puLWYlc/wcURxAi+qsg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260815.1': - resolution: {integrity: sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==, tarball: https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz} + '@cloudflare/workerd-linux-arm64@1.20260816.1': + resolution: {integrity: sha512-EaK6P7RkmC3NMMDE5lV0xyK+9xfIOjCnpZLqZDwhp6zdWzYJozZ3fDTIDfVmg7OhIZGRzpV4ehkHRAKOnfKGNQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] '@cloudflare/workerd-windows-64@1.20260317.1': - resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260317.1.tgz} + resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] '@cloudflare/workerd-windows-64@1.20260423.1': - resolution: {integrity: sha512-3ZhiwG/MSCF9YFxSOkfbXWM2yEIoMKWGnnZMZklY6jnNRTQIGqjvVBdzPYZyLiUqTRV5L+1W7Mvb7tg/merhiQ==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260423.1.tgz} + resolution: {integrity: sha512-3ZhiwG/MSCF9YFxSOkfbXWM2yEIoMKWGnnZMZklY6jnNRTQIGqjvVBdzPYZyLiUqTRV5L+1W7Mvb7tg/merhiQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workerd-windows-64@1.20260815.1': - resolution: {integrity: sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==, tarball: https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz} + '@cloudflare/workerd-windows-64@1.20260816.1': + resolution: {integrity: sha512-tKY1dt/KqkJUT0Flyq7spPB4zhfoNw9Pjq/AzzIHiUl9H2lcYtKut4a0ZGb2k/4ID/2DWD4DHRPv1fcM4zfVGA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -11812,8 +11812,8 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - electron-to-chromium@1.5.406: - resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + electron-to-chromium@1.5.407: + resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==} electron-to-chromium@1.5.45: resolution: {integrity: sha512-vOzZS6uZwhhbkZbcRyiy99Wg+pYFV5hk+5YaECvx0+Z31NR3Tt5zS6dze2OepT6PCTzVzT0dIJItti+uAW5zmw==} @@ -13067,6 +13067,9 @@ packages: jose@6.2.1: resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -16694,8 +16697,8 @@ packages: engines: {node: '>=16'} hasBin: true - workerd@1.20260815.1: - resolution: {integrity: sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==} + workerd@1.20260816.1: + resolution: {integrity: sha512-aOk2X0r+bezM3jAv++9GtnqmwWMVzHcbsM6fwkFTgq/+GNfWjhjgdUX2ju9MdHV0t/xAP6m6EH4NPSHa8Gs+5Q==} engines: {node: '>=16'} hasBin: true @@ -18597,7 +18600,7 @@ snapshots: '@cloudflare/workerd-darwin-64@1.20260423.1': optional: true - '@cloudflare/workerd-darwin-64@1.20260815.1': + '@cloudflare/workerd-darwin-64@1.20260816.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260317.1': @@ -18606,7 +18609,7 @@ snapshots: '@cloudflare/workerd-darwin-arm64@1.20260423.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260815.1': + '@cloudflare/workerd-darwin-arm64@1.20260816.1': optional: true '@cloudflare/workerd-linux-64@1.20260317.1': @@ -18615,7 +18618,7 @@ snapshots: '@cloudflare/workerd-linux-64@1.20260423.1': optional: true - '@cloudflare/workerd-linux-64@1.20260815.1': + '@cloudflare/workerd-linux-64@1.20260816.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260317.1': @@ -18624,7 +18627,7 @@ snapshots: '@cloudflare/workerd-linux-arm64@1.20260423.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260815.1': + '@cloudflare/workerd-linux-arm64@1.20260816.1': optional: true '@cloudflare/workerd-windows-64@1.20260317.1': @@ -18633,7 +18636,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260423.1': optional: true - '@cloudflare/workerd-windows-64@1.20260815.1': + '@cloudflare/workerd-windows-64@1.20260816.1': optional: true '@cloudflare/workers-editor-shared@0.1.1(@cloudflare/style-const@6.1.3(react@19.2.4))(@cloudflare/style-container@7.12.2(@cloudflare/style-const@6.1.3(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -20054,7 +20057,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.6.2(express@5.2.1) hono: 4.12.32 - jose: 6.2.1 + jose: 6.2.9 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -23277,7 +23280,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.406 + electron-to-chromium: 1.5.407 node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) @@ -24062,7 +24065,7 @@ snapshots: electron-to-chromium@1.5.286: {} - electron-to-chromium@1.5.406: {} + electron-to-chromium@1.5.407: {} electron-to-chromium@1.5.45: {} @@ -25654,6 +25657,8 @@ snapshots: jose@6.2.1: {} + jose@6.2.9: {} + joycon@3.1.1: {} js-base64@3.7.7: {} @@ -29839,13 +29844,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260423.1 '@cloudflare/workerd-windows-64': 1.20260423.1 - workerd@1.20260815.1: + workerd@1.20260816.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260815.1 - '@cloudflare/workerd-darwin-arm64': 1.20260815.1 - '@cloudflare/workerd-linux-64': 1.20260815.1 - '@cloudflare/workerd-linux-arm64': 1.20260815.1 - '@cloudflare/workerd-windows-64': 1.20260815.1 + '@cloudflare/workerd-darwin-64': 1.20260816.1 + '@cloudflare/workerd-darwin-arm64': 1.20260816.1 + '@cloudflare/workerd-linux-64': 1.20260816.1 + '@cloudflare/workerd-linux-arm64': 1.20260816.1 + '@cloudflare/workerd-windows-64': 1.20260816.1 wrangler@4.76.0(@cloudflare/workers-types@5.20260817.1): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bbfb546b73e..f95c60a95f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -128,8 +128,8 @@ catalog: esbuild: "0.28.1" "@playwright/test": "1.60.0" playwright-chromium: "1.60.0" - "@cloudflare/workers-types": "^5.20260815.1" - workerd: "1.20260815.1" + "@cloudflare/workers-types": "^5.20260816.1" + workerd: "1.20260816.1" jsonc-parser: "3.2.0" smol-toml: "1.5.2" msw: "2.14.6"