alchemy 0.16.0 → 0.16.2

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 (55) hide show
  1. package/lib/alchemy.d.ts.map +1 -1
  2. package/lib/alchemy.js +6 -5
  3. package/lib/alchemy.js.map +1 -1
  4. package/lib/apply.d.ts.map +1 -1
  5. package/lib/apply.js +31 -31
  6. package/lib/apply.js.map +1 -1
  7. package/lib/cloudflare/queue.d.ts.map +1 -1
  8. package/lib/cloudflare/queue.js +3 -2
  9. package/lib/cloudflare/queue.js.map +1 -1
  10. package/lib/cloudflare/r2-rest-state-store.d.ts +1 -1
  11. package/lib/cloudflare/r2-rest-state-store.d.ts.map +1 -1
  12. package/lib/cloudflare/r2-rest-state-store.js +5 -4
  13. package/lib/cloudflare/r2-rest-state-store.js.map +1 -1
  14. package/lib/cloudflare/worker.js +1 -1
  15. package/lib/cloudflare/worker.js.map +1 -1
  16. package/lib/context.d.ts +1 -1
  17. package/lib/context.d.ts.map +1 -1
  18. package/lib/context.js +6 -5
  19. package/lib/context.js.map +1 -1
  20. package/lib/destroy.d.ts.map +1 -1
  21. package/lib/destroy.js +20 -20
  22. package/lib/destroy.js.map +1 -1
  23. package/lib/fs/file-system-state-store.d.ts +1 -1
  24. package/lib/fs/file-system-state-store.d.ts.map +1 -1
  25. package/lib/fs/file-system-state-store.js +5 -3
  26. package/lib/fs/file-system-state-store.js.map +1 -1
  27. package/lib/os/exec.d.ts +46 -1
  28. package/lib/os/exec.d.ts.map +1 -1
  29. package/lib/os/exec.js +47 -1
  30. package/lib/os/exec.js.map +1 -1
  31. package/lib/resource.d.ts +15 -11
  32. package/lib/resource.d.ts.map +1 -1
  33. package/lib/resource.js +12 -7
  34. package/lib/resource.js.map +1 -1
  35. package/lib/scope.d.ts +1 -1
  36. package/lib/scope.d.ts.map +1 -1
  37. package/lib/scope.js +2 -1
  38. package/lib/scope.js.map +1 -1
  39. package/lib/state.d.ts +3 -2
  40. package/lib/state.d.ts.map +1 -1
  41. package/lib/state.js +29 -0
  42. package/lib/state.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/alchemy.ts +13 -6
  45. package/src/apply.ts +37 -30
  46. package/src/cloudflare/queue.ts +6 -3
  47. package/src/cloudflare/r2-rest-state-store.ts +5 -5
  48. package/src/cloudflare/worker.ts +1 -1
  49. package/src/context.ts +21 -13
  50. package/src/destroy.ts +34 -20
  51. package/src/fs/file-system-state-store.ts +5 -7
  52. package/src/os/exec.ts +87 -2
  53. package/src/resource.ts +22 -18
  54. package/src/scope.ts +2 -2
  55. package/src/state.ts +42 -2
package/src/alchemy.ts CHANGED
@@ -3,7 +3,14 @@ import path from "node:path";
3
3
 
4
4
  import { destroy, DestroyedSignal } from "./destroy.js";
5
5
  import { env } from "./env.js";
6
- import type { PendingResource } from "./resource.js";
6
+ import {
7
+ ResourceFQN,
8
+ ResourceID,
9
+ ResourceKind,
10
+ ResourceScope,
11
+ ResourceSeq,
12
+ type PendingResource,
13
+ } from "./resource.js";
7
14
  import { Scope } from "./scope.js";
8
15
  import { secret } from "./secret.js";
9
16
  import type { StateStoreType } from "./state.js";
@@ -325,11 +332,11 @@ async function run<T>(
325
332
  // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
326
333
  const seq = _scope.parent.seq();
327
334
  const output = {
328
- ID: id,
329
- FQN: "",
330
- Kind: Scope.KIND,
331
- Scope: _scope,
332
- Seq: seq,
335
+ [ResourceID]: id,
336
+ [ResourceFQN]: "",
337
+ [ResourceKind]: Scope.KIND,
338
+ [ResourceScope]: _scope,
339
+ [ResourceSeq]: seq,
333
340
  } as const;
334
341
  const resource = {
335
342
  kind: Scope.KIND,
package/src/apply.ts CHANGED
@@ -2,6 +2,11 @@ import { alchemy } from "./alchemy.js";
2
2
  import { context } from "./context.js";
3
3
  import {
4
4
  PROVIDERS,
5
+ ResourceFQN,
6
+ ResourceID,
7
+ ResourceKind,
8
+ ResourceScope,
9
+ ResourceSeq,
5
10
  type PendingResource,
6
11
  type Provider,
7
12
  type Resource,
@@ -20,42 +25,44 @@ export async function apply<Out extends Resource>(
20
25
  props: ResourceProps | undefined,
21
26
  options?: ApplyOptions,
22
27
  ): Promise<Awaited<Out>> {
23
- const scope = resource.Scope;
28
+ const scope = resource[ResourceScope];
24
29
  try {
25
30
  const quiet = props?.quiet ?? scope.quiet;
26
31
  await scope.init();
27
- let state: State | undefined = (await scope.state.get(resource.ID))!;
28
- const provider: Provider = PROVIDERS.get(resource.Kind);
32
+ let state: State | undefined = (await scope.state.get(
33
+ resource[ResourceID],
34
+ ))!;
35
+ const provider: Provider = PROVIDERS.get(resource[ResourceKind]);
29
36
  if (provider === undefined) {
30
- throw new Error(`Provider "${resource.Kind}" not found`);
37
+ throw new Error(`Provider "${resource[ResourceKind]}" not found`);
31
38
  }
32
39
  if (scope.phase === "read") {
33
40
  if (state === undefined) {
34
41
  throw new Error(
35
- `Resource "${resource.FQN}" not found and running in 'read' phase.`,
42
+ `Resource "${resource[ResourceFQN]}" not found and running in 'read' phase.`,
36
43
  );
37
44
  }
38
45
  return state.output as Awaited<Out>;
39
46
  }
40
47
  if (state === undefined) {
41
48
  state = {
42
- kind: resource.Kind,
43
- id: resource.ID,
44
- fqn: resource.FQN,
45
- seq: resource.Seq,
49
+ kind: resource[ResourceKind],
50
+ id: resource[ResourceID],
51
+ fqn: resource[ResourceFQN],
52
+ seq: resource[ResourceSeq],
46
53
  status: "creating",
47
54
  data: {},
48
55
  output: {
49
- ID: resource.ID,
50
- FQN: resource.FQN,
51
- Kind: resource.Kind,
52
- Scope: scope,
53
- Seq: resource.Seq,
56
+ [ResourceID]: resource[ResourceID],
57
+ [ResourceFQN]: resource[ResourceFQN],
58
+ [ResourceKind]: resource[ResourceKind],
59
+ [ResourceScope]: scope,
60
+ [ResourceSeq]: resource[ResourceSeq],
54
61
  },
55
62
  // deps: [...deps],
56
63
  props,
57
64
  };
58
- await scope.state.set(resource.ID, state);
65
+ await scope.state.set(resource[ResourceID], state);
59
66
  }
60
67
 
61
68
  const alwaysUpdate =
@@ -87,27 +94,27 @@ export async function apply<Out extends Resource>(
87
94
 
88
95
  if (!quiet) {
89
96
  console.log(
90
- `${phase === "create" ? "Create" : "Update"}: "${resource.FQN}"`,
97
+ `${phase === "create" ? "Create" : "Update"}: "${resource[ResourceFQN]}"`,
91
98
  );
92
99
  }
93
100
 
94
- await scope.state.set(resource.ID, state);
101
+ await scope.state.set(resource[ResourceID], state);
95
102
 
96
103
  let isReplaced = false;
97
104
 
98
105
  const ctx = context({
99
106
  scope,
100
107
  phase,
101
- kind: resource.Kind,
102
- id: resource.ID,
103
- fqn: resource.FQN,
104
- seq: resource.Seq,
108
+ kind: resource[ResourceKind],
109
+ id: resource[ResourceID],
110
+ fqn: resource[ResourceFQN],
111
+ seq: resource[ResourceSeq],
105
112
  props: state.oldProps,
106
113
  state,
107
114
  replace: () => {
108
115
  if (isReplaced) {
109
116
  console.warn(
110
- `Resource ${resource.Kind} ${resource.FQN} is already marked as REPLACE`,
117
+ `Resource ${resource[ResourceKind]} ${resource[ResourceFQN]} is already marked as REPLACE`,
111
118
  );
112
119
  return;
113
120
  }
@@ -116,23 +123,23 @@ export async function apply<Out extends Resource>(
116
123
  });
117
124
 
118
125
  const output = await alchemy.run(
119
- resource.ID,
126
+ resource[ResourceID],
120
127
  {
121
128
  isResource: true,
122
129
  },
123
- async () => provider.handler.bind(ctx)(resource.ID, props),
130
+ async () => provider.handler.bind(ctx)(resource[ResourceID], props),
124
131
  );
125
132
  if (!quiet) {
126
133
  console.log(
127
- `${phase === "create" ? "Created" : "Updated"}: "${resource.FQN}"`,
134
+ `${phase === "create" ? "Created" : "Updated"}: "${resource[ResourceFQN]}"`,
128
135
  );
129
136
  }
130
137
 
131
- await scope.state.set(resource.ID, {
132
- kind: resource.Kind,
133
- id: resource.ID,
134
- fqn: resource.FQN,
135
- seq: resource.Seq,
138
+ await scope.state.set(resource[ResourceID], {
139
+ kind: resource[ResourceKind],
140
+ id: resource[ResourceID],
141
+ fqn: resource[ResourceFQN],
142
+ seq: resource[ResourceSeq],
136
143
  data: state.data,
137
144
  status: phase === "create" ? "created" : "updated",
138
145
  output,
@@ -1,9 +1,9 @@
1
1
  import type { Context } from "../context.js";
2
- import { Resource } from "../resource.js";
2
+ import { Resource, ResourceKind } from "../resource.js";
3
3
  import { CloudflareApiError, handleApiError } from "./api-error.js";
4
4
  import {
5
- type CloudflareApi,
6
5
  createCloudflareApi,
6
+ type CloudflareApi,
7
7
  type CloudflareApiOptions,
8
8
  } from "./api.js";
9
9
 
@@ -59,7 +59,10 @@ export interface QueueProps extends CloudflareApiOptions {
59
59
  }
60
60
 
61
61
  export function isQueue(eventSource: any): eventSource is Queue {
62
- return "Kind" in eventSource && eventSource.Kind === "cloudflare::Queue";
62
+ return (
63
+ ResourceKind in eventSource &&
64
+ eventSource[ResourceKind] === "cloudflare::Queue"
65
+ );
63
66
  }
64
67
 
65
68
  /**
@@ -1,6 +1,7 @@
1
+ import { ResourceScope } from "../resource.js";
1
2
  import type { Scope } from "../scope.js";
2
- import { deserialize, serialize } from "../serde.js";
3
- import type { State, StateStore } from "../state.js";
3
+ import { serialize } from "../serde.js";
4
+ import { deserializeState, type State, type StateStore } from "../state.js";
4
5
  import { withExponentialBackoff } from "../util/retry.js";
5
6
  import { CloudflareApiError, handleApiError } from "./api-error.js";
6
7
  import {
@@ -211,15 +212,14 @@ export class R2RestStateStore implements StateStore {
211
212
  }
212
213
 
213
214
  // Parse and deserialize the state data
214
- const rawData = await response.json();
215
- const state = (await deserialize(this.scope, rawData)) as State;
215
+ const state = await deserializeState(this.scope, await response.text());
216
216
 
217
217
  // Create a new state object with proper output
218
218
  return {
219
219
  ...state,
220
220
  output: {
221
221
  ...(state.output || {}),
222
- Scope: this.scope,
222
+ [ResourceScope]: this.scope,
223
223
  },
224
224
  };
225
225
  } catch (error: any) {
@@ -814,7 +814,7 @@ async function prepareWorkerMetadata<B extends Bindings>(
814
814
  meta.bindings.push({
815
815
  type: "service",
816
816
  name: bindingName,
817
- service: binding.id,
817
+ service: binding.name,
818
818
  });
819
819
  } else if (binding.type === "durable_object_namespace") {
820
820
  meta.bindings.push({
package/src/context.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { DestroyedSignal } from "./destroy.js";
2
- import type {
3
- Resource,
2
+ import {
4
3
  ResourceFQN,
5
4
  ResourceID,
6
5
  ResourceKind,
7
- ResourceProps,
6
+ ResourceScope,
7
+ ResourceSeq,
8
+ type Resource,
9
+ type ResourceProps,
8
10
  } from "./resource.js";
9
11
  import type { Scope } from "./scope.js";
10
12
  import type { State } from "./state.js";
@@ -96,23 +98,29 @@ export function context<
96
98
  state: State<Kind, Props, Out>;
97
99
  replace: () => void;
98
100
  }): Context<Out> {
99
- function create(props: Omit<Out, "Kind" | "ID" | "Scope">): Out;
100
- function create(id: string, props: Omit<Out, "Kind" | "ID" | "Scope">): Out;
101
+ type InternalSymbols =
102
+ | typeof ResourceID
103
+ | typeof ResourceKind
104
+ | typeof ResourceFQN
105
+ | typeof ResourceSeq
106
+ | typeof ResourceScope;
107
+ function create(props: Omit<Out, InternalSymbols>): Out;
108
+ function create(id: string, props: Omit<Out, InternalSymbols>): Out;
101
109
  function create(
102
110
  ...args:
103
- | [props: Omit<Out, "Kind" | "ID" | "Scope">]
104
- | [id: string, props: Omit<Out, "Kind" | "ID" | "Scope">]
111
+ | [props: Omit<Out, InternalSymbols>]
112
+ | [id: string, props: Omit<Out, InternalSymbols>]
105
113
  ): Out {
106
114
  const [ID, props] =
107
115
  typeof args[0] === "string" ? (args as [string, any]) : [id, args[0]];
108
116
 
109
117
  return {
110
118
  ...props,
111
- Kind: kind,
112
- ID,
113
- FQN: fqn,
114
- Scope: scope,
115
- Seq: seq,
119
+ [ResourceKind]: kind,
120
+ [ResourceID]: ID,
121
+ [ResourceFQN]: fqn,
122
+ [ResourceScope]: scope,
123
+ [ResourceSeq]: seq,
116
124
  } as Out;
117
125
  }
118
126
  return Object.assign(create, {
@@ -138,5 +146,5 @@ export function context<
138
146
  throw new DestroyedSignal();
139
147
  },
140
148
  create,
141
- }) as Context<Out>;
149
+ }) as unknown as Context<Out>;
142
150
  }
package/src/destroy.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { alchemy } from "./alchemy.js";
2
2
  import { context } from "./context.js";
3
- import { PROVIDERS, type Provider, type Resource } from "./resource.js";
3
+ import {
4
+ PROVIDERS,
5
+ ResourceFQN,
6
+ ResourceID,
7
+ ResourceKind,
8
+ ResourceScope,
9
+ ResourceSeq,
10
+ type Provider,
11
+ type Resource,
12
+ } from "./resource.js";
4
13
  import { Scope } from "./scope.js";
5
14
 
6
15
  export class DestroyedSignal extends Error {}
@@ -52,34 +61,36 @@ export async function destroy<Type extends string>(
52
61
  return;
53
62
  }
54
63
 
55
- if (instance.Kind === Scope.KIND) {
64
+ if (instance[ResourceKind] === Scope.KIND) {
56
65
  const scope = new Scope({
57
- parent: instance.Scope,
58
- scopeName: instance.ID,
66
+ parent: instance[ResourceScope],
67
+ scopeName: instance[ResourceID],
59
68
  });
60
69
  console.log("Destroying scope", scope.chain.join("/"));
61
70
  return await destroy(scope, options);
62
71
  }
63
72
 
64
- const Provider: Provider<Type> | undefined = PROVIDERS.get(instance.Kind);
73
+ const Provider: Provider<Type> | undefined = PROVIDERS.get(
74
+ instance[ResourceKind],
75
+ );
65
76
  if (!Provider) {
66
77
  throw new Error(
67
- `Cannot destroy resource "${instance.FQN}" type ${instance.Kind} - no provider found. You may need to import the provider in your alchemy.config.ts.`,
78
+ `Cannot destroy resource "${instance[ResourceFQN]}" type ${instance[ResourceKind]} - no provider found. You may need to import the provider in your alchemy.config.ts.`,
68
79
  );
69
80
  }
70
81
 
71
- const scope = instance.Scope;
82
+ const scope = instance[ResourceScope];
72
83
  if (!scope) {
73
- console.warn(`Resource "${instance.FQN}" has no scope`);
84
+ console.warn(`Resource "${instance[ResourceFQN]}" has no scope`);
74
85
  }
75
86
  const quiet = options?.quiet ?? scope.quiet;
76
87
 
77
88
  try {
78
89
  if (!quiet) {
79
- console.log(`Delete: "${instance.FQN}"`);
90
+ console.log(`Delete: "${instance[ResourceFQN]}"`);
80
91
  }
81
92
 
82
- const state = await scope.state.get(instance.ID);
93
+ const state = await scope.state.get(instance[ResourceID]);
83
94
 
84
95
  if (state === undefined) {
85
96
  return;
@@ -88,10 +99,10 @@ export async function destroy<Type extends string>(
88
99
  const ctx = context({
89
100
  scope,
90
101
  phase: "delete",
91
- kind: instance.Kind,
92
- id: instance.ID,
93
- fqn: instance.FQN,
94
- seq: instance.Seq,
102
+ kind: instance[ResourceKind],
103
+ id: instance[ResourceID],
104
+ fqn: instance[ResourceFQN],
105
+ seq: instance[ResourceSeq],
95
106
  props: state.props,
96
107
  state,
97
108
  replace: () => {
@@ -103,15 +114,18 @@ export async function destroy<Type extends string>(
103
114
  try {
104
115
  // BUG: this does not restore persisted scope
105
116
  await alchemy.run(
106
- instance.ID,
117
+ instance[ResourceID],
107
118
  {
108
119
  // TODO(sam): this is an awful hack to differentiate between naked scopes and resources
109
- isResource: instance.Kind !== "alchemy::Scope",
120
+ isResource: instance[ResourceKind] !== "alchemy::Scope",
110
121
  parent: scope,
111
122
  },
112
123
  async (scope) => {
113
124
  nestedScope = scope;
114
- return await Provider.handler.bind(ctx)(instance.ID, state.props);
125
+ return await Provider.handler.bind(ctx)(
126
+ instance[ResourceID],
127
+ state.props,
128
+ );
115
129
  },
116
130
  );
117
131
  } catch (err) {
@@ -126,10 +140,10 @@ export async function destroy<Type extends string>(
126
140
  await destroy(nestedScope, options);
127
141
  }
128
142
 
129
- await scope.delete(instance.ID);
143
+ await scope.delete(instance[ResourceID]);
130
144
 
131
145
  if (!quiet) {
132
- console.log(`Deleted: "${instance.FQN}"`);
146
+ console.log(`Deleted: "${instance[ResourceFQN]}"`);
133
147
  }
134
148
  } catch (error) {
135
149
  console.error(error);
@@ -140,7 +154,7 @@ export async function destroy<Type extends string>(
140
154
  export namespace destroy {
141
155
  export async function all(resources: Resource[], options?: DestroyOptions) {
142
156
  if (options?.strategy !== "parallel") {
143
- const sorted = resources.sort((a, b) => b.Seq - a.Seq);
157
+ const sorted = resources.sort((a, b) => b[ResourceSeq] - a[ResourceSeq]);
144
158
  for (const resource of sorted) {
145
159
  await destroy(resource, options);
146
160
  }
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { ResourceScope } from "../resource.js";
3
4
  import type { Scope } from "../scope.js";
4
- import { deserialize, serialize } from "../serde.js";
5
- import type { State, StateStore } from "../state.js";
5
+ import { serialize } from "../serde.js";
6
+ import { deserializeState, type State, type StateStore } from "../state.js";
6
7
  import { ignore } from "../util/ignore.js";
7
8
 
8
9
  const stateRootDir = path.join(process.cwd(), ".alchemy");
@@ -50,14 +51,11 @@ export class FileSystemStateStore implements StateStore {
50
51
  async get(key: string): Promise<State | undefined> {
51
52
  try {
52
53
  const content = await fs.promises.readFile(this.getPath(key), "utf8");
53
- const state = (await deserialize(
54
- this.scope,
55
- JSON.parse(content),
56
- )) as State;
54
+ const state = await deserializeState(this.scope, content);
57
55
  if (state.output === undefined) {
58
56
  state.output = {} as any;
59
57
  }
60
- state.output.Scope = this.scope;
58
+ state.output[ResourceScope] = this.scope;
61
59
  return state;
62
60
  } catch (error: any) {
63
61
  if (error.code === "ENOENT") {
package/src/os/exec.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { spawn, type SpawnOptions } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
2
4
  import type { Context } from "../context.js";
3
5
  import { Resource } from "../resource.js";
4
6
 
@@ -14,9 +16,28 @@ export interface ExecProps {
14
16
  /**
15
17
  * Whether to memoize the command (only re-run if the command changes)
16
18
  *
19
+ * When set to `true`, the command will only be re-executed if the command string changes.
20
+ *
21
+ * When set to an object with `patterns`, the command will be re-executed if either:
22
+ * 1. The command string changes, or
23
+ * 2. The contents of any files matching the glob patterns change
24
+ *
25
+ * ⚠️ **Important Note**: When using memoization with build commands, the build outputs
26
+ * will not be produced if the command is memoized. This is because the command is not
27
+ * actually executed when memoized. Consider disabling memoization in CI environments:
28
+ *
29
+ * @example
30
+ * // Disable memoization in CI to ensure build outputs are always produced
31
+ * await Exec("build", {
32
+ * command: "vite build",
33
+ * memoize: process.env.CI ? false : {
34
+ * patterns: ["./src/**"]
35
+ * }
36
+ * });
37
+ *
17
38
  * @default false
18
39
  */
19
- memoize?: boolean;
40
+ memoize?: boolean | { patterns: string[] };
20
41
 
21
42
  /**
22
43
  * Working directory for the command
@@ -68,6 +89,11 @@ export interface Exec extends Resource<"os::Exec">, ExecProps {
68
89
  * Whether the command has completed execution
69
90
  */
70
91
  completed: boolean;
92
+
93
+ /**
94
+ * Hash of the command inputs
95
+ */
96
+ hash?: string;
71
97
  }
72
98
 
73
99
  /**
@@ -108,6 +134,26 @@ export interface Exec extends Resource<"os::Exec">, ExecProps {
108
134
  * command: "git status",
109
135
  * memoize: true
110
136
  * });
137
+ *
138
+ * @example
139
+ * // Memoize a build command with file patterns, but disable in CI
140
+ * // This ensures build outputs are always produced in CI
141
+ * const build = await Exec("build", {
142
+ * command: "vite build",
143
+ * memoize: process.env.CI ? false : {
144
+ * patterns: ["./src/**"]
145
+ * }
146
+ * });
147
+ *
148
+ * @example
149
+ * // Memoize a database migration command based on schema files
150
+ * // This is safe to memoize since it's idempotent
151
+ * const migrate = await Exec("db-migrate", {
152
+ * command: "drizzle-kit push:pg",
153
+ * memoize: {
154
+ * patterns: ["./src/db/schema/**"]
155
+ * }
156
+ * });
111
157
  */
112
158
  export const Exec = Resource(
113
159
  "os::Exec",
@@ -123,14 +169,23 @@ export const Exec = Resource(
123
169
  // Nothing to actually delete for an exec command
124
170
  return this.destroy();
125
171
  }
172
+
173
+ const hash =
174
+ typeof props.memoize === "object"
175
+ ? await hashInputs(props.cwd ?? process.cwd(), props.memoize.patterns)
176
+ : undefined;
177
+
126
178
  if (
127
179
  this.phase === "update" &&
128
180
  props.memoize &&
129
- this.output?.command === props.command
181
+ this.output?.command === props.command &&
182
+ (typeof props.memoize === "boolean" ||
183
+ (hash && this.output?.hash === hash))
130
184
  ) {
131
185
  // If memoize is enabled and the command hasn't changed, return the existing output
132
186
  return this.output;
133
187
  }
188
+
134
189
  // Default values
135
190
  let stdout = "";
136
191
  let stderr = "";
@@ -198,6 +253,7 @@ export const Exec = Resource(
198
253
  stderr,
199
254
  executedAt: Date.now(),
200
255
  completed: true,
256
+ hash,
201
257
  });
202
258
  },
203
259
  );
@@ -242,3 +298,32 @@ export async function exec(
242
298
  });
243
299
  });
244
300
  }
301
+
302
+ async function hashInputs(cwd: string, patterns: string[]) {
303
+ const { glob, readFile } = await import("node:fs/promises");
304
+
305
+ const hashes = new Map<string, string>();
306
+
307
+ await Promise.all(
308
+ patterns.flatMap(async (pattern) => {
309
+ const files = await Array.fromAsync(glob(pattern, { cwd }));
310
+ return Promise.all(
311
+ files.map(async (file: string) => {
312
+ const path = join(cwd, file);
313
+ const content = await readFile(path);
314
+ hashes.set(path, createHash("sha256").update(content).digest("hex"));
315
+ }),
316
+ );
317
+ }),
318
+ );
319
+
320
+ const sortedHashes = Array.from(hashes.entries()).sort((a, b) =>
321
+ a[0].localeCompare(b[0]),
322
+ );
323
+
324
+ const finalHash = createHash("sha256");
325
+ for (const [, hash] of sortedHashes) {
326
+ finalHash.update(hash);
327
+ }
328
+ return finalHash.digest("hex");
329
+ }
package/src/resource.ts CHANGED
@@ -5,8 +5,13 @@ import { Scope as _Scope } from "./scope.js";
5
5
  export const PROVIDERS = new Map<ResourceKind, Provider<string, any>>();
6
6
 
7
7
  export type ResourceID = string;
8
+ export const ResourceID = Symbol.for("alchemy::ResourceID");
8
9
  export type ResourceFQN = string;
10
+ export const ResourceFQN = Symbol.for("alchemy::ResourceFQN");
9
11
  export type ResourceKind = string;
12
+ export const ResourceKind = Symbol.for("alchemy::ResourceKind");
13
+ export const ResourceScope = Symbol.for("alchemy::ResourceScope");
14
+ export const ResourceSeq = Symbol.for("alchemy::ResourceSeq");
10
15
 
11
16
  export interface ProviderOptions {
12
17
  /**
@@ -37,12 +42,11 @@ export type PendingResource<
37
42
  Scope extends _Scope = _Scope,
38
43
  Seq extends number = number,
39
44
  > = Promise<Out> & {
40
- Kind: Kind;
41
- ID: ID;
42
- FQN: FQN;
43
- Seq: Seq;
44
- Scope: Scope;
45
- signal: () => void;
45
+ [ResourceKind]: Kind;
46
+ [ResourceID]: ID;
47
+ [ResourceFQN]: FQN;
48
+ [ResourceScope]: Scope;
49
+ [ResourceSeq]: Seq;
46
50
  };
47
51
 
48
52
  export interface Resource<
@@ -54,11 +58,11 @@ export interface Resource<
54
58
  Seq extends number = number,
55
59
  > {
56
60
  // use capital letters to avoid collision with conventional camelCase typescript properties
57
- Kind: Kind;
58
- ID: ID;
59
- FQN: FQN;
60
- Scope: Scope;
61
- Seq: Seq;
61
+ [ResourceKind]: Kind;
62
+ [ResourceID]: ID;
63
+ [ResourceFQN]: FQN;
64
+ [ResourceScope]: Scope;
65
+ [ResourceSeq]: Seq;
62
66
  }
63
67
 
64
68
  // helper for semantic syntax highlighting (color as a type/class instead of function/value)
@@ -113,10 +117,10 @@ export function Resource<
113
117
  // TODO(sam): do we want to throw?
114
118
  // it's kind of awesome that you can re-create a resource and call apply
115
119
  const otherResource = scope.resources.get(resourceID);
116
- if (otherResource?.Kind !== type) {
120
+ if (otherResource?.[ResourceKind] !== type) {
117
121
  scope.fail();
118
122
  throw new Error(
119
- `Resource ${resourceID} already exists in the stack and is of a different type: '${otherResource?.Kind}' !== '${type}'`,
123
+ `Resource ${resourceID} already exists in the stack and is of a different type: '${otherResource?.[ResourceKind]}' !== '${type}'`,
120
124
  );
121
125
  }
122
126
  // console.warn(
@@ -127,11 +131,11 @@ export function Resource<
127
131
  // get a sequence number (unique within the scope) for the resource
128
132
  const seq = scope.seq();
129
133
  const meta = {
130
- Kind: type,
131
- ID: resourceID,
132
- FQN: scope.fqn(resourceID),
133
- Seq: seq,
134
- Scope: scope,
134
+ [ResourceKind]: type,
135
+ [ResourceID]: resourceID,
136
+ [ResourceFQN]: scope.fqn(resourceID),
137
+ [ResourceSeq]: seq,
138
+ [ResourceScope]: scope,
135
139
  } as any as PendingResource<Out>;
136
140
  const promise = apply(meta, props, options);
137
141
  const resource = Object.assign(promise, meta);
package/src/scope.ts CHANGED
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { Phase } from "./alchemy.js";
3
3
  import { destroy } from "./destroy.js";
4
4
  import { FileSystemStateStore } from "./fs/file-system-state-store.js";
5
- import type { PendingResource, ResourceID } from "./resource.js";
5
+ import { ResourceID, type PendingResource } from "./resource.js";
6
6
  import type { StateStore, StateStoreType } from "./state.js";
7
7
 
8
8
  const scopeStorage = new AsyncLocalStorage<Scope>();
@@ -157,7 +157,7 @@ export class Scope {
157
157
  return `Scope(
158
158
  chain=${this.chain.join("/")},
159
159
  resources=[${Array.from(this.resources.values())
160
- .map((r) => r.ID)
160
+ .map((r) => r[ResourceID])
161
161
  .join(",\n ")}]
162
162
  )`;
163
163
  }