alchemy 0.8.0 → 0.9.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 (44) hide show
  1. package/lib/alchemy.d.ts +11 -18
  2. package/lib/alchemy.js +34 -39
  3. package/lib/apply.js +3 -1
  4. package/lib/cloudflare/api-error.js +1 -1
  5. package/lib/cloudflare/bindings.d.ts +2 -1
  6. package/lib/cloudflare/bound.d.ts +2 -1
  7. package/lib/cloudflare/index.d.ts +3 -0
  8. package/lib/cloudflare/index.js +3 -0
  9. package/lib/cloudflare/pipeline.js +0 -3
  10. package/lib/cloudflare/vectorize-index.d.ts +136 -0
  11. package/lib/cloudflare/vectorize-index.js +170 -0
  12. package/lib/cloudflare/vectorize-metadata-index.d.ts +95 -0
  13. package/lib/cloudflare/vectorize-metadata-index.js +120 -0
  14. package/lib/cloudflare/vite-site.d.ts +39 -0
  15. package/lib/cloudflare/vite-site.js +57 -0
  16. package/lib/cloudflare/worker.js +7 -0
  17. package/lib/cloudflare/wrangler.json.d.ts +7 -0
  18. package/lib/cloudflare/wrangler.json.js +11 -1
  19. package/lib/destroy.js +14 -2
  20. package/lib/fs/file-system-state-store.d.ts +1 -0
  21. package/lib/fs/file-system-state-store.js +23 -13
  22. package/lib/scope.d.ts +1 -1
  23. package/lib/scope.js +5 -2
  24. package/lib/state.d.ts +2 -2
  25. package/lib/test/bun.d.ts +1 -6
  26. package/lib/test/bun.js +18 -37
  27. package/package.json +1 -1
  28. package/src/alchemy.ts +51 -45
  29. package/src/apply.ts +6 -2
  30. package/src/cloudflare/api-error.ts +1 -1
  31. package/src/cloudflare/bindings.ts +2 -0
  32. package/src/cloudflare/bound.ts +8 -5
  33. package/src/cloudflare/index.ts +3 -0
  34. package/src/cloudflare/pipeline.ts +0 -4
  35. package/src/cloudflare/vectorize-index.ts +331 -0
  36. package/src/cloudflare/vectorize-metadata-index.ts +239 -0
  37. package/src/cloudflare/vite-site.ts +111 -0
  38. package/src/cloudflare/worker.ts +6 -0
  39. package/src/cloudflare/wrangler.json.ts +19 -1
  40. package/src/destroy.ts +25 -8
  41. package/src/fs/file-system-state-store.ts +23 -14
  42. package/src/scope.ts +9 -5
  43. package/src/state.ts +3 -3
  44. package/src/test/bun.ts +31 -54
package/src/alchemy.ts CHANGED
@@ -1,14 +1,12 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { DestroyedSignal, destroy } from "./destroy";
4
+ import { destroy, DestroyedSignal } from "./destroy";
5
+ import type { PendingResource } from "./resource";
5
6
  import { Scope } from "./scope";
6
7
  import { secret } from "./secret";
7
8
  import type { StateStoreType } from "./state";
8
9
 
9
- // TODO: support browser
10
- const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
11
-
12
10
  /**
13
11
  * Type alias for semantic highlighting of `alchemy` as a type keyword
14
12
  */
@@ -37,7 +35,6 @@ export const alchemy: Alchemy = _alchemy as any;
37
35
  * await app.finalize();
38
36
  */
39
37
  export interface Alchemy {
40
- scope: typeof scope;
41
38
  run: typeof run;
42
39
  destroy: typeof destroy;
43
40
 
@@ -63,7 +60,7 @@ export interface Alchemy {
63
60
  * password: process.env.SECRET_PASSPHRASE
64
61
  * });
65
62
  */
66
- (...parameters: Parameters<typeof scope>): Promise<Scope>;
63
+ (appName: string, options?: Omit<AlchemyOptions, "appName">): Promise<Scope>;
67
64
  /**
68
65
  * Template literal tag that supports file interpolation for documentation.
69
66
  * Automatically formats the content and appends file contents as code blocks.
@@ -95,11 +92,12 @@ async function _alchemy(
95
92
  ): Promise<Scope | string | never> {
96
93
  if (typeof args[0] === "string") {
97
94
  const [appName, options] = args as [string, AlchemyOptions?];
98
- const root = scope(undefined, {
95
+ const root = new Scope({
99
96
  ...options,
100
97
  appName,
101
98
  stage: options?.stage,
102
99
  });
100
+ root.enter();
103
101
  if (options?.phase === "destroy") {
104
102
  await destroy(root);
105
103
  return process.exit(0);
@@ -215,7 +213,6 @@ async function _alchemy(
215
213
  }
216
214
  _alchemy.destroy = destroy;
217
215
  _alchemy.run = run;
218
- _alchemy.scope = scope;
219
216
  _alchemy.secret = secret;
220
217
  _alchemy.env = env;
221
218
 
@@ -256,7 +253,6 @@ export interface AlchemyOptions {
256
253
  * @default false
257
254
  */
258
255
  quiet?: boolean;
259
-
260
256
  /**
261
257
  * A passphrase to use to encrypt/decrypt secrets.
262
258
  * Required if using alchemy.secret() in this scope.
@@ -264,35 +260,16 @@ export interface AlchemyOptions {
264
260
  password?: string;
265
261
  }
266
262
 
267
- /**
268
- * Enter a new scope synchronously.
269
- *
270
- * @example
271
- * // Create a scope with a password for secret handling
272
- * await using scope = alchemy.scope("my-scope", {
273
- * password: process.env.SECRET_PASSPHRASE
274
- * });
275
- *
276
- * // Use secrets within the scope
277
- * const resource = await Resource("my-resource", {
278
- * apiKey: alchemy.secret(process.env.API_KEY)
279
- * });
280
- */
281
- function scope(
282
- id: string | undefined,
283
- options?: AlchemyOptions
284
- // TODO: maybe we want to allow using _ = await alchemy.scope(import.meta)
285
- // | [meta: ImportMeta]
286
- ): Scope {
287
- const scope = new Scope({
288
- ...options,
289
- appName: options?.appName,
290
- stage: options?.stage ?? DEFAULT_STAGE,
291
- scopeName: id,
292
- parent: options?.parent ?? Scope.get(),
293
- });
294
- scope.enter();
295
- return scope;
263
+ export interface ScopeOptions extends AlchemyOptions {
264
+ enter: boolean;
265
+ }
266
+
267
+ export interface RunOptions extends AlchemyOptions {
268
+ /**
269
+ * @default false
270
+ */
271
+ // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
272
+ isResource?: boolean;
296
273
  }
297
274
 
298
275
  /**
@@ -315,7 +292,7 @@ async function run<T>(
315
292
  | [id: string, fn: (this: Scope, scope: Scope) => Promise<T>]
316
293
  | [
317
294
  id: string,
318
- options: AlchemyOptions,
295
+ options: RunOptions,
319
296
  fn: (this: Scope, scope: Scope) => Promise<T>,
320
297
  ]
321
298
  ): Promise<T> {
@@ -324,20 +301,49 @@ async function run<T>(
324
301
  ? [args[0], undefined, args[1]]
325
302
  : (args as [
326
303
  string,
327
- AlchemyOptions | undefined,
304
+ RunOptions,
328
305
  (this: Scope, scope: Scope) => Promise<T>,
329
306
  ]);
330
- const scope = alchemy.scope(id, options);
307
+ const _scope = new Scope({
308
+ ...options,
309
+ scopeName: id,
310
+ });
331
311
  try {
332
- return await fn.bind(scope)(scope);
312
+ if (options?.isResource !== true && _scope.parent) {
313
+ // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
314
+ const seq = _scope.parent.seq();
315
+ const output = {
316
+ ID: id,
317
+ FQN: "",
318
+ Kind: "alchemy::Scope",
319
+ Scope: _scope,
320
+ Seq: seq,
321
+ } as const;
322
+ const resource = {
323
+ kind: "scope",
324
+ id,
325
+ seq,
326
+ data: {},
327
+ fqn: "",
328
+ props: {},
329
+ status: "created",
330
+ output,
331
+ } as const;
332
+ await _scope.parent!.state.set(id, resource);
333
+ _scope.parent!.resources.set(
334
+ id,
335
+ Object.assign(Promise.resolve(resource), output) as PendingResource
336
+ );
337
+ }
338
+ return await _scope.run(async () => fn.bind(_scope)(_scope));
333
339
  } catch (error) {
334
340
  if (!(error instanceof DestroyedSignal)) {
335
- scope.fail();
336
- } else {
341
+ console.log(error);
342
+ _scope.fail();
337
343
  }
338
344
  throw error;
339
345
  } finally {
340
- await scope.finalize();
346
+ await _scope.finalize();
341
347
  }
342
348
  }
343
349
 
package/src/apply.ts CHANGED
@@ -107,8 +107,12 @@ export async function apply<Out extends Resource>(
107
107
  },
108
108
  });
109
109
 
110
- const output = await alchemy.run(resource.ID, async () =>
111
- provider.handler.bind(ctx)(resource.ID, props)
110
+ const output = await alchemy.run(
111
+ resource.ID,
112
+ {
113
+ isResource: true,
114
+ },
115
+ async () => provider.handler.bind(ctx)(resource.ID, props)
112
116
  );
113
117
  if (!quiet) {
114
118
  console.log(
@@ -49,7 +49,7 @@ export async function handleApiError(
49
49
  resourceName: string
50
50
  ): Promise<never> {
51
51
  const json: any = await response.json();
52
- const errors: { message: string }[] = json.errors || [
52
+ const errors: { message: string }[] = json?.errors || [
53
53
  { message: response.statusText },
54
54
  ];
55
55
  const errorMessage = `Error ${action} ${resourceType} '${resourceName}': ${errors[0]?.message || response.statusText}`;
@@ -11,6 +11,7 @@ import type { DurableObjectNamespace } from "./durable-object-namespace";
11
11
  import type { KVNamespace } from "./kv-namespace";
12
12
  import type { Pipeline } from "./pipeline";
13
13
  import type { Queue } from "./queue";
14
+ import type { VectorizeIndex } from "./vectorize-index";
14
15
  import type { Worker } from "./worker";
15
16
  import type { Workflow } from "./workflow";
16
17
 
@@ -31,6 +32,7 @@ export type Binding =
31
32
  | R2Bucket
32
33
  | Secret
33
34
  | string
35
+ | VectorizeIndex
34
36
  | Worker
35
37
  | Workflow;
36
38
 
@@ -8,6 +8,7 @@ import type { DurableObjectNamespace as _DurableObjectNamespace } from "./durabl
8
8
  import type { KVNamespace as _KVNamespace } from "./kv-namespace";
9
9
  import type { Pipeline as _Pipeline } from "./pipeline";
10
10
  import type { Queue as _Queue } from "./queue";
11
+ import type { VectorizeIndex as _VectorizeIndex } from "./vectorize-index";
11
12
  import type { Worker as _Worker } from "./worker";
12
13
  import type { Workflow as _Workflow } from "./workflow";
13
14
 
@@ -27,8 +28,10 @@ export type Bound<T extends Binding> = T extends _DurableObjectNamespace
27
28
  ? Workflow<P>
28
29
  : T extends _D1Database
29
30
  ? D1Database
30
- : T extends _Queue
31
- ? Queue
32
- : T extends _Pipeline<infer R>
33
- ? Pipeline<R>
34
- : Service;
31
+ : T extends _VectorizeIndex
32
+ ? VectorizeIndex
33
+ : T extends _Queue
34
+ ? Queue
35
+ : T extends _Pipeline<infer R>
36
+ ? Pipeline<R>
37
+ : Service;
@@ -13,6 +13,9 @@ export * from "./permission-groups";
13
13
  export * from "./pipeline";
14
14
  export * from "./queue";
15
15
  export * from "./r2-rest-state-store";
16
+ export * from "./vectorize-index";
17
+ export * from "./vectorize-metadata-index";
18
+ export * from "./vite-site";
16
19
  export * from "./worker";
17
20
  export { Workflow } from "./workflow";
18
21
  export * from "./wrangler.json";
@@ -318,7 +318,6 @@ export const Pipeline = Resource("cloudflare::Pipeline", async function <
318
318
  const pipelineName = props.name || id;
319
319
 
320
320
  if (this.phase === "delete") {
321
- console.log("Deleting Cloudflare Pipeline:", pipelineName);
322
321
  if (props.delete !== false) {
323
322
  // Delete Pipeline
324
323
  await deletePipeline(api, pipelineName);
@@ -330,13 +329,10 @@ export const Pipeline = Resource("cloudflare::Pipeline", async function <
330
329
  let pipelineData: CloudflarePipelineResponse;
331
330
 
332
331
  if (this.phase === "create") {
333
- console.log("Creating Cloudflare Pipeline:", pipelineName);
334
332
  pipelineData = await createPipeline(api, pipelineName, props);
335
333
  } else {
336
334
  // Update operation
337
335
  if (this.output?.id) {
338
- console.log("Updating Cloudflare Pipeline:", pipelineName);
339
-
340
336
  // Check if name is being changed, which is not allowed
341
337
  if (props.name !== this.output.name) {
342
338
  throw new Error(
@@ -0,0 +1,331 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import {
4
+ CloudflareApi,
5
+ createCloudflareApi,
6
+ type CloudflareApiOptions,
7
+ } from "./api";
8
+ import { CloudflareApiError, handleApiError } from "./api-error";
9
+
10
+ /**
11
+ * Properties for creating or updating a Vectorize Index
12
+ */
13
+ export interface VectorizeIndexProps extends CloudflareApiOptions {
14
+ /**
15
+ * Name of the index
16
+ */
17
+ name: string;
18
+
19
+ /**
20
+ * Optional description of the index
21
+ */
22
+ description?: string;
23
+
24
+ /**
25
+ * Dimensions of the vectors
26
+ */
27
+ dimensions: number;
28
+
29
+ /**
30
+ * Distance metric used for vector similarity
31
+ */
32
+ metric: "cosine" | "euclidean" | "dot_product";
33
+
34
+ /**
35
+ * Whether to delete the index if removed
36
+ * If set to false, the index will remain but the resource will be removed from state
37
+ *
38
+ * @default true
39
+ */
40
+ delete?: boolean;
41
+
42
+ /**
43
+ * Whether to adopt an existing index with the same name if it exists
44
+ * If true and an index with the same name exists, it will be adopted rather than creating a new one
45
+ *
46
+ * @default false
47
+ */
48
+ adopt?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Output returned after Vectorize Index creation/update
53
+ */
54
+ export interface VectorizeIndex
55
+ extends Resource<"cloudflare::VectorizeIndex">,
56
+ VectorizeIndexProps {
57
+ type: "vectorize";
58
+
59
+ /**
60
+ * The unique identifier for the index (same as name)
61
+ */
62
+ id: string;
63
+
64
+ /**
65
+ * Time at which the index was created
66
+ */
67
+ createdAt?: number;
68
+ }
69
+
70
+ /**
71
+ * Creates and manages Cloudflare Vectorize Indexes.
72
+ *
73
+ * Vectorize is Cloudflare's vector database that enables vector search within Cloudflare Workers.
74
+ *
75
+ * @example
76
+ * // Create a basic vector index for text embeddings
77
+ * const basicIndex = await VectorizeIndex("text-embeddings", {
78
+ * name: "text-embeddings",
79
+ * config: {
80
+ * dimensions: 768,
81
+ * metric: "cosine"
82
+ * }
83
+ * });
84
+ *
85
+ * @example
86
+ * // Create a vector index with a description
87
+ * const descIndex = await VectorizeIndex("image-embeddings", {
88
+ * name: "image-embeddings",
89
+ * description: "Vector index for image embeddings using CLIP model",
90
+ * config: {
91
+ * dimensions: 512,
92
+ * metric: "cosine"
93
+ * }
94
+ * });
95
+ *
96
+ * @example
97
+ * // Adopt an existing index if it already exists instead of failing
98
+ * const existingIndex = await VectorizeIndex("existing-index", {
99
+ * name: "existing-index",
100
+ * adopt: true,
101
+ * config: {
102
+ * dimensions: 1024,
103
+ * metric: "euclidean"
104
+ * }
105
+ * });
106
+ *
107
+ * @see https://developers.cloudflare.com/vectorize/
108
+ */
109
+ export const VectorizeIndex = Resource(
110
+ "cloudflare::VectorizeIndex",
111
+ async function (
112
+ this: Context<VectorizeIndex>,
113
+ id: string,
114
+ props: VectorizeIndexProps
115
+ ): Promise<VectorizeIndex> {
116
+ const api = await createCloudflareApi(props);
117
+ const indexName = props.name || id;
118
+
119
+ if (this.phase === "delete") {
120
+ console.log("Deleting Vectorize index:", indexName);
121
+ if (props.delete !== false) {
122
+ // Delete Vectorize index
123
+ await deleteIndex(api, indexName);
124
+ }
125
+
126
+ // Return void (a deleted index has no content)
127
+ return this.destroy();
128
+ } else {
129
+ let indexData: CloudflareVectorizeResponse;
130
+
131
+ if (this.phase === "create") {
132
+ console.log("Creating Vectorize index:", indexName);
133
+ try {
134
+ indexData = await createIndex(api, indexName, {
135
+ ...props,
136
+ name: indexName,
137
+ });
138
+ } catch (error) {
139
+ // Check if this is a "index already exists" error and adopt is enabled
140
+ if (
141
+ props.adopt &&
142
+ error instanceof CloudflareApiError &&
143
+ error.message.includes("already exists")
144
+ ) {
145
+ console.log(`Index ${indexName} already exists, adopting it`);
146
+ // Find the existing index
147
+ indexData = await getIndex(api, indexName);
148
+ } else {
149
+ // Re-throw the error if adopt is false or it's not a "index already exists" error
150
+ throw error;
151
+ }
152
+ }
153
+ } else {
154
+ // Update operation is not supported by Vectorize API
155
+ throw new Error(
156
+ "Updating Vectorize indexes is not supported by the Cloudflare API. " +
157
+ "To change an index, delete it and create a new one with the desired configuration."
158
+ );
159
+ }
160
+
161
+ return this({
162
+ type: "vectorize",
163
+ id: indexName,
164
+ name: indexName,
165
+ description: props.description,
166
+ dimensions: indexData.result.config.dimensions,
167
+ metric: indexData.result.config.metric as
168
+ | "cosine"
169
+ | "euclidean"
170
+ | "dot_product",
171
+ accountId: api.accountId,
172
+ createdAt: indexData.result.created_on
173
+ ? new Date(indexData.result.created_on).getTime()
174
+ : undefined,
175
+ });
176
+ }
177
+ }
178
+ );
179
+
180
+ interface CloudflareVectorizeResponse {
181
+ result: {
182
+ name: string;
183
+ description?: string;
184
+ created_on?: string;
185
+ config: {
186
+ dimensions: number;
187
+ metric: string;
188
+ };
189
+ };
190
+ success: boolean;
191
+ errors: Array<{ code: number; message: string }>;
192
+ messages: string[];
193
+ }
194
+
195
+ /**
196
+ * Create a new Vectorize index
197
+ */
198
+ export async function createIndex(
199
+ api: CloudflareApi,
200
+ indexName: string,
201
+ props: VectorizeIndexProps
202
+ ): Promise<CloudflareVectorizeResponse> {
203
+ // Create new Vectorize index
204
+ const createPayload: any = {
205
+ name: indexName,
206
+ config: {
207
+ dimensions: props.dimensions,
208
+ metric: props.metric,
209
+ },
210
+ };
211
+
212
+ if (props.description) {
213
+ createPayload.description = props.description;
214
+ }
215
+
216
+ const createResponse = await api.post(
217
+ `/accounts/${api.accountId}/vectorize/v2/indexes`,
218
+ createPayload
219
+ );
220
+
221
+ if (!createResponse.ok) {
222
+ return await handleApiError(
223
+ createResponse,
224
+ "creating",
225
+ "Vectorize index",
226
+ indexName
227
+ );
228
+ }
229
+
230
+ return (await createResponse.json()) as CloudflareVectorizeResponse;
231
+ }
232
+
233
+ /**
234
+ * Get a Vectorize index
235
+ */
236
+ export async function getIndex(
237
+ api: CloudflareApi,
238
+ indexName: string
239
+ ): Promise<CloudflareVectorizeResponse> {
240
+ const response = await api.get(
241
+ `/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}`
242
+ );
243
+
244
+ if (!response.ok) {
245
+ return await handleApiError(
246
+ response,
247
+ "getting",
248
+ "Vectorize index",
249
+ indexName
250
+ );
251
+ }
252
+
253
+ return (await response.json()) as CloudflareVectorizeResponse;
254
+ }
255
+
256
+ /**
257
+ * Delete a Vectorize index
258
+ */
259
+ export async function deleteIndex(
260
+ api: CloudflareApi,
261
+ indexName: string
262
+ ): Promise<void> {
263
+ // Delete Vectorize index
264
+ const deleteResponse = await api.delete(
265
+ `/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}`
266
+ );
267
+
268
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
269
+ const errorData: any = await deleteResponse.json().catch(() => ({
270
+ errors: [{ message: deleteResponse.statusText }],
271
+ }));
272
+ throw new CloudflareApiError(
273
+ `Error deleting Vectorize index '${indexName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
274
+ deleteResponse
275
+ );
276
+ }
277
+ }
278
+
279
+ /**
280
+ * List all Vectorize indexes in an account
281
+ */
282
+ export async function listIndexes(
283
+ api: CloudflareApi
284
+ ): Promise<{ name: string; description?: string }[]> {
285
+ const response = await api.get(
286
+ `/accounts/${api.accountId}/vectorize/v2/indexes`
287
+ );
288
+
289
+ if (!response.ok) {
290
+ throw new CloudflareApiError(
291
+ `Failed to list indexes: ${response.statusText}`,
292
+ response
293
+ );
294
+ }
295
+
296
+ const data = (await response.json()) as {
297
+ success: boolean;
298
+ errors?: Array<{ code: number; message: string }>;
299
+ result?: Array<{
300
+ name: string;
301
+ description?: string;
302
+ }>;
303
+ };
304
+
305
+ if (!data.success) {
306
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
307
+ throw new Error(`Failed to list indexes: ${errorMessage}`);
308
+ }
309
+
310
+ // Transform API response
311
+ return (data.result || []).map((index) => ({
312
+ name: index.name,
313
+ description: index.description,
314
+ }));
315
+ }
316
+
317
+ /**
318
+ * Update a Vectorize index
319
+ *
320
+ * Note: The Cloudflare Vectorize API does not support updating indexes.
321
+ * This function will always throw an error.
322
+ */
323
+ export async function updateIndex(
324
+ api: CloudflareApi,
325
+ indexName: string,
326
+ props: VectorizeIndexProps
327
+ ): Promise<CloudflareVectorizeResponse> {
328
+ throw new Error(
329
+ "Updating Vectorize indexes is not supported by the Cloudflare API. To change an index, delete it and create a new one."
330
+ );
331
+ }