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.
- package/lib/alchemy.d.ts.map +1 -1
- package/lib/alchemy.js +6 -5
- package/lib/alchemy.js.map +1 -1
- package/lib/apply.d.ts.map +1 -1
- package/lib/apply.js +31 -31
- package/lib/apply.js.map +1 -1
- package/lib/cloudflare/queue.d.ts.map +1 -1
- package/lib/cloudflare/queue.js +3 -2
- package/lib/cloudflare/queue.js.map +1 -1
- package/lib/cloudflare/r2-rest-state-store.d.ts +1 -1
- package/lib/cloudflare/r2-rest-state-store.d.ts.map +1 -1
- package/lib/cloudflare/r2-rest-state-store.js +5 -4
- package/lib/cloudflare/r2-rest-state-store.js.map +1 -1
- package/lib/cloudflare/worker.js +1 -1
- package/lib/cloudflare/worker.js.map +1 -1
- package/lib/context.d.ts +1 -1
- package/lib/context.d.ts.map +1 -1
- package/lib/context.js +6 -5
- package/lib/context.js.map +1 -1
- package/lib/destroy.d.ts.map +1 -1
- package/lib/destroy.js +20 -20
- package/lib/destroy.js.map +1 -1
- package/lib/fs/file-system-state-store.d.ts +1 -1
- package/lib/fs/file-system-state-store.d.ts.map +1 -1
- package/lib/fs/file-system-state-store.js +5 -3
- package/lib/fs/file-system-state-store.js.map +1 -1
- package/lib/os/exec.d.ts +46 -1
- package/lib/os/exec.d.ts.map +1 -1
- package/lib/os/exec.js +47 -1
- package/lib/os/exec.js.map +1 -1
- package/lib/resource.d.ts +15 -11
- package/lib/resource.d.ts.map +1 -1
- package/lib/resource.js +12 -7
- package/lib/resource.js.map +1 -1
- package/lib/scope.d.ts +1 -1
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +2 -1
- package/lib/scope.js.map +1 -1
- package/lib/state.d.ts +3 -2
- package/lib/state.d.ts.map +1 -1
- package/lib/state.js +29 -0
- package/lib/state.js.map +1 -1
- package/package.json +1 -1
- package/src/alchemy.ts +13 -6
- package/src/apply.ts +37 -30
- package/src/cloudflare/queue.ts +6 -3
- package/src/cloudflare/r2-rest-state-store.ts +5 -5
- package/src/cloudflare/worker.ts +1 -1
- package/src/context.ts +21 -13
- package/src/destroy.ts +34 -20
- package/src/fs/file-system-state-store.ts +5 -7
- package/src/os/exec.ts +87 -2
- package/src/resource.ts +22 -18
- package/src/scope.ts +2 -2
- 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
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
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(
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
43
|
-
id: resource
|
|
44
|
-
fqn: resource
|
|
45
|
-
seq: resource
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
97
|
+
`${phase === "create" ? "Create" : "Update"}: "${resource[ResourceFQN]}"`,
|
|
91
98
|
);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
await scope.state.set(resource
|
|
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
|
|
102
|
-
id: resource
|
|
103
|
-
fqn: resource
|
|
104
|
-
seq: resource
|
|
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
|
|
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
|
|
126
|
+
resource[ResourceID],
|
|
120
127
|
{
|
|
121
128
|
isResource: true,
|
|
122
129
|
},
|
|
123
|
-
async () => provider.handler.bind(ctx)(resource
|
|
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
|
|
134
|
+
`${phase === "create" ? "Created" : "Updated"}: "${resource[ResourceFQN]}"`,
|
|
128
135
|
);
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
await scope.state.set(resource
|
|
132
|
-
kind: resource
|
|
133
|
-
id: resource
|
|
134
|
-
fqn: resource
|
|
135
|
-
seq: resource
|
|
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,
|
package/src/cloudflare/queue.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
3
|
-
import type
|
|
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
|
|
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
|
-
|
|
222
|
+
[ResourceScope]: this.scope,
|
|
223
223
|
},
|
|
224
224
|
};
|
|
225
225
|
} catch (error: any) {
|
package/src/cloudflare/worker.ts
CHANGED
|
@@ -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.
|
|
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
|
|
3
|
-
Resource,
|
|
2
|
+
import {
|
|
4
3
|
ResourceFQN,
|
|
5
4
|
ResourceID,
|
|
6
5
|
ResourceKind,
|
|
7
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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,
|
|
104
|
-
| [id: string, props: Omit<Out,
|
|
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
|
-
|
|
112
|
-
ID,
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 {
|
|
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
|
|
64
|
+
if (instance[ResourceKind] === Scope.KIND) {
|
|
56
65
|
const scope = new Scope({
|
|
57
|
-
parent: instance
|
|
58
|
-
scopeName: instance
|
|
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(
|
|
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
|
|
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
|
|
82
|
+
const scope = instance[ResourceScope];
|
|
72
83
|
if (!scope) {
|
|
73
|
-
console.warn(`Resource "${instance
|
|
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
|
|
90
|
+
console.log(`Delete: "${instance[ResourceFQN]}"`);
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
const state = await scope.state.get(instance
|
|
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
|
|
92
|
-
id: instance
|
|
93
|
-
fqn: instance
|
|
94
|
-
seq: instance
|
|
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
|
|
117
|
+
instance[ResourceID],
|
|
107
118
|
{
|
|
108
119
|
// TODO(sam): this is an awful hack to differentiate between naked scopes and resources
|
|
109
|
-
isResource: instance
|
|
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)(
|
|
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
|
|
143
|
+
await scope.delete(instance[ResourceID]);
|
|
130
144
|
|
|
131
145
|
if (!quiet) {
|
|
132
|
-
console.log(`Deleted: "${instance
|
|
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
|
|
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 {
|
|
5
|
-
import type
|
|
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 =
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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?.
|
|
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?.
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
160
|
+
.map((r) => r[ResourceID])
|
|
161
161
|
.join(",\n ")}]
|
|
162
162
|
)`;
|
|
163
163
|
}
|