@void/deploy-cloudflare 0.21.2 → 0.21.4

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,13 @@ 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
+ /** Pin opaque secret bindings to the live version when an unpublished upload is newer. */
314
+ pinActiveSecrets?: (activeVersionId: string, bindings: ReadonlyArray<{
315
+ name: string;
316
+ type: "secret_text" | "secret_key";
317
+ }>) => Promise<boolean> | boolean;
318
+ /** Explicitly publish an existing Durable Object Worker in one atomic operation. */
319
+ atomic?: boolean;
312
320
  /**
313
321
  * Plaintext secrets to attach only to the uploaded candidate version. Supplying every remote
314
322
  * `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
  }
@@ -1144,6 +1175,7 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1144
1175
  };
1145
1176
  const previousVersionId = previous.versionId;
1146
1177
  let inheritanceBaseline;
1178
+ let trustedCandidateChain = false;
1147
1179
  if (options.expectedPreviousVersionId !== void 0 || options.verifyInheritedSecrets === true) {
1148
1180
  const recent = await readRecentCloudflareVersionIds(options, commonArgs);
1149
1181
  if (recent === null) return {
@@ -1151,29 +1183,33 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1151
1183
  reason: "inheritance-source-unavailable"
1152
1184
  };
1153
1185
  const latestVersionId = recent.at(-1)?.id ?? null;
1154
- if (options.expectedPreviousVersionId !== void 0) {
1186
+ if (options.expectedPreviousVersionId !== void 0 || options.trustedInheritanceVersionIds !== void 0) {
1155
1187
  const activeIndex = recent.findIndex((version) => version.id === previous.versionId);
1156
1188
  const inheritedChain = activeIndex < 0 ? [] : recent.slice(activeIndex);
1157
1189
  const inheritedCandidates = inheritedChain.slice(1).map((version) => version.id);
1158
- if (!(inheritedChain.length > 0 && inheritedChain.every((version, index) => index === 0 || version.number === inheritedChain[index - 1].number + 1) && arraysEqual(inheritedCandidates, options.trustedInheritanceVersionIds ?? []))) return {
1190
+ trustedCandidateChain = options.trustedInheritanceVersionIds !== void 0 && options.trustedInheritanceVersionIds.length > 0 && arraysEqual(inheritedCandidates, options.trustedInheritanceVersionIds);
1191
+ if (!(inheritedChain.length > 0 && inheritedChain.every((version, index) => index === 0 || version.number === inheritedChain[index - 1].number + 1) && (arraysEqual(inheritedCandidates, options.trustedInheritanceVersionIds ?? []) || options.pinActiveSecrets !== void 0))) return {
1159
1192
  ok: false,
1160
1193
  reason: "inheritance-source-changed",
1161
1194
  activeVersionId: recent.at(-1)?.id ?? null,
1162
1195
  rollback: "not-needed"
1163
1196
  };
1164
1197
  }
1165
- if (options.verifyInheritedSecrets === true && previous.versionId !== latestVersionId) {
1198
+ if (options.verifyInheritedSecrets === true && previous.versionId !== latestVersionId && !trustedCandidateChain) {
1166
1199
  const replacesAllRemoteSecrets = await candidateReplacesAllRemoteSecrets(options, commonArgs, previous.versionId, latestVersionId);
1167
1200
  if (replacesAllRemoteSecrets === null) return {
1168
1201
  ok: false,
1169
1202
  reason: "inheritance-source-unavailable"
1170
1203
  };
1171
- if (!replacesAllRemoteSecrets) return {
1172
- ok: false,
1173
- reason: "inheritance-source-changed",
1174
- activeVersionId: latestVersionId,
1175
- rollback: "not-needed"
1176
- };
1204
+ if (!replacesAllRemoteSecrets) {
1205
+ const activeSecrets = previous.versionId ? await readCloudflareVersionSecretBindings(options, commonArgs, previous.versionId) : null;
1206
+ if (!activeSecrets || !options.pinActiveSecrets || !await options.pinActiveSecrets(previous.versionId, activeSecrets)) return {
1207
+ ok: false,
1208
+ reason: "inheritance-source-changed",
1209
+ activeVersionId: latestVersionId,
1210
+ rollback: "not-needed"
1211
+ };
1212
+ }
1177
1213
  }
1178
1214
  inheritanceBaseline = recent;
1179
1215
  }
@@ -1214,6 +1250,10 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1214
1250
  } : versionedPreviousTriggerConfig;
1215
1251
  const previousTriggerConfig = triggerConfigWithQueueConsumerCleanup(versionedPreviousTriggerConfigWithTraffic, options.queueConsumerCleanup);
1216
1252
  const alias = `void-ready-${Date.now().toString(36)}`;
1253
+ if (options.atomic && previous.versionId !== null) return bootstrapFirstDeployment({
1254
+ ...options,
1255
+ message
1256
+ }, commonArgs, previous.versionId, true);
1217
1257
  options.onProgress?.("Uploading the Worker and assets...");
1218
1258
  const upload = await Promise.resolve(options.runWrangler([
1219
1259
  "versions",
@@ -1278,8 +1318,8 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1278
1318
  }
1279
1319
  await options.validateUploadedVersion?.(versionId);
1280
1320
  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);
1321
+ if (!options.validateUploadedVersion && !inheritanceBaseline) throw new Error("A Worker candidate cannot be recorded without validating its upload and version sequence.");
1322
+ await options.recordVerifiedCandidate(versionId, previous.versionId);
1283
1323
  }
1284
1324
  const reportedPreviewUrl = output.match(PREVIEW_URL_RE)?.[1];
1285
1325
  let authenticatedWorkersDevSubdomain = reportedPreviewUrl ? extractWorkersDevSubdomain(reportedPreviewUrl) : void 0;
@@ -1311,14 +1351,19 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1311
1351
  const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
1312
1352
  if (stagingOwnerFailure) return stagingOwnerFailure;
1313
1353
  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
- };
1354
+ if (!isCleanCommandExit(staged)) {
1355
+ const cloudflareErrorCode = parseCloudflareErrorCode(`${staged.stdout}\n${staged.stderr}`);
1356
+ return {
1357
+ ok: false,
1358
+ reason: "readiness-staging-failed",
1359
+ status: staged.status,
1360
+ ...commandDiagnosticFields(staged),
1361
+ ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
1362
+ versionId,
1363
+ previousVersionId: previous.versionId,
1364
+ rollback: "not-needed"
1365
+ };
1366
+ }
1322
1367
  stagedVersionOverride = true;
1323
1368
  }
1324
1369
  options.onProgress?.("Waiting for the Worker to become ready...");
@@ -1336,14 +1381,19 @@ async function activateCloudflareVersionWithSecretsFile(options) {
1336
1381
  const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
1337
1382
  if (stagingOwnerFailure) return stagingOwnerFailure;
1338
1383
  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
- };
1384
+ if (!isCleanCommandExit(staged)) {
1385
+ const cloudflareErrorCode = parseCloudflareErrorCode(`${staged.stdout}\n${staged.stderr}`);
1386
+ return {
1387
+ ok: false,
1388
+ reason: "readiness-staging-failed",
1389
+ status: staged.status,
1390
+ ...commandDiagnosticFields(staged),
1391
+ ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
1392
+ versionId,
1393
+ previousVersionId: previous.versionId,
1394
+ rollback: "not-needed"
1395
+ };
1396
+ }
1347
1397
  readinessUrl = stableReadinessUrl;
1348
1398
  authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl);
1349
1399
  readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev;
@@ -1680,12 +1730,26 @@ async function readCloudflareVersionSecretBindings(options, commonArgs, versionI
1680
1730
  * brand-new Worker can therefore need one ordinary deploy before later releases use preview-first
1681
1731
  * version activation. This fallback requires prior proof that no application had been deployed.
1682
1732
  */
1683
- async function bootstrapFirstDeployment(options, commonArgs, previousVersionId) {
1733
+ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId, atomic = false) {
1684
1734
  if (!options.verifyUploader()) return {
1685
1735
  ok: false,
1686
1736
  reason: "uploader-changed"
1687
1737
  };
1688
- options.onProgress?.("Publishing the first Worker deployment...");
1738
+ if (atomic) {
1739
+ const current = await readCurrentProductionVersion(options, commonArgs);
1740
+ if (current.kind === "failed") return {
1741
+ ok: false,
1742
+ reason: "status-failed",
1743
+ status: current.status
1744
+ };
1745
+ if (current.kind !== "ok" || current.versionId !== previousVersionId) return {
1746
+ ok: false,
1747
+ reason: "deployment-changed",
1748
+ activeVersionId: current.kind === "ok" ? current.versionId : null,
1749
+ rollback: "not-needed"
1750
+ };
1751
+ }
1752
+ options.onProgress?.(atomic ? "Publishing the Worker atomically..." : "Publishing the first Worker deployment...");
1689
1753
  const bootstrap = await Promise.resolve(options.runWrangler([
1690
1754
  "deploy",
1691
1755
  ...commonArgs,
@@ -1705,7 +1769,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1705
1769
  const cloudflareErrorCode = parseCloudflareErrorCode(output);
1706
1770
  return {
1707
1771
  ok: false,
1708
- reason: "bootstrap-failed",
1772
+ reason: atomic ? "atomic-failed" : "bootstrap-failed",
1709
1773
  status: bootstrap.status,
1710
1774
  ...commandDiagnosticFields(bootstrap),
1711
1775
  ...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
@@ -1719,7 +1783,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1719
1783
  const reportedOrConfiguredUrl = reportedUrl ?? (desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes));
1720
1784
  if (!versionId || !reportedOrConfiguredUrl) return {
1721
1785
  ok: false,
1722
- reason: "bootstrap-output-invalid",
1786
+ reason: atomic ? "atomic-output-invalid" : "bootstrap-output-invalid",
1723
1787
  previousVersionId,
1724
1788
  ...versionId ? { versionId } : {}
1725
1789
  };
@@ -1728,7 +1792,7 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1728
1792
  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
1793
  if (ready !== "ready") return {
1730
1794
  ok: false,
1731
- reason: ready === "access-denied" ? "bootstrap-readiness-access-denied" : "bootstrap-readiness-failed",
1795
+ reason: ready === "access-denied" ? atomic ? "atomic-readiness-access-denied" : "bootstrap-readiness-access-denied" : atomic ? "atomic-readiness-failed" : "bootstrap-readiness-failed",
1732
1796
  versionId,
1733
1797
  previousVersionId,
1734
1798
  rollback: "unavailable"
@@ -1738,6 +1802,11 @@ async function bootstrapFirstDeployment(options, commonArgs, previousVersionId)
1738
1802
  versionId,
1739
1803
  previewUrl,
1740
1804
  previousVersionId,
1805
+ ...atomic ? { trafficConfig: {
1806
+ workers_dev: desiredTriggerConfig.workers_dev,
1807
+ preview_urls: desiredTriggerConfig.preview_urls,
1808
+ routes: desiredTriggerConfig.routes
1809
+ } } : {},
1741
1810
  ...workersDevSubdomainResult(previewUrl)
1742
1811
  };
1743
1812
  }
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.4",
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.4",
24
24
  "jsonc-parser": "3.3.1"
25
25
  },
26
26
  "devDependencies": {