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
@@ -0,0 +1,120 @@
1
+ import { Resource } from "../resource";
2
+ import { CloudflareApi, createCloudflareApi, } from "./api";
3
+ import { CloudflareApiError, handleApiError } from "./api-error";
4
+ import { VectorizeIndex } from "./vectorize-index";
5
+ /**
6
+ * Creates and manages Cloudflare Vectorize Metadata Indexes.
7
+ *
8
+ * Vectorize Metadata Indexes enable filtering based on metadata properties when querying vectors.
9
+ * Each Vectorize Index can have up to 10 metadata indexes.
10
+ *
11
+ * @example
12
+ * // First create a Vectorize Index
13
+ * const vectorIndex = await VectorizeIndex("documents", {
14
+ * name: "documents",
15
+ * config: {
16
+ * dimensions: 768,
17
+ * metric: "cosine"
18
+ * }
19
+ * });
20
+ *
21
+ * // Then create a metadata index for the "category" property
22
+ * const categoryIndex = await VectorizeMetadataIndex("category-index", {
23
+ * index: vectorIndex,
24
+ * propertyName: "category",
25
+ * indexType: "string"
26
+ * });
27
+ *
28
+ * @example
29
+ * // Create a metadata index for a numeric property
30
+ * const yearIndex = await VectorizeMetadataIndex("year-index", {
31
+ * index: vectorIndex,
32
+ * propertyName: "year",
33
+ * indexType: "number"
34
+ * });
35
+ *
36
+ * @see https://developers.cloudflare.com/vectorize/
37
+ */
38
+ export const VectorizeMetadataIndex = Resource("cloudflare::VectorizeMetadataIndex", async function (id, props) {
39
+ const api = await createCloudflareApi(props);
40
+ const indexName = props.index.name;
41
+ const propertyName = props.propertyName;
42
+ if (this.phase === "delete") {
43
+ // Delete metadata index
44
+ try {
45
+ await deleteMetadataIndex(api, indexName, propertyName);
46
+ }
47
+ catch (error) {
48
+ if (error instanceof CloudflareApiError &&
49
+ error.status === 400 &&
50
+ error.message.includes("does not exist")) {
51
+ // Index doesn't exist, which is what we want
52
+ }
53
+ else {
54
+ throw error;
55
+ }
56
+ }
57
+ return this.destroy();
58
+ }
59
+ else if (this.phase === "update") {
60
+ // Update operation is not supported
61
+ throw new Error("Updating Vectorize metadata indexes is not supported by the Cloudflare API. " +
62
+ "To change a metadata index, delete it and create a new one with the desired configuration.");
63
+ }
64
+ else {
65
+ const indexData = await createMetadataIndex(api, indexName, props);
66
+ return this({
67
+ id: propertyName, // Use propertyName as ID
68
+ index: props.index,
69
+ propertyName: props.propertyName,
70
+ indexType: props.indexType,
71
+ accountId: api.accountId,
72
+ mutationId: indexData.result.mutationId,
73
+ });
74
+ }
75
+ });
76
+ /**
77
+ * Create a new Vectorize metadata index
78
+ */
79
+ export async function createMetadataIndex(api, indexName, props) {
80
+ // Create new metadata index
81
+ const createPayload = {
82
+ propertyName: props.propertyName,
83
+ indexType: props.indexType,
84
+ };
85
+ const createResponse = await api.post(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`, createPayload);
86
+ if (!createResponse.ok) {
87
+ return await handleApiError(createResponse, "creating", "Vectorize metadata index", props.propertyName);
88
+ }
89
+ return (await createResponse.json());
90
+ }
91
+ /**
92
+ * Delete a Vectorize metadata index
93
+ */
94
+ export async function deleteMetadataIndex(api, indexName, propertyName) {
95
+ const deleteResponse = await api.post(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/delete`, {
96
+ propertyName,
97
+ });
98
+ if (!deleteResponse.ok) {
99
+ await handleApiError(deleteResponse, "deleting", "Vectorize metadata index", propertyName);
100
+ }
101
+ }
102
+ /**
103
+ * List all metadata indexes for a Vectorize index
104
+ */
105
+ export async function listMetadataIndexes(api, indexName) {
106
+ const response = await api.get(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/list`);
107
+ if (response.status === 410) {
108
+ // Gone
109
+ return [];
110
+ }
111
+ if (!response.ok) {
112
+ throw new CloudflareApiError(`Failed to list metadata indexes: ${response.statusText}`, response);
113
+ }
114
+ const data = (await response.json());
115
+ if (!data.success) {
116
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
117
+ throw new Error(`Failed to list metadata indexes: ${errorMessage}`);
118
+ }
119
+ return data.result.metadataIndexes;
120
+ }
@@ -0,0 +1,39 @@
1
+ import { Assets } from "./assets";
2
+ import type { Bindings } from "./bindings";
3
+ import { Worker } from "./worker";
4
+ export interface ViteSiteProps<B extends Bindings> {
5
+ /**
6
+ * The command to run to build the site
7
+ */
8
+ command: string;
9
+ /**
10
+ * The name of the worker
11
+ *
12
+ * @default id
13
+ */
14
+ name?: string;
15
+ /**
16
+ * The entrypoint to your server
17
+ *
18
+ * @default - a simple server that serves static assets is generated
19
+ */
20
+ main?: string;
21
+ /**
22
+ * The directory containing your static assets
23
+ */
24
+ assets: string;
25
+ /**
26
+ * The bindings to pass to the worker
27
+ */
28
+ bindings?: B;
29
+ /**
30
+ * @default process.cwd()
31
+ */
32
+ cwd?: string;
33
+ }
34
+ export type ViteSite<B extends Bindings> = Promise<B extends {
35
+ ASSETS: any;
36
+ } ? never : Worker<B & {
37
+ ASSETS: Assets;
38
+ }>>;
39
+ export declare function ViteSite<B extends Bindings>(id: string, props: ViteSiteProps<B>): ViteSite<B>;
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { alchemy } from "../alchemy";
4
+ import { Exec } from "../os";
5
+ import { Assets } from "./assets";
6
+ import { Worker } from "./worker";
7
+ import { WranglerJson } from "./wrangler.json";
8
+ export async function ViteSite(id, props) {
9
+ if (props.bindings?.ASSETS) {
10
+ throw new Error("ASSETS binding is reserved for internal use");
11
+ }
12
+ // @ts-ignore - we know the types are correct
13
+ return await alchemy.run(id, async () => {
14
+ // Create minimal wrangler.jsonc if it doesn't exist
15
+ // `building the site requires a wrangler.jsonc file to start - so initialize an empty one if it doesn't exist`
16
+ const cwd = props.cwd || process.cwd();
17
+ const wranglerPath = path.join(cwd, "wrangler.jsonc");
18
+ try {
19
+ await fs.access(wranglerPath);
20
+ }
21
+ catch {
22
+ await fs.writeFile(wranglerPath, JSON.stringify({
23
+ name: id,
24
+ main: props.main,
25
+ compatibility_date: new Date().toISOString().split("T")[0],
26
+ }, null, 2));
27
+ }
28
+ await Exec("build", {
29
+ command: props.command,
30
+ });
31
+ const staticAssets = await Assets("assets", {
32
+ path: "./dist",
33
+ });
34
+ const worker = await Worker("worker", {
35
+ name: props.name ?? id,
36
+ entrypoint: props.main,
37
+ script: props.main
38
+ ? undefined
39
+ : `
40
+ export default {
41
+ async fetch(request, env) {
42
+ return env.ASSETS.fetch(request);
43
+ },
44
+ };`,
45
+ url: true,
46
+ adopt: true,
47
+ bindings: {
48
+ ...props.bindings,
49
+ ASSETS: staticAssets,
50
+ },
51
+ });
52
+ await WranglerJson("wrangler.jsonc", {
53
+ worker,
54
+ });
55
+ return worker;
56
+ });
57
+ }
@@ -353,6 +353,13 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
353
353
  pipeline: binding.name,
354
354
  });
355
355
  }
356
+ else if (binding.type === "vectorize") {
357
+ meta.bindings.push({
358
+ type: "vectorize",
359
+ name: bindingName,
360
+ index_name: binding.name,
361
+ });
362
+ }
356
363
  else {
357
364
  // @ts-expect-error - we should never reach here
358
365
  throw new Error(`Unsupported binding type: ${binding.type}`);
@@ -114,6 +114,13 @@ export interface WranglerJsonSpec {
114
114
  binding: string;
115
115
  class_name: string;
116
116
  }[];
117
+ /**
118
+ * Vectorize index bindings
119
+ */
120
+ vectorize_indexes?: {
121
+ binding: string;
122
+ index_name: string;
123
+ }[];
117
124
  /**
118
125
  * Plain text bindings (vars)
119
126
  */
@@ -5,7 +5,7 @@ import { Resource } from "../resource";
5
5
  */
6
6
  export const WranglerJson = Resource("cloudflare::WranglerJson", async function (id, props) {
7
7
  // Default path is wrangler.json in current directory
8
- const filePath = props.path || "wrangler.json";
8
+ const filePath = props.path || "wrangler.jsonc";
9
9
  if (this.phase === "delete") {
10
10
  return this.destroy();
11
11
  }
@@ -50,6 +50,7 @@ function processBindings(spec, bindings) {
50
50
  const workflows = [];
51
51
  const d1Databases = [];
52
52
  const queues = [];
53
+ const vectorizeIndexes = [];
53
54
  // Process each binding
54
55
  for (const [bindingName, binding] of Object.entries(bindings)) {
55
56
  if (typeof binding === "string") {
@@ -120,6 +121,12 @@ function processBindings(spec, bindings) {
120
121
  queue: binding.name,
121
122
  });
122
123
  }
124
+ else if (binding.type === "vectorize") {
125
+ vectorizeIndexes.push({
126
+ binding: bindingName,
127
+ index_name: binding.name,
128
+ });
129
+ }
123
130
  }
124
131
  // Add collected bindings to the spec
125
132
  if (kvNamespaces.length > 0) {
@@ -139,4 +146,7 @@ function processBindings(spec, bindings) {
139
146
  if (queues.length > 0) {
140
147
  spec.queues = queues;
141
148
  }
149
+ if (vectorizeIndexes.length > 0) {
150
+ spec.vectorize_indexes = vectorizeIndexes;
151
+ }
142
152
  }
package/lib/destroy.js CHANGED
@@ -33,6 +33,14 @@ export async function destroy(...args) {
33
33
  if (!instance) {
34
34
  return;
35
35
  }
36
+ if (instance.Kind === "alchemy::Scope") {
37
+ const scope = new Scope({
38
+ parent: instance.Scope,
39
+ scopeName: instance.ID,
40
+ });
41
+ console.log("Destroying scope", scope.chain.join("/"));
42
+ return await destroy(scope, options);
43
+ }
36
44
  const Provider = PROVIDERS.get(instance.Kind);
37
45
  if (!Provider) {
38
46
  throw new Error(`Cannot destroy resource "${instance.FQN}" type ${instance.Kind} - no provider found. You may need to import the provider in your alchemy.config.ts.`);
@@ -66,7 +74,11 @@ export async function destroy(...args) {
66
74
  let nestedScope;
67
75
  try {
68
76
  // BUG: this does not restore persisted scope
69
- await alchemy.run(instance.ID, async (scope) => {
77
+ await alchemy.run(instance.ID, {
78
+ // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
79
+ isResource: instance.Kind !== "alchemy::Scope",
80
+ parent: scope,
81
+ }, async (scope) => {
70
82
  nestedScope = scope;
71
83
  return await Provider.handler.bind(ctx)(instance.ID, state.props);
72
84
  });
@@ -97,7 +109,7 @@ export async function destroy(...args) {
97
109
  if (options?.strategy !== "parallel") {
98
110
  const sorted = resources.sort((a, b) => b.Seq - a.Seq);
99
111
  for (const resource of sorted) {
100
- await destroy(resource);
112
+ await destroy(resource, options);
101
113
  }
102
114
  }
103
115
  else {
@@ -3,6 +3,7 @@ import type { State, StateStore } from "../state";
3
3
  export declare class FileSystemStateStore implements StateStore {
4
4
  readonly scope: Scope;
5
5
  readonly dir: string;
6
+ private initialized;
6
7
  constructor(scope: Scope);
7
8
  init(): Promise<void>;
8
9
  deinit(): Promise<void>;
@@ -1,4 +1,4 @@
1
- import fs from "node:fs/promises";
1
+ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { deserialize, serialize } from "../serde";
4
4
  import { ignore } from "../util/ignore";
@@ -6,23 +6,27 @@ const stateRootDir = path.join(process.cwd(), ".alchemy");
6
6
  export class FileSystemStateStore {
7
7
  scope;
8
8
  dir;
9
+ initialized = false;
9
10
  constructor(scope) {
10
11
  this.scope = scope;
11
12
  this.dir = path.join(stateRootDir, ...scope.chain);
12
13
  }
13
14
  async init() {
14
- await fs.mkdir(stateRootDir, { recursive: true });
15
- await fs.mkdir(this.dir, { recursive: true });
15
+ if (this.initialized) {
16
+ return;
17
+ }
18
+ this.initialized = true;
19
+ await fs.promises.mkdir(this.dir, { recursive: true });
16
20
  }
17
21
  async deinit() {
18
- await ignore("ENOENT", () => fs.rmdir(this.dir));
22
+ await ignore("ENOENT", () => fs.promises.rmdir(this.dir));
19
23
  }
20
24
  async count() {
21
25
  return Object.keys(await this.list()).length;
22
26
  }
23
27
  async list() {
24
28
  try {
25
- const files = await fs.readdir(this.dir, {
29
+ const files = await fs.promises.readdir(this.dir, {
26
30
  withFileTypes: true,
27
31
  });
28
32
  return files
@@ -39,7 +43,7 @@ export class FileSystemStateStore {
39
43
  }
40
44
  async get(key) {
41
45
  try {
42
- const content = await fs.readFile(await this.getPath(key), "utf8");
46
+ const content = await fs.promises.readFile(this.getPath(key), "utf8");
43
47
  const state = (await deserialize(this.scope, JSON.parse(content)));
44
48
  if (state.output === undefined) {
45
49
  state.output = {};
@@ -55,10 +59,19 @@ export class FileSystemStateStore {
55
59
  }
56
60
  }
57
61
  async set(key, value) {
58
- return fs.writeFile(await this.getPath(key), JSON.stringify(await serialize(this.scope, value), null, 2));
62
+ await this.init();
63
+ await fs.promises.writeFile(this.getPath(key), JSON.stringify(await serialize(this.scope, value), null, 2));
59
64
  }
60
65
  async delete(key) {
61
- return fs.unlink(await this.getPath(key));
66
+ try {
67
+ return await fs.promises.unlink(this.getPath(key));
68
+ }
69
+ catch (error) {
70
+ if (error.code === "ENOENT") {
71
+ return;
72
+ }
73
+ throw error;
74
+ }
62
75
  }
63
76
  async all() {
64
77
  return this.getBatch(await this.list());
@@ -72,16 +85,13 @@ export class FileSystemStateStore {
72
85
  return [[id, s]];
73
86
  }))).flat());
74
87
  }
75
- async getPath(key) {
88
+ getPath(key) {
76
89
  if (key.includes(":")) {
77
90
  throw new Error(`ID cannot include colons: ${key}`);
78
91
  }
79
92
  if (key.includes("/")) {
80
93
  key = key.replaceAll("/", ":");
81
94
  }
82
- const file = path.join(this.dir, `${key}.json`);
83
- const dir = path.dirname(file);
84
- await fs.mkdir(dir, { recursive: true });
85
- return file;
95
+ return path.join(this.dir, `${key}.json`);
86
96
  }
87
97
  }
package/lib/scope.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { PendingResource, ResourceID } from "./resource";
2
2
  import type { StateStore, StateStoreType } from "./state";
3
3
  export type ScopeOptions = {
4
4
  appName?: string;
5
- stage: string;
5
+ stage?: string;
6
6
  parent?: Scope;
7
7
  scopeName?: string;
8
8
  password?: string;
package/lib/scope.js CHANGED
@@ -2,6 +2,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { destroy } from "./destroy";
3
3
  import { FileSystemStateStore } from "./fs/file-system-state-store";
4
4
  const scopeStorage = new AsyncLocalStorage();
5
+ // TODO: support browser
6
+ const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
5
7
  export class Scope {
6
8
  static get() {
7
9
  return scopeStorage.getStore();
@@ -24,14 +26,14 @@ export class Scope {
24
26
  isErrored = false;
25
27
  constructor(options) {
26
28
  this.appName = options.appName;
27
- this.stage = options.stage;
29
+ this.stage = options?.stage ?? DEFAULT_STAGE;
28
30
  this.scopeName = options.scopeName ?? null;
29
31
  this.parent = options.parent ?? Scope.get();
30
32
  this.quiet = options.quiet ?? this.parent?.quiet ?? false;
31
33
  if (this.parent && !this.scopeName) {
32
34
  throw new Error("Scope name is required when creating a child scope");
33
35
  }
34
- this.password = options.password;
36
+ this.password = options.password ?? this.parent?.password;
35
37
  this.state = options.stateStore
36
38
  ? options.stateStore(this)
37
39
  : new FileSystemStateStore(this);
@@ -65,6 +67,7 @@ export class Scope {
65
67
  await this.state.init?.();
66
68
  }
67
69
  async deinit() {
70
+ await this.parent?.state.delete(this.scopeName);
68
71
  await this.state.deinit?.();
69
72
  }
70
73
  fqn(resourceID) {
package/lib/state.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Resource, ResourceProps } from "./resource";
2
2
  import type { Scope } from "./scope";
3
- export interface State<Kind extends string = string, Props extends ResourceProps | undefined = ResourceProps | undefined, Out extends Resource = Resource> {
3
+ export type State<Kind extends string = string, Props extends ResourceProps | undefined = ResourceProps | undefined, Out extends Resource = Resource> = {
4
4
  status: `creating` | `created` | `updating` | `updated` | `deleting` | `deleted`;
5
5
  kind: Kind;
6
6
  id: string;
@@ -10,7 +10,7 @@ export interface State<Kind extends string = string, Props extends ResourceProps
10
10
  props: Props;
11
11
  oldProps?: Props;
12
12
  output: Out;
13
- }
13
+ };
14
14
  export type StateStoreType = (scope: Scope) => StateStore;
15
15
  export interface StateStore {
16
16
  /** Initialize the state container if one is required */
package/lib/test/bun.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Scope } from "../scope";
1
+ import { Scope } from "../scope";
2
2
  import type { StateStoreType } from "../state";
3
3
  /**
4
4
  * Extend the Alchemy interface to include test functionality
@@ -12,11 +12,6 @@ declare module "../alchemy" {
12
12
  * Options for configuring test behavior
13
13
  */
14
14
  export interface TestOptions {
15
- /**
16
- * Whether to automatically destroy resources after test.
17
- * @default true.
18
- */
19
- destroy?: boolean;
20
15
  /**
21
16
  * Whether to suppress logging output.
22
17
  * @default false.
package/lib/test/bun.js CHANGED
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, it } from "bun:test";
3
3
  import path from "node:path";
4
4
  import { alchemy } from "../alchemy";
5
5
  import { R2RestStateStore } from "../cloudflare";
6
- import { destroy } from "../destroy";
6
+ import { Scope } from "../scope";
7
7
  /**
8
8
  * Add test functionality to alchemy instance
9
9
  */
@@ -32,7 +32,6 @@ alchemy.test = test;
32
32
  * ```
33
33
  */
34
34
  export function test(meta, defaultOptions) {
35
- let isFailed = false;
36
35
  defaultOptions = defaultOptions ?? {};
37
36
  if (defaultOptions.stateStore === undefined &&
38
37
  // process.env.CI &&
@@ -52,65 +51,47 @@ export function test(meta, defaultOptions) {
52
51
  return test;
53
52
  };
54
53
  // Create local test scope based on filename
55
- const localTestScope = alchemy.scope(`${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`, {
54
+ const scope = new Scope({
55
+ scopeName: `${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`,
56
56
  // parent: globalTestScope,
57
57
  stateStore: defaultOptions?.stateStore,
58
58
  });
59
- test.scope = localTestScope;
60
59
  test.beforeAll = (fn) => {
61
- return beforeAll(async () => {
62
- test.scope.enter();
63
- await fn(test.scope);
64
- });
60
+ return beforeAll(() => scope.run(() => fn(scope)));
65
61
  };
66
62
  test.afterAll = (fn) => {
67
- return afterAll(async () => {
68
- test.scope.enter();
69
- await fn(test.scope);
70
- });
63
+ return afterAll(() => scope.run(() => fn(scope)));
71
64
  };
72
- // Clean up test scope after all tests complete
73
- afterAll(async () => {
74
- if (defaultOptions?.destroy !== false && !isFailed) {
75
- await alchemy.destroy(test.scope);
76
- }
77
- });
78
65
  return test;
79
66
  function test(...args) {
80
67
  const testName = args[0];
81
68
  const _options = typeof args[1] === "object" ? args[1] : undefined;
69
+ const timeout = typeof args[args.length - 1] === "number"
70
+ ? args[args.length - 1]
71
+ : 120000;
82
72
  const spread = (obj) => obj && typeof obj === "object"
83
73
  ? Object.fromEntries(Object.entries(obj).flatMap(([k, v]) => v !== undefined ? [[k, v]] : []))
84
74
  : {};
85
75
  // Merge options with defaults
86
76
  const options = {
87
- destroy: true,
88
77
  quiet: false,
89
78
  password: "test-password",
90
79
  ...spread(defaultOptions),
91
80
  ...spread(_options),
92
81
  };
93
82
  const fn = typeof args[1] === "function" ? args[1] : args[2];
94
- return alchemy.run(testName, {
83
+ return it(testName, async () => alchemy.run(testName, {
95
84
  ...options,
96
- parent: localTestScope,
85
+ parent: scope,
97
86
  }, async (scope) => {
98
- return it(testName, async () => {
87
+ try {
99
88
  // Enter test scope since bun calls from different scope
100
- scope.enter();
101
- try {
102
- await fn(scope);
103
- }
104
- catch (err) {
105
- console.error(err);
106
- throw err;
107
- }
108
- finally {
109
- if (options.destroy !== false) {
110
- await destroy(scope);
111
- }
112
- }
113
- });
114
- });
89
+ await scope.run(() => fn(scope));
90
+ }
91
+ catch (err) {
92
+ console.error(err);
93
+ throw err;
94
+ }
95
+ }), timeout);
115
96
  }
116
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "module": "./lib/index.js",
6
6
  "scripts": {