alchemy 0.8.1 → 0.9.1

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/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(
@@ -15,6 +15,7 @@ export * from "./queue";
15
15
  export * from "./r2-rest-state-store";
16
16
  export * from "./vectorize-index";
17
17
  export * from "./vectorize-metadata-index";
18
+ export * from "./vite-site";
18
19
  export * from "./worker";
19
20
  export { Workflow } from "./workflow";
20
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,111 @@
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 type { Bindings } from "./bindings";
7
+ import { Worker } from "./worker";
8
+ import { WranglerJson } from "./wrangler.json";
9
+
10
+ export interface ViteSiteProps<B extends Bindings> {
11
+ /**
12
+ * The command to run to build the site
13
+ */
14
+ command: string;
15
+ /**
16
+ * The name of the worker
17
+ *
18
+ * @default id
19
+ */
20
+ name?: string;
21
+ /**
22
+ * The entrypoint to your server
23
+ *
24
+ * @default - a simple server that serves static assets is generated
25
+ */
26
+ main?: string;
27
+ /**
28
+ * The directory containing your static assets
29
+ */
30
+ assets: string;
31
+ /**
32
+ * The bindings to pass to the worker
33
+ */
34
+ bindings?: B;
35
+ /**
36
+ * @default process.cwd()
37
+ */
38
+ cwd?: string;
39
+ }
40
+
41
+ export type ViteSite<B extends Bindings> = Promise<
42
+ // don't allow the ASSETS to be overriden
43
+ B extends { ASSETS: any } ? never : Worker<B & { ASSETS: Assets }>
44
+ >;
45
+
46
+ export async function ViteSite<B extends Bindings>(
47
+ id: string,
48
+ props: ViteSiteProps<B>
49
+ ): ViteSite<B> {
50
+ if (props.bindings?.ASSETS) {
51
+ throw new Error("ASSETS binding is reserved for internal use");
52
+ }
53
+
54
+ // @ts-ignore - we know the types are correct
55
+ return await alchemy.run(id, async () => {
56
+ // Create minimal wrangler.jsonc if it doesn't exist
57
+
58
+ // `building the site requires a wrangler.jsonc file to start - so initialize an empty one if it doesn't exist`
59
+ const cwd = props.cwd || process.cwd();
60
+ const wranglerPath = path.join(cwd, "wrangler.jsonc");
61
+ try {
62
+ await fs.access(wranglerPath);
63
+ } catch {
64
+ await fs.writeFile(
65
+ wranglerPath,
66
+ JSON.stringify(
67
+ {
68
+ name: id,
69
+ main: props.main,
70
+ compatibility_date: new Date().toISOString().split("T")[0],
71
+ },
72
+ null,
73
+ 2
74
+ )
75
+ );
76
+ }
77
+
78
+ await Exec("build", {
79
+ command: props.command,
80
+ });
81
+
82
+ const staticAssets = await Assets("assets", {
83
+ path: "./dist",
84
+ });
85
+
86
+ const worker = await Worker("worker", {
87
+ name: props.name ?? id,
88
+ entrypoint: props.main,
89
+ script: props.main
90
+ ? undefined
91
+ : `
92
+ export default {
93
+ async fetch(request, env) {
94
+ return env.ASSETS.fetch(request);
95
+ },
96
+ };`,
97
+ url: true,
98
+ adopt: true,
99
+ bindings: {
100
+ ...props.bindings,
101
+ ASSETS: staticAssets,
102
+ },
103
+ });
104
+
105
+ await WranglerJson("wrangler.jsonc", {
106
+ worker,
107
+ });
108
+
109
+ return worker;
110
+ });
111
+ }
@@ -55,7 +55,7 @@ export const WranglerJson = Resource(
55
55
  props: WranglerJsonProps
56
56
  ): Promise<WranglerJson> {
57
57
  // Default path is wrangler.json in current directory
58
- const filePath = props.path || "wrangler.json";
58
+ const filePath = props.path || "wrangler.jsonc";
59
59
 
60
60
  if (this.phase === "delete") {
61
61
  return this.destroy();
@@ -200,6 +200,15 @@ export interface WranglerJsonSpec {
200
200
  */
201
201
  vars?: Record<string, string>;
202
202
 
203
+ /**
204
+ * D1 database bindings
205
+ */
206
+ d1_databases?: {
207
+ binding: string;
208
+ database_id: string;
209
+ database_name: string;
210
+ }[];
211
+
203
212
  /**
204
213
  * Assets bindings
205
214
  */
@@ -338,6 +347,10 @@ function processBindings(spec: WranglerJsonSpec, bindings: Bindings): void {
338
347
  spec.services = services;
339
348
  }
340
349
 
350
+ if (d1Databases.length > 0) {
351
+ spec.d1_databases = d1Databases;
352
+ }
353
+
341
354
  if (queues.length > 0) {
342
355
  spec.queues = queues;
343
356
  }
package/src/destroy.ts CHANGED
@@ -39,7 +39,7 @@ export async function destroy<Type extends string>(
39
39
  ...orphan.output,
40
40
  Scope: scope,
41
41
  })),
42
- options,
42
+ options
43
43
  );
44
44
  // finally, destroy the scope container
45
45
  await scope.deinit();
@@ -52,10 +52,19 @@ export async function destroy<Type extends string>(
52
52
  return;
53
53
  }
54
54
 
55
+ if (instance.Kind === "alchemy::Scope") {
56
+ const scope = new Scope({
57
+ parent: instance.Scope,
58
+ scopeName: instance.ID,
59
+ });
60
+ console.log("Destroying scope", scope.chain.join("/"));
61
+ return await destroy(scope, options);
62
+ }
63
+
55
64
  const Provider: Provider<Type> | undefined = PROVIDERS.get(instance.Kind);
56
65
  if (!Provider) {
57
66
  throw new Error(
58
- `Cannot destroy resource "${instance.FQN}" type ${instance.Kind} - no provider found. You may need to import the provider in your alchemy.config.ts.`,
67
+ `Cannot destroy resource "${instance.FQN}" type ${instance.Kind} - no provider found. You may need to import the provider in your alchemy.config.ts.`
59
68
  );
60
69
  }
61
70
 
@@ -93,10 +102,18 @@ export async function destroy<Type extends string>(
93
102
  let nestedScope: Scope | undefined;
94
103
  try {
95
104
  // BUG: this does not restore persisted scope
96
- await alchemy.run(instance.ID, async (scope) => {
97
- nestedScope = scope;
98
- return await Provider.handler.bind(ctx)(instance.ID, state.props);
99
- });
105
+ await alchemy.run(
106
+ instance.ID,
107
+ {
108
+ // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
109
+ isResource: instance.Kind !== "alchemy::Scope",
110
+ parent: scope,
111
+ },
112
+ async (scope) => {
113
+ nestedScope = scope;
114
+ return await Provider.handler.bind(ctx)(instance.ID, state.props);
115
+ }
116
+ );
100
117
  } catch (err) {
101
118
  if (err instanceof DestroyedSignal) {
102
119
  // TODO: should we fail if the DestroyedSignal is not thrown?
@@ -125,11 +142,11 @@ export namespace destroy {
125
142
  if (options?.strategy !== "parallel") {
126
143
  const sorted = resources.sort((a, b) => b.Seq - a.Seq);
127
144
  for (const resource of sorted) {
128
- await destroy(resource);
145
+ await destroy(resource, options);
129
146
  }
130
147
  } else {
131
148
  await Promise.all(
132
- resources.map((resource) => destroy(resource, options)),
149
+ resources.map((resource) => destroy(resource, options))
133
150
  );
134
151
  }
135
152
  }
@@ -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 type { Scope } from "../scope";
4
4
  import { deserialize, serialize } from "../serde";
@@ -9,17 +9,21 @@ const stateRootDir = path.join(process.cwd(), ".alchemy");
9
9
 
10
10
  export class FileSystemStateStore implements StateStore {
11
11
  public readonly dir: string;
12
+ private initialized = false;
12
13
  constructor(public readonly scope: Scope) {
13
14
  this.dir = path.join(stateRootDir, ...scope.chain);
14
15
  }
15
16
 
16
17
  async init(): Promise<void> {
17
- await fs.mkdir(stateRootDir, { recursive: true });
18
- await fs.mkdir(this.dir, { recursive: true });
18
+ if (this.initialized) {
19
+ return;
20
+ }
21
+ this.initialized = true;
22
+ await fs.promises.mkdir(this.dir, { recursive: true });
19
23
  }
20
24
 
21
25
  async deinit(): Promise<void> {
22
- await ignore("ENOENT", () => fs.rmdir(this.dir));
26
+ await ignore("ENOENT", () => fs.promises.rmdir(this.dir));
23
27
  }
24
28
 
25
29
  async count(): Promise<number> {
@@ -28,7 +32,7 @@ export class FileSystemStateStore implements StateStore {
28
32
 
29
33
  async list(): Promise<string[]> {
30
34
  try {
31
- const files = await fs.readdir(this.dir, {
35
+ const files = await fs.promises.readdir(this.dir, {
32
36
  withFileTypes: true,
33
37
  });
34
38
  return files
@@ -45,7 +49,7 @@ export class FileSystemStateStore implements StateStore {
45
49
 
46
50
  async get(key: string): Promise<State | undefined> {
47
51
  try {
48
- const content = await fs.readFile(await this.getPath(key), "utf8");
52
+ const content = await fs.promises.readFile(this.getPath(key), "utf8");
49
53
  const state = (await deserialize(
50
54
  this.scope,
51
55
  JSON.parse(content)
@@ -64,14 +68,22 @@ export class FileSystemStateStore implements StateStore {
64
68
  }
65
69
 
66
70
  async set(key: string, value: State): Promise<void> {
67
- return fs.writeFile(
68
- await this.getPath(key),
71
+ await this.init();
72
+ await fs.promises.writeFile(
73
+ this.getPath(key),
69
74
  JSON.stringify(await serialize(this.scope, value), null, 2)
70
75
  );
71
76
  }
72
77
 
73
78
  async delete(key: string): Promise<void> {
74
- return fs.unlink(await this.getPath(key));
79
+ try {
80
+ return await fs.promises.unlink(this.getPath(key));
81
+ } catch (error: any) {
82
+ if (error.code === "ENOENT") {
83
+ return;
84
+ }
85
+ throw error;
86
+ }
75
87
  }
76
88
 
77
89
  async all(): Promise<Record<string, State>> {
@@ -94,16 +106,13 @@ export class FileSystemStateStore implements StateStore {
94
106
  );
95
107
  }
96
108
 
97
- private async getPath(key: string): Promise<string> {
109
+ private getPath(key: string): string {
98
110
  if (key.includes(":")) {
99
111
  throw new Error(`ID cannot include colons: ${key}`);
100
112
  }
101
113
  if (key.includes("/")) {
102
114
  key = key.replaceAll("/", ":");
103
115
  }
104
- const file = path.join(this.dir, `${key}.json`);
105
- const dir = path.dirname(file);
106
- await fs.mkdir(dir, { recursive: true });
107
- return file;
116
+ return path.join(this.dir, `${key}.json`);
108
117
  }
109
118
  }
package/src/scope.ts CHANGED
@@ -8,7 +8,7 @@ const scopeStorage = new AsyncLocalStorage<Scope>();
8
8
 
9
9
  export type ScopeOptions = {
10
10
  appName?: string;
11
- stage: string;
11
+ stage?: string;
12
12
  parent?: Scope;
13
13
  scopeName?: string;
14
14
  password?: string;
@@ -16,6 +16,9 @@ export type ScopeOptions = {
16
16
  quiet?: boolean;
17
17
  };
18
18
 
19
+ // TODO: support browser
20
+ const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
21
+
19
22
  export class Scope {
20
23
  public static get(): Scope | undefined {
21
24
  return scopeStorage.getStore();
@@ -42,14 +45,14 @@ export class Scope {
42
45
 
43
46
  constructor(options: ScopeOptions) {
44
47
  this.appName = options.appName;
45
- this.stage = options.stage;
48
+ this.stage = options?.stage ?? DEFAULT_STAGE;
46
49
  this.scopeName = options.scopeName ?? null;
47
50
  this.parent = options.parent ?? Scope.get();
48
51
  this.quiet = options.quiet ?? this.parent?.quiet ?? false;
49
52
  if (this.parent && !this.scopeName) {
50
53
  throw new Error("Scope name is required when creating a child scope");
51
54
  }
52
- this.password = options.password;
55
+ this.password = options.password ?? this.parent?.password;
53
56
  this.state = options.stateStore
54
57
  ? options.stateStore(this)
55
58
  : new FileSystemStateStore(this);
@@ -90,6 +93,7 @@ export class Scope {
90
93
  }
91
94
 
92
95
  public async deinit() {
96
+ await this.parent?.state.delete(this.scopeName!);
93
97
  await this.state.deinit?.();
94
98
  }
95
99
 
@@ -111,10 +115,10 @@ export class Scope {
111
115
  const resourceIds = await this.state.list();
112
116
  const aliveIds = new Set(this.resources.keys());
113
117
  const orphanIds = Array.from(
114
- resourceIds.filter((id) => !aliveIds.has(id)),
118
+ resourceIds.filter((id) => !aliveIds.has(id))
115
119
  );
116
120
  const orphans = await Promise.all(
117
- orphanIds.map(async (id) => (await this.state.get(id))!.output),
121
+ orphanIds.map(async (id) => (await this.state.get(id))!.output)
118
122
  );
119
123
  await destroy.all(orphans, {
120
124
  quiet: this.quiet,
package/src/state.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { Resource, ResourceProps } from "./resource";
2
2
  import type { Scope } from "./scope";
3
3
 
4
- export interface State<
4
+ export type State<
5
5
  Kind extends string = string,
6
6
  Props extends ResourceProps | undefined = ResourceProps | undefined,
7
7
  Out extends Resource = Resource,
8
- > {
8
+ > = {
9
9
  status:
10
10
  | `creating`
11
11
  | `created`
@@ -22,7 +22,7 @@ export interface State<
22
22
  props: Props;
23
23
  oldProps?: Props;
24
24
  output: Out;
25
- }
25
+ };
26
26
 
27
27
  export type StateStoreType = (scope: Scope) => StateStore;
28
28