alchemy 0.8.1 → 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.
- package/lib/alchemy.d.ts +11 -18
- package/lib/alchemy.js +34 -39
- package/lib/apply.js +3 -1
- package/lib/cloudflare/index.d.ts +1 -0
- package/lib/cloudflare/index.js +1 -0
- package/lib/cloudflare/pipeline.js +0 -3
- package/lib/cloudflare/vite-site.d.ts +39 -0
- package/lib/cloudflare/vite-site.js +57 -0
- package/lib/cloudflare/wrangler.json.js +1 -1
- package/lib/destroy.js +14 -2
- package/lib/fs/file-system-state-store.d.ts +1 -0
- package/lib/fs/file-system-state-store.js +23 -13
- package/lib/scope.d.ts +1 -1
- package/lib/scope.js +5 -2
- package/lib/state.d.ts +2 -2
- package/lib/test/bun.d.ts +1 -6
- package/lib/test/bun.js +15 -37
- package/package.json +1 -1
- package/src/alchemy.ts +51 -45
- package/src/apply.ts +6 -2
- package/src/cloudflare/index.ts +1 -0
- package/src/cloudflare/pipeline.ts +0 -4
- package/src/cloudflare/vite-site.ts +111 -0
- package/src/cloudflare/wrangler.json.ts +1 -1
- package/src/destroy.ts +25 -8
- package/src/fs/file-system-state-store.ts +23 -14
- package/src/scope.ts +9 -5
- package/src/state.ts +3 -3
- package/src/test/bun.ts +22 -53
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 {
|
|
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
|
-
(
|
|
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 =
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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:
|
|
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
|
-
|
|
304
|
+
RunOptions,
|
|
328
305
|
(this: Scope, scope: Scope) => Promise<T>,
|
|
329
306
|
]);
|
|
330
|
-
const
|
|
307
|
+
const _scope = new Scope({
|
|
308
|
+
...options,
|
|
309
|
+
scopeName: id,
|
|
310
|
+
});
|
|
331
311
|
try {
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
341
|
+
console.log(error);
|
|
342
|
+
_scope.fail();
|
|
337
343
|
}
|
|
338
344
|
throw error;
|
|
339
345
|
} finally {
|
|
340
|
-
await
|
|
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(
|
|
111
|
-
|
|
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(
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -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.
|
|
58
|
+
const filePath = props.path || "wrangler.jsonc";
|
|
59
59
|
|
|
60
60
|
if (this.phase === "delete") {
|
|
61
61
|
return this.destroy();
|
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(
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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(
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|