@void/deploy-cloudflare 0.21.2 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -69,6 +69,7 @@ export declare function assertCloudflareDurableObjectLifecycleSupported(options:
69
69
  accountId: string;
70
70
  apiBaseUrl: string;
71
71
  readMigrationTag?: (options: CloudflareWorkerMigrationTagOptions) => Promise<string | null>;
72
+ allowPendingAtomicMigration?: boolean;
72
73
  }): Promise<void>;
73
74
  export declare function assertCloudflareBindingsPreserved(before: ExistingCloudflareWorker, after: ExistingCloudflareWorker, approvedKv?: ReadonlyArray<ApprovedCloudflareKvBinding>, options?: {
74
75
  staticAssetsBinding?: string;
@@ -197,7 +198,7 @@ type CloudflareVersionActivation = {
197
198
  trafficConfig?: CloudflareTrafficConfig;
198
199
  } | {
199
200
  ok: false;
200
- reason: "uploader-changed" | "status-failed" | "deployment-changed" | "inheritance-source-unavailable" | "inheritance-source-changed" | "traffic-preservation-unavailable" | "traffic-split-unsupported" | "upload-failed" | "upload-output-invalid" | "preview-unavailable" | "readiness-staging-failed" | "readiness-failed" | "readiness-access-denied" | "bootstrap-failed" | "bootstrap-output-invalid" | "bootstrap-readiness-failed" | "bootstrap-readiness-access-denied" | "activation-failed" | "triggers-failed";
201
+ reason: "uploader-changed" | "status-failed" | "deployment-changed" | "inheritance-source-unavailable" | "inheritance-source-changed" | "traffic-preservation-unavailable" | "traffic-split-unsupported" | "upload-failed" | "upload-output-invalid" | "preview-unavailable" | "readiness-staging-failed" | "readiness-failed" | "readiness-access-denied" | "bootstrap-failed" | "bootstrap-output-invalid" | "bootstrap-readiness-failed" | "bootstrap-readiness-access-denied" | "atomic-failed" | "atomic-output-invalid" | "atomic-readiness-failed" | "atomic-readiness-access-denied" | "activation-failed" | "triggers-failed";
201
202
  status?: number;
202
203
  cloudflareErrorCode?: number;
203
204
  /** Bounded uploader error text with terminal escapes removed and common credential formats redacted. */
@@ -273,7 +274,7 @@ type CloudflareVersionActivationOptions = {
273
274
  preserveTriggers?: boolean;
274
275
  expectedPreviousVersionId?: string;
275
276
  trustedInheritanceVersionIds?: ReadonlyArray<string>;
276
- recordVerifiedCandidate?: (versionId: string) => void | Promise<void>;
277
+ recordVerifiedCandidate?: (versionId: string, parentVersionId: string | null) => void | Promise<void>;
277
278
  validateUploadedVersion?: (versionId: string) => Promise<void>;
278
279
  runWrangler: CloudflareCommandRunner;
279
280
  verifyUploader: () => boolean;
@@ -309,6 +310,8 @@ type CloudflareVersionActivationOptions = {
309
310
  readinessAttempts?: number;
310
311
  /** Verify that omitted remote secrets come from the active Worker Version before uploading. */
311
312
  verifyInheritedSecrets?: boolean;
313
+ /** Explicitly publish an existing Durable Object Worker in one atomic operation. */
314
+ atomic?: boolean;
312
315
  /**
313
316
  * Plaintext secrets to attach only to the uploaded candidate version. Supplying every remote
314
317
  * `secret_text` binding lets a deployment recover safely when an inactive upload is newer than
package/dist/index.mjs CHANGED
@@ -246,13 +246,15 @@ async function assertCloudflareDurableObjectLifecycleSupported(options) {
246
246
  const afterTag = await readMigrationTag(migrationOptions);
247
247
  if (!after || after.snapshot.versionId !== existing.snapshot.versionId || afterTag !== activeTag) throw new Error("Cloudflare production or Durable Object migration state changed during verification. Retry from the current deployment.");
248
248
  if (activeTag === null && existing.isSecretOnlyDraft && after.isSecretOnlyDraft) return;
249
+ if (activeTag === null && declarativeClassesMatch(migrations, existing.snapshot.runtime.exports)) return;
249
250
  const activeIndex = activeTag === null ? -1 : migrations.findIndex((migration) => migration.tag === activeTag);
250
251
  const pending = migrations.slice(activeIndex + 1).filter((migration) => migration.changesClasses);
251
252
  if (pending.length === 0) return;
253
+ if (options.allowPendingAtomicMigration === true) return;
252
254
  throw new Error([
253
- "deploy: native Cloudflare beta cannot apply a Durable Object class migration to an existing Worker.",
255
+ "deploy: this Durable Object class migration needs an atomic Cloudflare deployment.",
254
256
  `Pending migration tag${pending.length === 1 ? "" : "s"}: ${pending.map((migration) => migration.tag).join(", ")}.`,
255
- "Apply the class migration through a supported Cloudflare deployment workflow, then retry after that migration tag is active.",
257
+ "Review the migration, then run `void deploy --platform cloudflare --atomic`. The Worker will receive traffic before readiness is checked.",
256
258
  "No database migration or Worker secret was changed."
257
259
  ].join("\n"));
258
260
  }
@@ -270,10 +272,39 @@ function parseDurableObjectMigrations(value) {
270
272
  for (const field of fields) if (field !== void 0 && !Array.isArray(field)) throw new Error("deploy: generated Cloudflare config has a malformed class migration.");
271
273
  return {
272
274
  tag: migration.tag,
273
- changesClasses: fields.some((field) => Array.isArray(field) && field.length > 0)
275
+ changesClasses: fields.some((field) => Array.isArray(field) && field.length > 0),
276
+ step: migration
274
277
  };
275
278
  });
276
279
  }
280
+ /** Cloudflare's declarative exports replace the legacy migration tag after an atomic publish. */
281
+ function declarativeClassesMatch(migrations, actualValue) {
282
+ if (!record$1(actualValue)) return false;
283
+ const desired = /* @__PURE__ */ new Map();
284
+ for (const { step } of migrations) {
285
+ for (const [field, storage] of [["new_classes", "legacy-kv"], ["new_sqlite_classes", "sqlite"]]) for (const name of step[field] ?? []) {
286
+ if (typeof name !== "string") return false;
287
+ desired.set(name, storage);
288
+ }
289
+ for (const item of step.renamed_classes ?? []) {
290
+ if (!record$1(item) || typeof item.from !== "string" || typeof item.to !== "string") return false;
291
+ const storage = desired.get(item.from);
292
+ if (storage !== "sqlite" && storage !== "legacy-kv") return false;
293
+ desired.set(item.from, "deleted");
294
+ desired.set(item.to, storage);
295
+ }
296
+ for (const name of step.deleted_classes ?? []) {
297
+ if (typeof name !== "string") return false;
298
+ desired.set(name, "deleted");
299
+ }
300
+ }
301
+ if (desired.size === 0) return false;
302
+ return [...desired].every(([name, storage]) => {
303
+ const current = actualValue[name];
304
+ if (storage === "deleted") return current === void 0 || record$1(current) && current.state === "deleted";
305
+ return record$1(current) && current.type === "durable-object" && current.storage === storage;
306
+ });
307
+ }
277
308
  function isMissingWorkerOutput$1(output) {
278
309
  return /\[code:\s*10007\]/i.test(output) || /no deployments|\bworker\b.*\b(?:does not exist|not found)\b/i.test(output);
279
310
  }
@@ -1151,7 +1182,7 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1151
1182
  reason: "inheritance-source-unavailable"
1152
1183
  };
1153
1184
  const latestVersionId = recent.at(-1)?.id ?? null;
1154
- if (options.expectedPreviousVersionId !== void 0) {
1185
+ if (options.expectedPreviousVersionId !== void 0 || options.trustedInheritanceVersionIds !== void 0) {
1155
1186
  const activeIndex = recent.findIndex((version) => version.id === previous.versionId);
1156
1187
  const inheritedChain = activeIndex < 0 ? [] : recent.slice(activeIndex);
1157
1188
  const inheritedCandidates = inheritedChain.slice(1).map((version) => version.id);
@@ -1162,7 +1193,8 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1162
1193
  rollback: "not-needed"
1163
1194
  };
1164
1195
  }
1165
- if (options.verifyInheritedSecrets === true && previous.versionId !== latestVersionId) {
1196
+ const trustedCandidateChain = options.trustedInheritanceVersionIds !== void 0 && options.trustedInheritanceVersionIds.length > 0 && previous.versionId !== latestVersionId;
1197
+ if (options.verifyInheritedSecrets === true && previous.versionId !== latestVersionId && !trustedCandidateChain) {
1166
1198
  const replacesAllRemoteSecrets = await candidateReplacesAllRemoteSecrets(options, commonArgs, previous.versionId, latestVersionId);
1167
1199
  if (replacesAllRemoteSecrets === null) return {
1168
1200
  ok: false,
@@ -1214,6 +1246,10 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1214
1246
  } : versionedPreviousTriggerConfig;
1215
1247
  const previousTriggerConfig = triggerConfigWithQueueConsumerCleanup(versionedPreviousTriggerConfigWithTraffic, options.queueConsumerCleanup);
1216
1248
  const alias = `void-ready-${Date.now().toString(36)}`;
1249
+ if (options.atomic && previous.versionId !== null) return bootstrapFirstDeployment({
1250
+ ...options,
1251
+ message
1252
+ }, commonArgs, previous.versionId, true);
1217
1253
  options.onProgress?.("Uploading the Worker and assets...");
1218
1254
  const upload = await Promise.resolve(options.runWrangler([
1219
1255
  "versions",
@@ -1278,8 +1314,8 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1278
1314
  }
1279
1315
  await options.validateUploadedVersion?.(versionId);
1280
1316
  if (options.recordVerifiedCandidate) {
1281
- if (!options.validateUploadedVersion) throw new Error("A Worker candidate cannot be recorded without validating its upload.");
1282
- await options.recordVerifiedCandidate(versionId);
1317
+ if (!options.validateUploadedVersion && !inheritanceBaseline) throw new Error("A Worker candidate cannot be recorded without validating its upload and version sequence.");
1318
+ await options.recordVerifiedCandidate(versionId, previous.versionId);
1283
1319
  }
1284
1320
  const reportedPreviewUrl = output.match(PREVIEW_URL_RE)?.[1];
1285
1321
  let authenticatedWorkersDevSubdomain = reportedPreviewUrl ? extractWorkersDevSubdomain(reportedPreviewUrl) : void 0;
@@ -1311,14 +1347,19 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1311
1347
  const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
1312
1348
  if (stagingOwnerFailure) return stagingOwnerFailure;
1313
1349
  const staged = await deployVersionPercentages(options, commonArgs, [`${previous.versionId}@100`, `${versionId}@0`], message);
1314
- if (!isCleanCommandExit(staged)) return {
1315
- ok: false,
1316
- reason: "readiness-staging-failed",
1317
- status: staged.status,
1318
- versionId,
1319
- previousVersionId: previous.versionId,
1320
- rollback: "not-needed"
1321
- };
1350
+ if (!isCleanCommandExit(staged)) {
1351
+ const cloudflareErrorCode = parseCloudflareErrorCode(`${staged.stdout}\n${staged.stderr}`);
1352
+ return {
1353
+ ok: false,
1354
+ reason: "readiness-staging-failed",
1355
+ status: staged.status,
1356
+ ...commandDiagnosticFields(staged),
1357
+ ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
1358
+ versionId,
1359
+ previousVersionId: previous.versionId,
1360
+ rollback: "not-needed"
1361
+ };
1362
+ }
1322
1363
  stagedVersionOverride = true;
1323
1364
  }
1324
1365
  options.onProgress?.("Waiting for the Worker to become ready...");
@@ -1336,14 +1377,19 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1336
1377
  const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
1337
1378
  if (stagingOwnerFailure) return stagingOwnerFailure;
1338
1379
  const staged = await deployVersionPercentages(options, commonArgs, [`${previous.versionId}@100`, `${versionId}@0`], message);
1339
- if (!isCleanCommandExit(staged)) return {
1340
- ok: false,
1341
- reason: "readiness-staging-failed",
1342
- status: staged.status,
1343
- versionId,
1344
- previousVersionId: previous.versionId,
1345
- rollback: "not-needed"
1346
- };
1380
+ if (!isCleanCommandExit(staged)) {
1381
+ const cloudflareErrorCode = parseCloudflareErrorCode(`${staged.stdout}\n${staged.stderr}`);
1382
+ return {
1383
+ ok: false,
1384
+ reason: "readiness-staging-failed",
1385
+ status: staged.status,
1386
+ ...commandDiagnosticFields(staged),
1387
+ ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
1388
+ versionId,
1389
+ previousVersionId: previous.versionId,
1390
+ rollback: "not-needed"
1391
+ };
1392
+ }
1347
1393
  readinessUrl = stableReadinessUrl;
1348
1394
  authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl);
1349
1395
  readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev;
@@ -1680,12 +1726,26 @@ async function readCloudflareVersionSecretBindings(options, commonArgs, versionI
1680
1726
  * brand-new Worker can therefore need one ordinary deploy before later releases use preview-first
1681
1727
  * version activation. This fallback requires prior proof that no application had been deployed.
1682
1728
  */
1683
- async function bootstrapFirstDeployment(options, commonArgs, previousVersionId) {
1729
+ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId, atomic = false) {
1684
1730
  if (!options.verifyUploader()) return {
1685
1731
  ok: false,
1686
1732
  reason: "uploader-changed"
1687
1733
  };
1688
- options.onProgress?.("Publishing the first Worker deployment...");
1734
+ if (atomic) {
1735
+ const current = await readCurrentProductionVersion(options, commonArgs);
1736
+ if (current.kind === "failed") return {
1737
+ ok: false,
1738
+ reason: "status-failed",
1739
+ status: current.status
1740
+ };
1741
+ if (current.kind !== "ok" || current.versionId !== previousVersionId) return {
1742
+ ok: false,
1743
+ reason: "deployment-changed",
1744
+ activeVersionId: current.kind === "ok" ? current.versionId : null,
1745
+ rollback: "not-needed"
1746
+ };
1747
+ }
1748
+ options.onProgress?.(atomic ? "Publishing the Worker atomically..." : "Publishing the first Worker deployment...");
1689
1749
  const bootstrap = await Promise.resolve(options.runWrangler([
1690
1750
  "deploy",
1691
1751
  ...commonArgs,
@@ -1705,7 +1765,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1705
1765
  const cloudflareErrorCode = parseCloudflareErrorCode(output);
1706
1766
  return {
1707
1767
  ok: false,
1708
- reason: "bootstrap-failed",
1768
+ reason: atomic ? "atomic-failed" : "bootstrap-failed",
1709
1769
  status: bootstrap.status,
1710
1770
  ...commandDiagnosticFields(bootstrap),
1711
1771
  ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
@@ -1719,7 +1779,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1719
1779
  const reportedOrConfiguredUrl = reportedUrl ?? (desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes));
1720
1780
  if (!versionId || !reportedOrConfiguredUrl) return {
1721
1781
  ok: false,
1722
- reason: "bootstrap-output-invalid",
1782
+ reason: atomic ? "atomic-output-invalid" : "bootstrap-output-invalid",
1723
1783
  previousVersionId,
1724
1784
  ...versionId ? { versionId } : {}
1725
1785
  };
@@ -1728,7 +1788,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1728
1788
  const ready = await probeReadiness(`${previewUrl.replace(/\/$/, "")}/__void/ready`, options.workerName, reportedUrl || desiredTriggerConfig.workers_dev ? options.readinessHeaders : void 0, extractWorkersDevSubdomain(previewUrl), void 0, options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 30, 6e4);
1729
1789
  if (ready !== "ready") return {
1730
1790
  ok: false,
1731
- reason: ready === "access-denied" ? "bootstrap-readiness-access-denied" : "bootstrap-readiness-failed",
1791
+ reason: ready === "access-denied" ? atomic ? "atomic-readiness-access-denied" : "bootstrap-readiness-access-denied" : atomic ? "atomic-readiness-failed" : "bootstrap-readiness-failed",
1732
1792
  versionId,
1733
1793
  previousVersionId,
1734
1794
  rollback: "unavailable"
@@ -1738,6 +1798,11 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1738
1798
  versionId,
1739
1799
  previewUrl,
1740
1800
  previousVersionId,
1801
+ ...atomic ? { trafficConfig: {
1802
+ workers_dev: desiredTriggerConfig.workers_dev,
1803
+ preview_urls: desiredTriggerConfig.preview_urls,
1804
+ routes: desiredTriggerConfig.routes
1805
+ } } : {},
1741
1806
  ...workersDevSubdomainResult(previewUrl)
1742
1807
  };
1743
1808
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/deploy-cloudflare",
3
- "version": "0.21.2",
3
+ "version": "0.21.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/voidzero-dev/void.git",
@@ -20,7 +20,7 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@void/deploy-core": "0.21.2",
23
+ "@void/deploy-core": "0.21.3",
24
24
  "jsonc-parser": "3.3.1"
25
25
  },
26
26
  "devDependencies": {