alchemy 0.70.1 → 0.71.0

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.
Files changed (122) hide show
  1. package/bin/alchemy.js +13 -15
  2. package/bin/commands/init.ts +0 -10
  3. package/lib/apply.d.ts.map +1 -1
  4. package/lib/apply.js +2 -0
  5. package/lib/apply.js.map +1 -1
  6. package/lib/clickhouse/organization.d.ts +1 -1
  7. package/lib/clickhouse/organization.js +2 -2
  8. package/lib/clickhouse/organization.js.map +1 -1
  9. package/lib/clickhouse/service.d.ts +28 -18
  10. package/lib/clickhouse/service.d.ts.map +1 -1
  11. package/lib/clickhouse/service.js +61 -9
  12. package/lib/clickhouse/service.js.map +1 -1
  13. package/lib/cloudflare/account-api-token.js +1 -1
  14. package/lib/cloudflare/account-api-token.js.map +1 -1
  15. package/lib/cloudflare/bindings.d.ts +12 -2
  16. package/lib/cloudflare/bindings.d.ts.map +1 -1
  17. package/lib/cloudflare/bindings.js.map +1 -1
  18. package/lib/cloudflare/bound.d.ts +3 -2
  19. package/lib/cloudflare/bound.d.ts.map +1 -1
  20. package/lib/cloudflare/bucket.d.ts +6 -0
  21. package/lib/cloudflare/bucket.d.ts.map +1 -1
  22. package/lib/cloudflare/bucket.js +5 -3
  23. package/lib/cloudflare/bucket.js.map +1 -1
  24. package/lib/cloudflare/bun-spa/bun-spa.d.ts +6 -1
  25. package/lib/cloudflare/bun-spa/bun-spa.d.ts.map +1 -1
  26. package/lib/cloudflare/bun-spa/bun-spa.js +29 -9
  27. package/lib/cloudflare/bun-spa/bun-spa.js.map +1 -1
  28. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  29. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  30. package/lib/cloudflare/d1-database.d.ts +1 -1
  31. package/lib/cloudflare/d1-database.d.ts.map +1 -1
  32. package/lib/cloudflare/d1-database.js +1 -1
  33. package/lib/cloudflare/d1-database.js.map +1 -1
  34. package/lib/cloudflare/index.d.ts +2 -0
  35. package/lib/cloudflare/index.d.ts.map +1 -1
  36. package/lib/cloudflare/index.js +2 -0
  37. package/lib/cloudflare/index.js.map +1 -1
  38. package/lib/cloudflare/logpush-job.d.ts +349 -0
  39. package/lib/cloudflare/logpush-job.d.ts.map +1 -0
  40. package/lib/cloudflare/logpush-job.js +277 -0
  41. package/lib/cloudflare/logpush-job.js.map +1 -0
  42. package/lib/cloudflare/miniflare/build-worker-options.d.ts +1 -0
  43. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  44. package/lib/cloudflare/miniflare/build-worker-options.js +16 -6
  45. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  46. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  47. package/lib/cloudflare/miniflare/remote-binding-proxy.js +2 -0
  48. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  49. package/lib/cloudflare/permission-groups.d.ts +1489 -26
  50. package/lib/cloudflare/permission-groups.d.ts.map +1 -1
  51. package/lib/cloudflare/permission-groups.js +1787 -6
  52. package/lib/cloudflare/permission-groups.js.map +1 -1
  53. package/lib/cloudflare/queue-consumer.js +2 -2
  54. package/lib/cloudflare/queue-consumer.js.map +1 -1
  55. package/lib/cloudflare/worker-loader.d.ts +9 -0
  56. package/lib/cloudflare/worker-loader.d.ts.map +1 -0
  57. package/lib/cloudflare/worker-loader.js +6 -0
  58. package/lib/cloudflare/worker-loader.js.map +1 -0
  59. package/lib/cloudflare/worker-metadata.d.ts +14 -2
  60. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  61. package/lib/cloudflare/worker-metadata.js +10 -1
  62. package/lib/cloudflare/worker-metadata.js.map +1 -1
  63. package/lib/cloudflare/worker-stub.d.ts +3 -1
  64. package/lib/cloudflare/worker-stub.d.ts.map +1 -1
  65. package/lib/cloudflare/worker-stub.js +19 -7
  66. package/lib/cloudflare/worker-stub.js.map +1 -1
  67. package/lib/cloudflare/worker-subdomain.js +1 -1
  68. package/lib/cloudflare/worker.d.ts +74 -10
  69. package/lib/cloudflare/worker.d.ts.map +1 -1
  70. package/lib/cloudflare/worker.js +23 -7
  71. package/lib/cloudflare/worker.js.map +1 -1
  72. package/lib/cloudflare/wrangler.json.d.ts +3 -0
  73. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  74. package/lib/cloudflare/wrangler.json.js +9 -0
  75. package/lib/cloudflare/wrangler.json.js.map +1 -1
  76. package/lib/github/comment.d.ts.map +1 -1
  77. package/lib/github/comment.js +14 -7
  78. package/lib/github/comment.js.map +1 -1
  79. package/lib/scope.d.ts.map +1 -1
  80. package/lib/scope.js +6 -7
  81. package/lib/scope.js.map +1 -1
  82. package/lib/util/camel-to-snake.d.ts +12 -0
  83. package/lib/util/camel-to-snake.d.ts.map +1 -0
  84. package/lib/util/camel-to-snake.js +16 -0
  85. package/lib/util/camel-to-snake.js.map +1 -0
  86. package/lib/util/retry.d.ts +1 -1
  87. package/lib/util/retry.d.ts.map +1 -1
  88. package/lib/util/retry.js +2 -2
  89. package/lib/util/retry.js.map +1 -1
  90. package/lib/util/validate-resource-id.d.ts +2 -0
  91. package/lib/util/validate-resource-id.d.ts.map +1 -0
  92. package/lib/util/validate-resource-id.js +11 -0
  93. package/lib/util/validate-resource-id.js.map +1 -0
  94. package/package.json +2 -2
  95. package/src/apply.ts +2 -0
  96. package/src/clickhouse/organization.ts +2 -2
  97. package/src/clickhouse/service.ts +124 -33
  98. package/src/cloudflare/account-api-token.ts +1 -1
  99. package/src/cloudflare/bindings.ts +13 -0
  100. package/src/cloudflare/bound.ts +36 -32
  101. package/src/cloudflare/bucket.ts +13 -4
  102. package/src/cloudflare/bun-spa/bun-spa.ts +42 -11
  103. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  104. package/src/cloudflare/d1-database.ts +2 -2
  105. package/src/cloudflare/index.ts +2 -0
  106. package/src/cloudflare/logpush-job.ts +768 -0
  107. package/src/cloudflare/miniflare/build-worker-options.ts +18 -6
  108. package/src/cloudflare/miniflare/remote-binding-proxy.ts +2 -0
  109. package/src/cloudflare/permission-groups.ts +1832 -45
  110. package/src/cloudflare/queue-consumer.ts +2 -2
  111. package/src/cloudflare/worker-loader.ts +12 -0
  112. package/src/cloudflare/worker-metadata.ts +25 -3
  113. package/src/cloudflare/worker-stub.ts +28 -11
  114. package/src/cloudflare/worker-subdomain.ts +1 -1
  115. package/src/cloudflare/worker.ts +106 -17
  116. package/src/cloudflare/wrangler.json.ts +15 -0
  117. package/src/github/comment.ts +17 -8
  118. package/src/scope.ts +7 -10
  119. package/src/util/camel-to-snake.ts +88 -0
  120. package/src/util/retry.ts +2 -1
  121. package/src/util/validate-resource-id.ts +10 -0
  122. package/workers/tunnel-proxy.js +1 -1
@@ -318,7 +318,7 @@ export async function createQueueConsumer(
318
318
  typeof props.settings.deadLetterQueue === "string"
319
319
  ? props.settings.deadLetterQueue
320
320
  : props.settings.deadLetterQueue.name;
321
- createPayload.settings.dead_letter_queue = dlqName;
321
+ createPayload.dead_letter_queue = dlqName;
322
322
  }
323
323
  }
324
324
 
@@ -405,7 +405,7 @@ async function updateQueueConsumer(
405
405
  typeof props.settings.deadLetterQueue === "string"
406
406
  ? props.settings.deadLetterQueue
407
407
  : props.settings.deadLetterQueue.name;
408
- updatePayload.settings.dead_letter_queue = dlqName;
408
+ updatePayload.dead_letter_queue = dlqName;
409
409
  }
410
410
  }
411
411
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A binding for dynamic worker loaders.
3
+ *
4
+ */
5
+ export type WorkerLoader = {
6
+ type: "worker_loader";
7
+ };
8
+ export function WorkerLoader(): WorkerLoader {
9
+ return {
10
+ type: "worker_loader",
11
+ };
12
+ }
@@ -1,4 +1,5 @@
1
1
  import { assertNever } from "../util/assert-never.ts";
2
+ import { camelToSnakeObjectDeep } from "../util/camel-to-snake.ts";
2
3
  import { logger } from "../util/logger.ts";
3
4
  import { memoize } from "../util/memoize.ts";
4
5
  import { extractCloudflareResult } from "./api-response.ts";
@@ -180,8 +181,20 @@ export interface WorkerMetadata {
180
181
  compatibility_date: string;
181
182
  compatibility_flags?: string[];
182
183
  bindings: WorkerBindingSpec[];
183
- observability: {
184
- enabled: boolean;
184
+ observability?: {
185
+ enabled?: boolean;
186
+ head_sampling_rate?: number;
187
+ logs?: {
188
+ enabled?: boolean;
189
+ head_sampling_rate?: number;
190
+ invocation_logs?: boolean;
191
+ };
192
+ traces?: {
193
+ enabled?: boolean;
194
+ head_sampling_rate?: number;
195
+ persist?: boolean;
196
+ destinations?: string[];
197
+ };
185
198
  };
186
199
  logpush?: boolean;
187
200
  migrations?: SingleStepMigration;
@@ -235,6 +248,7 @@ export async function prepareWorkerMetadata(
235
248
  ...(oldSettings?.tags ?? []),
236
249
  ]),
237
250
  );
251
+
238
252
  const oldBindings = oldSettings?.bindings;
239
253
  // we use Cloudflare Worker tags to store a mapping between Alchemy's stable identifier and the binding name
240
254
  // e.g.
@@ -315,6 +329,8 @@ export async function prepareWorkerMetadata(
315
329
  return [];
316
330
  });
317
331
 
332
+ const observability = camelToSnakeObjectDeep(props.observability);
333
+
318
334
  // Prepare metadata with bindings
319
335
  const meta: WorkerMetadata = {
320
336
  compatibility_date: props.compatibilityDate,
@@ -324,7 +340,8 @@ export async function prepareWorkerMetadata(
324
340
  ),
325
341
  bindings: [],
326
342
  observability: {
327
- enabled: props.observability?.enabled !== false,
343
+ ...observability,
344
+ enabled: observability?.enabled !== false,
328
345
  },
329
346
  logpush: props.logpush ?? false,
330
347
  // TODO(sam): base64 encode instead? 0 collision risk vs readability.
@@ -551,6 +568,11 @@ export async function prepareWorkerMetadata(
551
568
  name: bindingName,
552
569
  namespace: binding.namespaceName,
553
570
  });
571
+ } else if (binding.type === "worker_loader") {
572
+ meta.bindings.push({
573
+ type: "worker_loader",
574
+ name: bindingName,
575
+ });
554
576
  } else if (binding.type === "secret_key") {
555
577
  meta.bindings.push({
556
578
  type: "secret_key",
@@ -128,7 +128,7 @@ export const WorkerStub = Resource("cloudflare::WorkerStub", async function <
128
128
  } as WorkerStub<RPC>;
129
129
  });
130
130
 
131
- async function exists(
131
+ export async function exists(
132
132
  api: CloudflareApi,
133
133
  workerName: string,
134
134
  ): Promise<boolean> {
@@ -145,9 +145,10 @@ async function exists(
145
145
  }
146
146
  }
147
147
 
148
- async function createEmptyWorker(
148
+ export async function createEmptyWorker(
149
149
  api: CloudflareApi,
150
150
  workerName: string,
151
+ version?: string,
151
152
  ): Promise<void> {
152
153
  // Minimal empty worker script
153
154
  const emptyScript = `export default {
@@ -178,6 +179,12 @@ async function createEmptyWorker(
178
179
  main_module: "worker.js",
179
180
  compatibility_date: "2025-04-20",
180
181
  bindings: [],
182
+ ...(version != null && {
183
+ annotations: {
184
+ "workers/tag": version,
185
+ "workers/message": `Version ${version}`,
186
+ },
187
+ }),
181
188
  }),
182
189
  ],
183
190
  {
@@ -189,14 +196,24 @@ async function createEmptyWorker(
189
196
  // Upload worker script
190
197
  await extractCloudflareResult(
191
198
  `create empty worker "${workerName}"`,
192
- api.put(
193
- `/accounts/${api.accountId}/workers/scripts/${workerName}`,
194
- formData,
195
- {
196
- headers: {
197
- "Content-Type": "multipart/form-data",
198
- },
199
- },
200
- ),
199
+ version != null
200
+ ? api.post(
201
+ `/accounts/${api.accountId}/workers/scripts/${workerName}/versions`,
202
+ formData,
203
+ {
204
+ headers: {
205
+ "Content-Type": "multipart/form-data",
206
+ },
207
+ },
208
+ )
209
+ : api.put(
210
+ `/accounts/${api.accountId}/workers/scripts/${workerName}`,
211
+ formData,
212
+ {
213
+ headers: {
214
+ "Content-Type": "multipart/form-data",
215
+ },
216
+ },
217
+ ),
201
218
  );
202
219
  }
@@ -111,7 +111,7 @@ export async function enableWorkerSubdomain(
111
111
  ),
112
112
  ),
113
113
  (error) => error instanceof CloudflareApiError && error.status === 404,
114
- 10,
114
+ 20,
115
115
  1000,
116
116
  );
117
117
  }
@@ -61,6 +61,7 @@ import { Workflow, isWorkflow, upsertWorkflow } from "./workflow.ts";
61
61
  import "../esbuild/bundle.ts";
62
62
  import { Scope } from "../scope.ts";
63
63
  import type { WorkerRef } from "./worker-ref.ts";
64
+ import { createEmptyWorker, exists } from "./worker-stub.ts";
64
65
 
65
66
  /**
66
67
  * Configuration options for static assets
@@ -166,18 +167,12 @@ export interface BaseWorkerProps<
166
167
  url?: boolean;
167
168
 
168
169
  /**
169
- * Observability configuration for the worker
170
+ * Specify the observability behavior of the Worker.
170
171
  *
171
- * Controls whether worker logs are enabled
172
- * @default { enabled: true }
172
+ * @see https://developers.cloudflare.com/workers/wrangler/configuration/#observability
173
+ * @default - `enabled: true`
173
174
  */
174
- observability?: {
175
- /**
176
- * Whether to enable worker logs
177
- * @default true
178
- */
179
- enabled?: boolean;
180
- };
175
+ observability?: WorkerObservability;
181
176
 
182
177
  /**
183
178
  * Enable Workers Logpush to export trace events (request/response metadata,
@@ -393,6 +388,85 @@ export interface BaseWorkerProps<
393
388
  tailConsumers?: Array<Worker | { service: string }>;
394
389
  }
395
390
 
391
+ export interface WorkerObservability {
392
+ /**
393
+ * If observability is enabled for this Worker
394
+ *
395
+ * @default true
396
+ */
397
+ enabled?: boolean;
398
+
399
+ /**
400
+ * A number between 0 and 1, where 0 indicates zero out of one hundred requests are logged, and 1 indicates every request is logged.
401
+ * If head_sampling_rate is unspecified, it is configured to a default value of 1 (100%).
402
+ * @see https://developers.cloudflare.com/workers/observability/logs/workers-logs/#head-based-sampling
403
+ * @default 1
404
+ */
405
+ headSamplingRate?: number;
406
+
407
+ /**
408
+ * Configuration for worker logs
409
+ */
410
+ logs?: {
411
+ /**
412
+ * Whether logs are enabled
413
+ * @default true
414
+ */
415
+ enabled?: boolean;
416
+
417
+ /**
418
+ * The sampling rate for logs
419
+ */
420
+ headSamplingRate?: number;
421
+
422
+ /**
423
+ * Set to false to disable invocation logs
424
+ * @default true
425
+ */
426
+ invocationLogs?: boolean;
427
+
428
+ /**
429
+ * If logs should be persisted to the Cloudflare observability platform where they can be queried in the dashboard.
430
+ * @default true
431
+ */
432
+ persist?: boolean;
433
+
434
+ /**
435
+ * What destinations logs emitted from the Worker should be sent to.
436
+ * @default []
437
+ */
438
+ destinations?: string[];
439
+ };
440
+
441
+ /**
442
+ * Configuration for worker traces
443
+ */
444
+ traces?: {
445
+ /**
446
+ * Whether traces are enabled
447
+ * @default true
448
+ */
449
+ enabled?: boolean;
450
+
451
+ /**
452
+ * The sampling rate for traces
453
+ */
454
+ headSamplingRate?: number;
455
+
456
+ /**
457
+ * If traces should be persisted to the Cloudflare observability platform where they can be queried in the dashboard.
458
+ * @default true
459
+ */
460
+ persist?: boolean;
461
+
462
+ /**
463
+ * What destinations traces emitted from the Worker should be sent to.
464
+ * @default []
465
+ */
466
+ destinations?: string[];
467
+ };
468
+ }
469
+
396
470
  export interface InlineWorkerProps<
397
471
  B extends Bindings | undefined = Bindings,
398
472
  RPC extends Rpc.WorkerEntrypointBranded = Rpc.WorkerEntrypointBranded,
@@ -863,13 +937,20 @@ const _Worker = Resource(
863
937
  durableObjects: options.durableObjects,
864
938
  workflows: options.workflows,
865
939
  });
866
- if (!props.version && this.output?.dev?.hasRemote !== false) {
940
+ if (this.output?.dev?.hasRemote !== false) {
867
941
  const api = await createCloudflareApi(props);
868
- await deleteQueueConsumers(api, options.name);
869
- await deleteWorker(api, {
870
- scriptName: options.name,
871
- dispatchNamespace: options.dispatchNamespace,
872
- });
942
+ if (props.version) {
943
+ //* if the worker exists we deploy an empty version so we can destroy
944
+ if (await exists(api, options.name)) {
945
+ await createEmptyWorker(api, options.name, props.version);
946
+ }
947
+ } else {
948
+ await deleteQueueConsumers(api, options.name);
949
+ await deleteWorker(api, {
950
+ scriptName: options.name,
951
+ dispatchNamespace: options.dispatchNamespace,
952
+ });
953
+ }
873
954
  }
874
955
  return this.destroy();
875
956
  }
@@ -901,9 +982,11 @@ const _Worker = Resource(
901
982
  bundle,
902
983
  port: props.dev?.port,
903
984
  tunnel: props.dev?.tunnel ?? this.scope.tunnel,
985
+ cwd: props.cwd ?? process.cwd(),
904
986
  });
905
987
  this.onCleanup(() => controller.dispose());
906
988
  }
989
+
907
990
  await provisionResources(
908
991
  {
909
992
  ...props,
@@ -1159,12 +1242,18 @@ async function provisionResources<B extends Bindings>(
1159
1242
  containers: options.containers,
1160
1243
  domains: props.domains?.map((domain) => {
1161
1244
  if (typeof domain === "string") {
1245
+ if (domain === "") {
1246
+ throw new Error("Domain names cannot be empty strings");
1247
+ }
1162
1248
  return {
1163
1249
  name: domain,
1164
1250
  zoneId: undefined,
1165
1251
  adopt: props.adopt,
1166
1252
  };
1167
1253
  }
1254
+ if (domain.domainName === "") {
1255
+ throw new Error("Domain names cannot be empty strings");
1256
+ }
1168
1257
  return {
1169
1258
  name: domain.domainName,
1170
1259
  zoneId: domain.zoneId,
@@ -1607,7 +1696,7 @@ async function assertWorkerDoesNotExist(
1607
1696
  );
1608
1697
  }
1609
1698
 
1610
- async function getScriptMetadata(
1699
+ export async function getScriptMetadata(
1611
1700
  api: CloudflareApi,
1612
1701
  scriptName: string,
1613
1702
  ): Promise<WorkerScriptMetadata | undefined> {
@@ -376,6 +376,10 @@ export interface WranglerJsonSpec {
376
376
  entrypoint?: string;
377
377
  }[];
378
378
 
379
+ worker_loaders?: {
380
+ binding: string;
381
+ }[];
382
+
379
383
  /**
380
384
  * Workflow bindings
381
385
  */
@@ -627,6 +631,9 @@ function processBindings(
627
631
  const containers: {
628
632
  class_name: string;
629
633
  }[] = [];
634
+ const workerLoaders: {
635
+ binding: string;
636
+ }[] = [];
630
637
 
631
638
  for (const eventSource of eventSources ?? []) {
632
639
  if (isQueueEventSource(eventSource)) {
@@ -850,6 +857,10 @@ function processBindings(
850
857
  containers.push({
851
858
  class_name: binding.className,
852
859
  });
860
+ } else if (binding.type === "worker_loader") {
861
+ workerLoaders.push({
862
+ binding: bindingName,
863
+ });
853
864
  } else {
854
865
  console.log("binding", binding);
855
866
  // biome-ignore lint/correctness/noVoidTypeReturn: it returns never
@@ -930,4 +941,8 @@ function processBindings(
930
941
  bindings: unsafeBindings,
931
942
  };
932
943
  }
944
+
945
+ if (workerLoaders.length > 0) {
946
+ spec.worker_loaders = workerLoaders;
947
+ }
933
948
  }
@@ -173,17 +173,17 @@ export const GitHubComment = Resource(
173
173
  props: GitHubCommentProps,
174
174
  ): Promise<GitHubComment> {
175
175
  // Create authenticated Octokit client - will automatically handle token resolution
176
- const octokit = await createGitHubClient({
177
- token: props.token?.unencrypted,
178
- });
179
-
180
- // Verify authentication and permissions
181
- if (!this.quiet) {
182
- await verifyGitHubAuth(octokit, props.owner, props.repository);
183
- }
184
176
 
185
177
  if (this.phase === "delete") {
186
178
  if (this.output?.commentId && props.allowDelete) {
179
+ const octokit = await createGitHubClient({
180
+ token: props.token?.unencrypted,
181
+ });
182
+
183
+ // Verify authentication and permissions
184
+ if (!this.quiet) {
185
+ await verifyGitHubAuth(octokit, props.owner, props.repository);
186
+ }
187
187
  try {
188
188
  // Delete the comment
189
189
  await octokit.rest.issues.deleteComment({
@@ -209,6 +209,15 @@ export const GitHubComment = Resource(
209
209
  return this.destroy();
210
210
  }
211
211
 
212
+ const octokit = await createGitHubClient({
213
+ token: props.token?.unencrypted,
214
+ });
215
+
216
+ // Verify authentication and permissions
217
+ if (!this.quiet) {
218
+ await verifyGitHubAuth(octokit, props.owner, props.repository);
219
+ }
220
+
212
221
  try {
213
222
  if (this.phase === "update" && this.output?.commentId) {
214
223
  // Update existing comment
package/src/scope.ts CHANGED
@@ -30,6 +30,7 @@ import { logger } from "./util/logger.ts";
30
30
  import { AsyncMutex } from "./util/mutex.ts";
31
31
  import { ALCHEMY_ROOT } from "./util/root-dir.ts";
32
32
  import { createAndSendEvent } from "./util/telemetry.ts";
33
+ import { validateResourceID } from "./util/validate-resource-id.ts";
33
34
 
34
35
  export class RootScopeStateAttemptError extends Error {
35
36
  constructor() {
@@ -265,21 +266,17 @@ export class Scope {
265
266
  // Store provider credentials (TypeScript ensures no conflicts with core options)
266
267
  this.providerCredentials = providerCredentials as ProviderCredentials;
267
268
 
268
- const isChild = this.parent !== undefined;
269
- if (this.scopeName?.includes(":") && isChild) {
270
- // TODO(sam): relax this constraint once we move to SQLite3 store
271
- throw new Error(
272
- `Scope name "${this.scopeName}" cannot contain double colons`,
273
- );
274
- }
275
-
276
269
  this.stage = stage ?? this.parent?.stage ?? DEFAULT_STAGE;
277
270
  this.profile = profile ?? this.parent?.profile;
278
271
  this.parent?.children.set(this.scopeName!, this);
279
272
  this.quiet = quiet ?? this.parent?.quiet ?? false;
280
- if (this.parent && !this.scopeName) {
281
- throw new Error("Scope name is required when creating a child scope");
273
+ if (this.parent) {
274
+ if (!this.scopeName) {
275
+ throw new Error("Scope name is required when creating a child scope");
276
+ }
277
+ validateResourceID(this.scopeName, "Scope");
282
278
  }
279
+
283
280
  this.password = password ?? this.parent?.password;
284
281
  this.noTrack = noTrack ?? this.parent?.noTrack ?? false;
285
282
  const resolvedPhase = phase ?? this.parent?.phase;
@@ -0,0 +1,88 @@
1
+ export interface LogPushProps {
2
+ maxFooBar: string;
3
+ }
4
+
5
+ export function camelToSnakeObjectDeep<T extends object | undefined>(
6
+ obj: T,
7
+ ): T extends undefined ? undefined : CamelToSnake<T> {
8
+ return (
9
+ obj
10
+ ? Object.fromEntries(
11
+ Object.entries(obj).map(([key, value]) => [
12
+ key
13
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") // Handle consecutive capitals: "FOOBar" -> "FOO_Bar"
14
+ .replace(/([a-z])([A-Z])/g, "$1_$2") // Handle normal camelCase: "fooBar" -> "foo_Bar"
15
+ .toLowerCase(),
16
+ Array.isArray(value)
17
+ ? value.map(camelToSnakeObjectDeep)
18
+ : typeof value === "object" && value !== null
19
+ ? camelToSnakeObjectDeep(value)
20
+ : value,
21
+ ]),
22
+ )
23
+ : undefined
24
+ ) as T extends undefined ? undefined : CamelToSnake<T>;
25
+ }
26
+
27
+ // Helper to check if a character is uppercase
28
+ type IsUpper<C extends string> = C extends Uppercase<C>
29
+ ? C extends Lowercase<C>
30
+ ? false // Not a letter
31
+ : true // Uppercase letter
32
+ : false;
33
+
34
+ // Helper to check if a character is lowercase
35
+ type IsLower<C extends string> = C extends Lowercase<C>
36
+ ? C extends Uppercase<C>
37
+ ? false // Not a letter
38
+ : true // Lowercase letter
39
+ : false;
40
+
41
+ // Convert a camelCase string to snake_case at the type level
42
+ type CamelToSnakeString<
43
+ S extends string,
44
+ Acc extends string = "",
45
+ > = S extends `${infer First}${infer Second}${infer Rest}`
46
+ ? IsUpper<First> extends true
47
+ ? IsUpper<Second> extends true
48
+ ? // FOO... pattern - check if next char is lowercase
49
+ Rest extends `${infer Third}${infer _}`
50
+ ? IsLower<Third> extends true
51
+ ? // FOOBar -> add First, add _, add Second lowercase, continue with Rest
52
+ CamelToSnakeString<
53
+ Rest,
54
+ `${Acc}${Lowercase<First>}_${Lowercase<Second>}`
55
+ >
56
+ : // FOO -> continue
57
+ CamelToSnakeString<`${Second}${Rest}`, `${Acc}${Lowercase<First>}`>
58
+ : // Only two chars left, both upper
59
+ CamelToSnakeString<`${Second}${Rest}`, `${Acc}${Lowercase<First>}`>
60
+ : // FooBar or Foo - add underscore before uppercase if not at start
61
+ CamelToSnakeString<
62
+ `${Second}${Rest}`,
63
+ Acc extends "" ? `${Lowercase<First>}` : `${Acc}_${Lowercase<First>}`
64
+ >
65
+ : IsLower<First> extends true
66
+ ? IsUpper<Second> extends true
67
+ ? // fooBar -> just add the lowercase char, underscore will be added when processing uppercase
68
+ CamelToSnakeString<`${Second}${Rest}`, `${Acc}${First}`>
69
+ : // foobar -> continue
70
+ CamelToSnakeString<`${Second}${Rest}`, `${Acc}${First}`>
71
+ : // Number or special char
72
+ CamelToSnakeString<`${Second}${Rest}`, `${Acc}${First}`>
73
+ : S extends `${infer Last}`
74
+ ? `${Acc}${Lowercase<Last>}`
75
+ : Acc;
76
+
77
+ // Deep transformation for objects
78
+ type CamelToSnake<T> = T extends object
79
+ ? T extends Array<infer U>
80
+ ? Array<CamelToSnake<U>>
81
+ : T extends Date | RegExp | Function
82
+ ? T // Don't transform special object types
83
+ : {
84
+ [K in keyof T as K extends string
85
+ ? CamelToSnakeString<K>
86
+ : K]: CamelToSnake<T[K]>;
87
+ }
88
+ : T;
package/src/util/retry.ts CHANGED
@@ -14,6 +14,7 @@ export async function withExponentialBackoff<T>(
14
14
  isRetryable: (error: any) => any,
15
15
  maxAttempts = 5,
16
16
  initialDelayMs = 100,
17
+ maxDelayMs = 10000,
17
18
  ): Promise<T> {
18
19
  let attempt = 0;
19
20
  let delay = initialDelayMs;
@@ -31,7 +32,7 @@ export async function withExponentialBackoff<T>(
31
32
  const jitter = Math.random() * 0.1 * delay;
32
33
  await new Promise((resolve) => setTimeout(resolve, delay + jitter));
33
34
  delay *= 2; // Double the delay for next attempt
34
- delay = Math.min(delay, 10000); // Cap at 10 seconds
35
+ delay = Math.min(delay, maxDelayMs); // Cap at 10 seconds
35
36
  }
36
37
  }
37
38
  }
@@ -0,0 +1,10 @@
1
+ export function validateResourceID(id: string, qualifier = "Resource"): void {
2
+ //todo(sam): during effect rewrite, this should be much more limiting
3
+ //todo: its hard to undo the flexibility we've offered to users now
4
+ if (!id) {
5
+ throw new Error(`${qualifier} ID cannot be an empty string: "${id}"`);
6
+ }
7
+ if (id.includes(":")) {
8
+ throw new Error(`${qualifier} ID cannot include colons: "${id}"`);
9
+ }
10
+ }
@@ -34,7 +34,7 @@ var h={async fetch(e,r){let n=new URL(e.url);n.host=r.TUNNEL_HOST;let l=new Head
34
34
  Alchemy</a>.</p>
35
35
  </div>
36
36
  <div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
37
- <p class="text-sm text-slate-500">Alchemy 0.70.1</p>
37
+ <p class="text-sm text-slate-500">Alchemy 0.71.0</p>
38
38
  </div>
39
39
  </div>
40
40
  </body>