alchemy 0.78.0 → 0.80.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 (81) hide show
  1. package/bin/alchemy.js +2 -1
  2. package/lib/cloudflare/bindings.d.ts +2 -2
  3. package/lib/cloudflare/bindings.d.ts.map +1 -1
  4. package/lib/cloudflare/bindings.js.map +1 -1
  5. package/lib/cloudflare/bound.d.ts +1 -1
  6. package/lib/cloudflare/bound.d.ts.map +1 -1
  7. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  8. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  9. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  10. package/lib/cloudflare/miniflare/build-worker-options.js +10 -0
  11. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  12. package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -1
  13. package/lib/cloudflare/miniflare/miniflare-controller.js +2 -2
  14. package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -1
  15. package/lib/cloudflare/website.d.ts +4 -0
  16. package/lib/cloudflare/website.d.ts.map +1 -1
  17. package/lib/cloudflare/website.js +36 -2
  18. package/lib/cloudflare/website.js.map +1 -1
  19. package/lib/cloudflare/worker-metadata.d.ts +1 -1
  20. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  21. package/lib/cloudflare/worker-metadata.js +16 -1
  22. package/lib/cloudflare/worker-metadata.js.map +1 -1
  23. package/lib/cloudflare/worker-subdomain.d.ts +1 -0
  24. package/lib/cloudflare/worker-subdomain.d.ts.map +1 -1
  25. package/lib/cloudflare/worker-subdomain.js +10 -2
  26. package/lib/cloudflare/worker-subdomain.js.map +1 -1
  27. package/lib/cloudflare/worker.d.ts +10 -0
  28. package/lib/cloudflare/worker.d.ts.map +1 -1
  29. package/lib/cloudflare/worker.js +23 -3
  30. package/lib/cloudflare/worker.js.map +1 -1
  31. package/lib/cloudflare/workflow.d.ts +1 -0
  32. package/lib/cloudflare/workflow.d.ts.map +1 -1
  33. package/lib/cloudflare/workflow.js +6 -0
  34. package/lib/cloudflare/workflow.js.map +1 -1
  35. package/lib/cloudflare/wrangler.json.d.ts +1 -1
  36. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  37. package/lib/cloudflare/wrangler.json.js +16 -3
  38. package/lib/cloudflare/wrangler.json.js.map +1 -1
  39. package/lib/docker/api.d.ts +14 -0
  40. package/lib/docker/api.d.ts.map +1 -1
  41. package/lib/docker/api.js +18 -2
  42. package/lib/docker/api.js.map +1 -1
  43. package/lib/docker/container.d.ts +5 -0
  44. package/lib/docker/container.d.ts.map +1 -1
  45. package/lib/docker/container.js +69 -49
  46. package/lib/docker/container.js.map +1 -1
  47. package/lib/docker/volume.d.ts +5 -0
  48. package/lib/docker/volume.d.ts.map +1 -1
  49. package/lib/docker/volume.js +43 -19
  50. package/lib/docker/volume.js.map +1 -1
  51. package/lib/neon/project.d.ts +30 -16
  52. package/lib/neon/project.d.ts.map +1 -1
  53. package/lib/neon/project.js +123 -1
  54. package/lib/neon/project.js.map +1 -1
  55. package/lib/util/camel-to-snake.d.ts +1 -1
  56. package/lib/util/camel-to-snake.d.ts.map +1 -1
  57. package/lib/util/camel-to-snake.js +26 -13
  58. package/lib/util/camel-to-snake.js.map +1 -1
  59. package/lib/util/find-open-port.d.ts +1 -0
  60. package/lib/util/find-open-port.d.ts.map +1 -1
  61. package/lib/util/find-open-port.js +15 -0
  62. package/lib/util/find-open-port.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cloudflare/bindings.ts +3 -1
  65. package/src/cloudflare/bound.ts +19 -13
  66. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  67. package/src/cloudflare/miniflare/build-worker-options.ts +10 -0
  68. package/src/cloudflare/miniflare/miniflare-controller.ts +3 -2
  69. package/src/cloudflare/website.ts +43 -2
  70. package/src/cloudflare/worker-metadata.ts +19 -1
  71. package/src/cloudflare/worker-subdomain.ts +14 -2
  72. package/src/cloudflare/worker.ts +36 -2
  73. package/src/cloudflare/workflow.ts +9 -0
  74. package/src/cloudflare/wrangler.json.ts +14 -1
  75. package/src/docker/api.ts +24 -2
  76. package/src/docker/container.ts +83 -52
  77. package/src/docker/volume.ts +64 -27
  78. package/src/neon/project.ts +157 -2
  79. package/src/util/camel-to-snake.ts +34 -19
  80. package/src/util/find-open-port.ts +18 -0
  81. package/workers/tunnel-proxy.js +1 -1
@@ -43,6 +43,12 @@ export interface VolumeProps {
43
43
  * Custom metadata labels for the volume
44
44
  */
45
45
  labels?: VolumeLabel[] | Record<string, string>;
46
+
47
+ /**
48
+ * Whether to adopt the volume if it already exists
49
+ * @default false
50
+ */
51
+ adopt?: boolean;
46
52
  }
47
53
 
48
54
  /**
@@ -133,33 +139,64 @@ export const Volume = Resource(
133
139
 
134
140
  // Return destroyed state
135
141
  return this.destroy();
136
- } else {
137
- // Set default driver if not provided
138
- const driver = props.driver || "local";
139
- const driverOpts = props.driverOpts || {};
140
-
141
- // Create the volume
142
- const volumeId = await api.createVolume(
143
- volumeName,
144
- driver,
145
- driverOpts,
146
- processedLabels,
147
- );
148
-
149
- // Get volume details to retrieve mountpoint
150
- const volumeInfos = await api.inspectVolume(volumeId);
151
- const mountpoint = volumeInfos[0].Mountpoint;
152
-
153
- return {
154
- ...props,
155
- driver: driver,
156
- id: volumeId,
157
- name: volumeName,
158
- mountpoint,
159
- createdAt: Date.now(),
160
- labels: Array.isArray(props.labels) ? props.labels : undefined,
161
- driverOpts: props.driverOpts,
162
- };
163
142
  }
143
+
144
+ // Check if volume already exists
145
+ const volumeExists = await api.volumeExists(volumeName);
146
+
147
+ if (volumeExists) {
148
+ if (this.phase === "update") {
149
+ this.replace();
150
+ } else {
151
+ // Create phase - check for adoption
152
+ if (!props.adopt) {
153
+ throw new Error(
154
+ `Volume "${volumeName}" already exists. Use adopt: true to adopt it.`,
155
+ );
156
+ }
157
+
158
+ // Adopt existing volume
159
+ const volumeInfos = await api.inspectVolume(volumeName);
160
+ const volumeInfo = volumeInfos[0];
161
+
162
+ return {
163
+ ...props,
164
+ id: volumeInfo.Name,
165
+ name: volumeInfo.Name,
166
+ driver: volumeInfo.Driver,
167
+ mountpoint: volumeInfo.Mountpoint,
168
+ createdAt: new Date(volumeInfo.CreatedAt).getTime(),
169
+ labels: Array.isArray(props.labels) ? props.labels : undefined,
170
+ driverOpts: volumeInfo.Options,
171
+ };
172
+ }
173
+ }
174
+
175
+ // Set default driver if not provided
176
+ const driver = props.driver || "local";
177
+ const driverOpts = props.driverOpts || {};
178
+
179
+ // Create the volume
180
+ const volumeId = await api.createVolume(
181
+ volumeName,
182
+ driver,
183
+ driverOpts,
184
+ processedLabels,
185
+ );
186
+
187
+ // Get volume details to retrieve mountpoint
188
+ const volumeInfos = await api.inspectVolume(volumeId);
189
+ const mountpoint = volumeInfos[0].Mountpoint;
190
+
191
+ return {
192
+ ...props,
193
+ driver: driver,
194
+ id: volumeId,
195
+ name: volumeName,
196
+ mountpoint,
197
+ createdAt: Date.now(),
198
+ labels: Array.isArray(props.labels) ? props.labels : undefined,
199
+ driverOpts: props.driverOpts,
200
+ };
164
201
  },
165
202
  );
@@ -1,13 +1,15 @@
1
+ import { alchemy } from "../alchemy.ts";
1
2
  import type { Context } from "../context.ts";
2
3
  import { Resource } from "../resource.ts";
3
4
  import { createNeonApi, type NeonApiOptions } from "./api.ts";
5
+ import type { NeonClient } from "./api/sdk.gen.ts";
4
6
  import type * as neon from "./api/types.gen.ts";
5
7
  import {
6
8
  formatConnectionUri,
7
9
  formatRole,
8
- waitForOperations,
9
10
  type NeonConnectionUri,
10
11
  type NeonRole,
12
+ waitForOperations,
11
13
  } from "./utils.ts";
12
14
 
13
15
  /**
@@ -32,6 +34,18 @@ export type NeonPgVersion = 14 | 15 | 16 | 17 | 18;
32
34
  * Properties for creating or updating a Neon project
33
35
  */
34
36
  export interface NeonProjectProps extends NeonApiOptions {
37
+ /**
38
+ * When `true`, will adopt an existing project by `name`
39
+ */
40
+ adopt?: true;
41
+
42
+ /**
43
+ * Whether to delete the database when the resource is destroyed.
44
+ * When false, the database will only be removed from the state but not deleted via API.
45
+ * @default true, unless the resource was adopted
46
+ */
47
+ delete?: boolean;
48
+
35
49
  /**
36
50
  * Name of the project
37
51
  *
@@ -165,6 +179,13 @@ export interface NeonProject {
165
179
  * });
166
180
  *
167
181
  * @example
182
+ * // Adopt an existing Neon project by name:
183
+ * const project = await NeonProject("my-project", {
184
+ * adopt: true
185
+ * name: "adjective-noun-123",
186
+ * });
187
+ *
188
+ * @example
168
189
  * // Create a Neon project in a specific region with a specific PostgreSQL version:
169
190
  * const euProject = await NeonProject("my-eu-project", {
170
191
  * name: "My EU Project",
@@ -193,6 +214,17 @@ export const NeonProject = Resource(
193
214
 
194
215
  switch (this.phase) {
195
216
  case "create": {
217
+ if (props.adopt) {
218
+ try {
219
+ return await fetchProject(api, { ...props, name });
220
+ } catch (error) {
221
+ if (!(error instanceof NeonProjectNotFound)) {
222
+ throw error;
223
+ }
224
+ // project not found, continue with creation
225
+ }
226
+ }
227
+
196
228
  const { data } = await api.createProject({
197
229
  body: {
198
230
  project: {
@@ -276,8 +308,9 @@ export const NeonProject = Resource(
276
308
  history_retention_seconds: data.project.history_retention_seconds,
277
309
  };
278
310
  }
311
+
279
312
  case "delete": {
280
- if (this.output?.id) {
313
+ if (props.delete !== false && this.output?.id) {
281
314
  const response = await api.deleteProject({
282
315
  path: {
283
316
  project_id: this.output.id,
@@ -298,3 +331,125 @@ export const NeonProject = Resource(
298
331
  }
299
332
  },
300
333
  );
334
+
335
+ /**
336
+ * References an existing Neon project by name. You can use this to reference an existing NeonProject without adopting it.
337
+ *
338
+ * @example
339
+ * const project = await NeonProjectRef({
340
+ * name: "my-project"
341
+ * });
342
+ *
343
+ * @warning Project names must be unique when referencing existing projects. If multiple projects are found with the same name, an error will be thrown.
344
+ */
345
+ export async function NeonProjectRef(
346
+ props: NeonProjectProps & { name: string },
347
+ ): Promise<NeonProject> {
348
+ return await fetchProject(createNeonApi(props), props);
349
+ }
350
+
351
+ async function fetchProject(
352
+ api: NeonClient,
353
+ props: NeonProjectProps & { name: string },
354
+ ): Promise<NeonProject> {
355
+ const project = await api
356
+ .listProjects({
357
+ query: { search: props.name },
358
+ })
359
+ .then(({ data: { projects } }) => {
360
+ if (!projects.length) {
361
+ throw new NeonProjectNotFound(props.name);
362
+ }
363
+ if (projects.length > 1) {
364
+ throw new Error(
365
+ `Multiple projects found with name "${props.name}". Name must be unique when referencing existing projects.`,
366
+ );
367
+ }
368
+ return projects[0];
369
+ });
370
+ const endpointsPromise = api
371
+ .listProjectEndpoints({
372
+ path: { project_id: project.id },
373
+ })
374
+ .then((res) => res.data.endpoints as [neon.Endpoint, ...neon.Endpoint[]]);
375
+ const branch = await api
376
+ .listProjectBranches({
377
+ path: { project_id: project.id },
378
+ query: { search: props.default_branch_name ?? "main" },
379
+ })
380
+ .then((res) => {
381
+ if (!res.data.branches.length) {
382
+ throw new Error(
383
+ `Branch ${props.default_branch_name ?? "main"} does not exist in Neon project "${props.name}" (${project.id}).`,
384
+ );
385
+ }
386
+ return res.data.branches[0];
387
+ });
388
+ const rolesPromise = api
389
+ .listProjectBranchRoles({
390
+ path: { project_id: project.id, branch_id: branch.id },
391
+ })
392
+ .then((res) => res.data.roles.map(formatRole) as [NeonRole, ...NeonRole[]]);
393
+ const databases = await api
394
+ .listProjectBranchDatabases({
395
+ path: { project_id: project.id, branch_id: branch.id },
396
+ })
397
+ .then((res) => res.data.databases as [neon.Database, ...neon.Database[]]);
398
+ const [connection_uris, endpoints, roles] = await Promise.all([
399
+ Promise.all(databases.map(fetchConnectionUri)) as Promise<
400
+ [NeonConnectionUri, ...NeonConnectionUri[]]
401
+ >,
402
+ endpointsPromise,
403
+ rolesPromise,
404
+ ]);
405
+ return {
406
+ id: project.id,
407
+ name: project.name,
408
+ created_at: project.created_at,
409
+ updated_at: project.updated_at,
410
+ proxy_host: project.proxy_host,
411
+ region_id: project.region_id as NeonRegion,
412
+ pg_version: project.pg_version as NeonPgVersion,
413
+ settings: project.settings,
414
+ default_endpoint_settings: project.default_endpoint_settings,
415
+ history_retention_seconds: project.history_retention_seconds ?? 86400,
416
+ connection_uris,
417
+ roles,
418
+ databases,
419
+ branch,
420
+ endpoints,
421
+ };
422
+
423
+ async function fetchConnectionUri(
424
+ database: neon.Database,
425
+ ): Promise<NeonConnectionUri> {
426
+ const {
427
+ data: { uri },
428
+ } = await api.getConnectionUri({
429
+ path: { project_id: project.id },
430
+ query: {
431
+ branch_id: database.branch_id,
432
+ database_name: database.name,
433
+ role_name: database.owner_name,
434
+ pooled: false, // match default behavior from create endpoint (should this be true?)
435
+ },
436
+ });
437
+ const url = new URL(uri);
438
+ return {
439
+ connection_uri: alchemy.secret(uri),
440
+ connection_parameters: {
441
+ database: database.name,
442
+ host: url.host,
443
+ port: 5432,
444
+ user: url.username,
445
+ password: alchemy.secret(url.password),
446
+ },
447
+ };
448
+ }
449
+ }
450
+
451
+ class NeonProjectNotFound extends Error {
452
+ constructor(name: string) {
453
+ super(`Failed to find existing project "${name}".`);
454
+ }
455
+ }
@@ -2,26 +2,41 @@ export interface LogPushProps {
2
2
  maxFooBar: string;
3
3
  }
4
4
 
5
- export function camelToSnakeObjectDeep<T extends object | undefined>(
5
+ export function camelToSnakeObjectDeep<T>(
6
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>;
7
+ ): T extends undefined ? undefined : T extends object ? CamelToSnake<T> : T {
8
+ if (obj === undefined || obj === null) {
9
+ return obj as any;
10
+ }
11
+
12
+ // If it's not an object (primitive types), return as-is
13
+ if (typeof obj !== "object") {
14
+ return obj as any;
15
+ }
16
+
17
+ if (obj instanceof RegExp) {
18
+ return obj as any;
19
+ }
20
+
21
+ // Handle arrays
22
+ if (Array.isArray(obj)) {
23
+ return obj.map(camelToSnakeObjectDeep) as any;
24
+ }
25
+
26
+ // Handle objects
27
+ return Object.fromEntries(
28
+ Object.entries(obj).map(([key, value]) => [
29
+ key
30
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") // Handle consecutive capitals: "FOOBar" -> "FOO_Bar"
31
+ .replace(/([a-z])([A-Z])/g, "$1_$2") // Handle normal camelCase: "fooBar" -> "foo_Bar"
32
+ .toLowerCase(),
33
+ Array.isArray(value)
34
+ ? value.map(camelToSnakeObjectDeep)
35
+ : typeof value === "object" && value !== null
36
+ ? camelToSnakeObjectDeep(value)
37
+ : value,
38
+ ]),
39
+ ) as any;
25
40
  }
26
41
 
27
42
  // Helper to check if a character is uppercase
@@ -1,4 +1,22 @@
1
1
  import net from "node:net";
2
+ import { AsyncMutex } from "./mutex.ts";
3
+
4
+ const owners = new Map<string, number>();
5
+ const ports = new Map<number, string>();
6
+
7
+ const mutex = new AsyncMutex();
8
+
9
+ export async function reservePort(key: string) {
10
+ if (owners.has(key)) {
11
+ return owners.get(key)!;
12
+ }
13
+ return mutex.lock(async () => {
14
+ const port = await findOpenPort();
15
+ owners.set(key, port);
16
+ ports.set(port, key);
17
+ return port;
18
+ });
19
+ }
2
20
 
3
21
  export async function findOpenPort(min = 1337, max = 65535): Promise<number> {
4
22
  for (let port = min; port <= max; port++) {
@@ -83,7 +83,7 @@ const renderErrorHtml = (props) => `
83
83
  Alchemy</a>.</p>
84
84
  </div>
85
85
  <div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
86
- <p class="text-sm text-slate-500">Alchemy 0.78.0</p>
86
+ <p class="text-sm text-slate-500">Alchemy 0.80.0</p>
87
87
  </div>
88
88
  </div>
89
89
  </body>