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/lib/alchemy.d.ts
CHANGED
|
@@ -28,7 +28,6 @@ export declare const alchemy: Alchemy;
|
|
|
28
28
|
* await app.finalize();
|
|
29
29
|
*/
|
|
30
30
|
export interface Alchemy {
|
|
31
|
-
scope: typeof scope;
|
|
32
31
|
run: typeof run;
|
|
33
32
|
destroy: typeof destroy;
|
|
34
33
|
/**
|
|
@@ -52,7 +51,7 @@ export interface Alchemy {
|
|
|
52
51
|
* password: process.env.SECRET_PASSPHRASE
|
|
53
52
|
* });
|
|
54
53
|
*/
|
|
55
|
-
(
|
|
54
|
+
(appName: string, options?: Omit<AlchemyOptions, "appName">): Promise<Scope>;
|
|
56
55
|
/**
|
|
57
56
|
* Template literal tag that supports file interpolation for documentation.
|
|
58
57
|
* Automatically formats the content and appends file contents as code blocks.
|
|
@@ -115,21 +114,15 @@ export interface AlchemyOptions {
|
|
|
115
114
|
*/
|
|
116
115
|
password?: string;
|
|
117
116
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
* // Use secrets within the scope
|
|
128
|
-
* const resource = await Resource("my-resource", {
|
|
129
|
-
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
130
|
-
* });
|
|
131
|
-
*/
|
|
132
|
-
declare function scope(id: string | undefined, options?: AlchemyOptions): Scope;
|
|
117
|
+
export interface ScopeOptions extends AlchemyOptions {
|
|
118
|
+
enter: boolean;
|
|
119
|
+
}
|
|
120
|
+
export interface RunOptions extends AlchemyOptions {
|
|
121
|
+
/**
|
|
122
|
+
* @default false
|
|
123
|
+
*/
|
|
124
|
+
isResource?: boolean;
|
|
125
|
+
}
|
|
133
126
|
/**
|
|
134
127
|
* Run a function in a new scope asynchronously.
|
|
135
128
|
* Useful for isolating secret handling with a specific password.
|
|
@@ -147,7 +140,7 @@ declare function scope(id: string | undefined, options?: AlchemyOptions): Scope;
|
|
|
147
140
|
*/
|
|
148
141
|
declare function run<T>(...args: [id: string, fn: (this: Scope, scope: Scope) => Promise<T>] | [
|
|
149
142
|
id: string,
|
|
150
|
-
options:
|
|
143
|
+
options: RunOptions,
|
|
151
144
|
fn: (this: Scope, scope: Scope) => Promise<T>
|
|
152
145
|
]): Promise<T>;
|
|
153
146
|
export declare function env<T = string>(name: string, value?: T | undefined, error?: string): Promise<T>;
|
package/lib/alchemy.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { destroy, DestroyedSignal } from "./destroy";
|
|
4
4
|
import { Scope } from "./scope";
|
|
5
5
|
import { secret } from "./secret";
|
|
6
|
-
// TODO: support browser
|
|
7
|
-
const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
|
|
8
6
|
export const alchemy = _alchemy;
|
|
9
7
|
/**
|
|
10
8
|
* Implementation of the alchemy function that handles both application scoping
|
|
@@ -13,11 +11,12 @@ export const alchemy = _alchemy;
|
|
|
13
11
|
async function _alchemy(...args) {
|
|
14
12
|
if (typeof args[0] === "string") {
|
|
15
13
|
const [appName, options] = args;
|
|
16
|
-
const root =
|
|
14
|
+
const root = new Scope({
|
|
17
15
|
...options,
|
|
18
16
|
appName,
|
|
19
17
|
stage: options?.stage,
|
|
20
18
|
});
|
|
19
|
+
root.enter();
|
|
21
20
|
if (options?.phase === "destroy") {
|
|
22
21
|
await destroy(root);
|
|
23
22
|
return process.exit(0);
|
|
@@ -119,37 +118,8 @@ async function _alchemy(...args) {
|
|
|
119
118
|
}
|
|
120
119
|
_alchemy.destroy = destroy;
|
|
121
120
|
_alchemy.run = run;
|
|
122
|
-
_alchemy.scope = scope;
|
|
123
121
|
_alchemy.secret = secret;
|
|
124
122
|
_alchemy.env = env;
|
|
125
|
-
/**
|
|
126
|
-
* Enter a new scope synchronously.
|
|
127
|
-
*
|
|
128
|
-
* @example
|
|
129
|
-
* // Create a scope with a password for secret handling
|
|
130
|
-
* await using scope = alchemy.scope("my-scope", {
|
|
131
|
-
* password: process.env.SECRET_PASSPHRASE
|
|
132
|
-
* });
|
|
133
|
-
*
|
|
134
|
-
* // Use secrets within the scope
|
|
135
|
-
* const resource = await Resource("my-resource", {
|
|
136
|
-
* apiKey: alchemy.secret(process.env.API_KEY)
|
|
137
|
-
* });
|
|
138
|
-
*/
|
|
139
|
-
function scope(id, options
|
|
140
|
-
// TODO: maybe we want to allow using _ = await alchemy.scope(import.meta)
|
|
141
|
-
// | [meta: ImportMeta]
|
|
142
|
-
) {
|
|
143
|
-
const scope = new Scope({
|
|
144
|
-
...options,
|
|
145
|
-
appName: options?.appName,
|
|
146
|
-
stage: options?.stage ?? DEFAULT_STAGE,
|
|
147
|
-
scopeName: id,
|
|
148
|
-
parent: options?.parent ?? Scope.get(),
|
|
149
|
-
});
|
|
150
|
-
scope.enter();
|
|
151
|
-
return scope;
|
|
152
|
-
}
|
|
153
123
|
/**
|
|
154
124
|
* Run a function in a new scope asynchronously.
|
|
155
125
|
* Useful for isolating secret handling with a specific password.
|
|
@@ -169,20 +139,45 @@ async function run(...args) {
|
|
|
169
139
|
const [id, options, fn] = typeof args[1] === "function"
|
|
170
140
|
? [args[0], undefined, args[1]]
|
|
171
141
|
: args;
|
|
172
|
-
const
|
|
142
|
+
const _scope = new Scope({
|
|
143
|
+
...options,
|
|
144
|
+
scopeName: id,
|
|
145
|
+
});
|
|
173
146
|
try {
|
|
174
|
-
|
|
147
|
+
if (options?.isResource !== true && _scope.parent) {
|
|
148
|
+
// TODO(sam): this is an awful hack to differentiate between naked scopes and resources
|
|
149
|
+
const seq = _scope.parent.seq();
|
|
150
|
+
const output = {
|
|
151
|
+
ID: id,
|
|
152
|
+
FQN: "",
|
|
153
|
+
Kind: "alchemy::Scope",
|
|
154
|
+
Scope: _scope,
|
|
155
|
+
Seq: seq,
|
|
156
|
+
};
|
|
157
|
+
const resource = {
|
|
158
|
+
kind: "scope",
|
|
159
|
+
id,
|
|
160
|
+
seq,
|
|
161
|
+
data: {},
|
|
162
|
+
fqn: "",
|
|
163
|
+
props: {},
|
|
164
|
+
status: "created",
|
|
165
|
+
output,
|
|
166
|
+
};
|
|
167
|
+
await _scope.parent.state.set(id, resource);
|
|
168
|
+
_scope.parent.resources.set(id, Object.assign(Promise.resolve(resource), output));
|
|
169
|
+
}
|
|
170
|
+
return await _scope.run(async () => fn.bind(_scope)(_scope));
|
|
175
171
|
}
|
|
176
172
|
catch (error) {
|
|
177
173
|
if (!(error instanceof DestroyedSignal)) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
else {
|
|
174
|
+
console.log(error);
|
|
175
|
+
_scope.fail();
|
|
181
176
|
}
|
|
182
177
|
throw error;
|
|
183
178
|
}
|
|
184
179
|
finally {
|
|
185
|
-
await
|
|
180
|
+
await _scope.finalize();
|
|
186
181
|
}
|
|
187
182
|
}
|
|
188
183
|
export async function env(name, value, error) {
|
package/lib/apply.js
CHANGED
|
@@ -75,7 +75,9 @@ export async function apply(resource, props, options) {
|
|
|
75
75
|
isReplaced = true;
|
|
76
76
|
},
|
|
77
77
|
});
|
|
78
|
-
const output = await alchemy.run(resource.ID,
|
|
78
|
+
const output = await alchemy.run(resource.ID, {
|
|
79
|
+
isResource: true,
|
|
80
|
+
}, async () => provider.handler.bind(ctx)(resource.ID, props));
|
|
79
81
|
if (!quiet) {
|
|
80
82
|
console.log(`${phase === "create" ? "Created" : "Updated"}: "${resource.FQN}"`);
|
|
81
83
|
}
|
|
@@ -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";
|
package/lib/cloudflare/index.js
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";
|
|
@@ -71,7 +71,6 @@ export const Pipeline = Resource("cloudflare::Pipeline", async function (id, pro
|
|
|
71
71
|
const api = await createCloudflareApi(props);
|
|
72
72
|
const pipelineName = props.name || id;
|
|
73
73
|
if (this.phase === "delete") {
|
|
74
|
-
console.log("Deleting Cloudflare Pipeline:", pipelineName);
|
|
75
74
|
if (props.delete !== false) {
|
|
76
75
|
// Delete Pipeline
|
|
77
76
|
await deletePipeline(api, pipelineName);
|
|
@@ -82,13 +81,11 @@ export const Pipeline = Resource("cloudflare::Pipeline", async function (id, pro
|
|
|
82
81
|
else {
|
|
83
82
|
let pipelineData;
|
|
84
83
|
if (this.phase === "create") {
|
|
85
|
-
console.log("Creating Cloudflare Pipeline:", pipelineName);
|
|
86
84
|
pipelineData = await createPipeline(api, pipelineName, props);
|
|
87
85
|
}
|
|
88
86
|
else {
|
|
89
87
|
// Update operation
|
|
90
88
|
if (this.output?.id) {
|
|
91
|
-
console.log("Updating Cloudflare Pipeline:", pipelineName);
|
|
92
89
|
// Check if name is being changed, which is not allowed
|
|
93
90
|
if (props.name !== this.output.name) {
|
|
94
91
|
throw new Error("Cannot update Pipeline name after creation. Pipeline name is immutable.");
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Assets } from "./assets";
|
|
2
|
+
import type { Bindings } from "./bindings";
|
|
3
|
+
import { Worker } from "./worker";
|
|
4
|
+
export interface ViteSiteProps<B extends Bindings> {
|
|
5
|
+
/**
|
|
6
|
+
* The command to run to build the site
|
|
7
|
+
*/
|
|
8
|
+
command: string;
|
|
9
|
+
/**
|
|
10
|
+
* The name of the worker
|
|
11
|
+
*
|
|
12
|
+
* @default id
|
|
13
|
+
*/
|
|
14
|
+
name?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The entrypoint to your server
|
|
17
|
+
*
|
|
18
|
+
* @default - a simple server that serves static assets is generated
|
|
19
|
+
*/
|
|
20
|
+
main?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The directory containing your static assets
|
|
23
|
+
*/
|
|
24
|
+
assets: string;
|
|
25
|
+
/**
|
|
26
|
+
* The bindings to pass to the worker
|
|
27
|
+
*/
|
|
28
|
+
bindings?: B;
|
|
29
|
+
/**
|
|
30
|
+
* @default process.cwd()
|
|
31
|
+
*/
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}
|
|
34
|
+
export type ViteSite<B extends Bindings> = Promise<B extends {
|
|
35
|
+
ASSETS: any;
|
|
36
|
+
} ? never : Worker<B & {
|
|
37
|
+
ASSETS: Assets;
|
|
38
|
+
}>>;
|
|
39
|
+
export declare function ViteSite<B extends Bindings>(id: string, props: ViteSiteProps<B>): ViteSite<B>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { alchemy } from "../alchemy";
|
|
4
|
+
import { Exec } from "../os";
|
|
5
|
+
import { Assets } from "./assets";
|
|
6
|
+
import { Worker } from "./worker";
|
|
7
|
+
import { WranglerJson } from "./wrangler.json";
|
|
8
|
+
export async function ViteSite(id, props) {
|
|
9
|
+
if (props.bindings?.ASSETS) {
|
|
10
|
+
throw new Error("ASSETS binding is reserved for internal use");
|
|
11
|
+
}
|
|
12
|
+
// @ts-ignore - we know the types are correct
|
|
13
|
+
return await alchemy.run(id, async () => {
|
|
14
|
+
// Create minimal wrangler.jsonc if it doesn't exist
|
|
15
|
+
// `building the site requires a wrangler.jsonc file to start - so initialize an empty one if it doesn't exist`
|
|
16
|
+
const cwd = props.cwd || process.cwd();
|
|
17
|
+
const wranglerPath = path.join(cwd, "wrangler.jsonc");
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(wranglerPath);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
await fs.writeFile(wranglerPath, JSON.stringify({
|
|
23
|
+
name: id,
|
|
24
|
+
main: props.main,
|
|
25
|
+
compatibility_date: new Date().toISOString().split("T")[0],
|
|
26
|
+
}, null, 2));
|
|
27
|
+
}
|
|
28
|
+
await Exec("build", {
|
|
29
|
+
command: props.command,
|
|
30
|
+
});
|
|
31
|
+
const staticAssets = await Assets("assets", {
|
|
32
|
+
path: "./dist",
|
|
33
|
+
});
|
|
34
|
+
const worker = await Worker("worker", {
|
|
35
|
+
name: props.name ?? id,
|
|
36
|
+
entrypoint: props.main,
|
|
37
|
+
script: props.main
|
|
38
|
+
? undefined
|
|
39
|
+
: `
|
|
40
|
+
export default {
|
|
41
|
+
async fetch(request, env) {
|
|
42
|
+
return env.ASSETS.fetch(request);
|
|
43
|
+
},
|
|
44
|
+
};`,
|
|
45
|
+
url: true,
|
|
46
|
+
adopt: true,
|
|
47
|
+
bindings: {
|
|
48
|
+
...props.bindings,
|
|
49
|
+
ASSETS: staticAssets,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
await WranglerJson("wrangler.jsonc", {
|
|
53
|
+
worker,
|
|
54
|
+
});
|
|
55
|
+
return worker;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -5,7 +5,7 @@ import { Resource } from "../resource";
|
|
|
5
5
|
*/
|
|
6
6
|
export const WranglerJson = Resource("cloudflare::WranglerJson", async function (id, props) {
|
|
7
7
|
// Default path is wrangler.json in current directory
|
|
8
|
-
const filePath = props.path || "wrangler.
|
|
8
|
+
const filePath = props.path || "wrangler.jsonc";
|
|
9
9
|
if (this.phase === "delete") {
|
|
10
10
|
return this.destroy();
|
|
11
11
|
}
|
package/lib/destroy.js
CHANGED
|
@@ -33,6 +33,14 @@ export async function destroy(...args) {
|
|
|
33
33
|
if (!instance) {
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
|
+
if (instance.Kind === "alchemy::Scope") {
|
|
37
|
+
const scope = new Scope({
|
|
38
|
+
parent: instance.Scope,
|
|
39
|
+
scopeName: instance.ID,
|
|
40
|
+
});
|
|
41
|
+
console.log("Destroying scope", scope.chain.join("/"));
|
|
42
|
+
return await destroy(scope, options);
|
|
43
|
+
}
|
|
36
44
|
const Provider = PROVIDERS.get(instance.Kind);
|
|
37
45
|
if (!Provider) {
|
|
38
46
|
throw new Error(`Cannot destroy resource "${instance.FQN}" type ${instance.Kind} - no provider found. You may need to import the provider in your alchemy.config.ts.`);
|
|
@@ -66,7 +74,11 @@ export async function destroy(...args) {
|
|
|
66
74
|
let nestedScope;
|
|
67
75
|
try {
|
|
68
76
|
// BUG: this does not restore persisted scope
|
|
69
|
-
await alchemy.run(instance.ID,
|
|
77
|
+
await alchemy.run(instance.ID, {
|
|
78
|
+
// TODO(sam): this is an awful hack to differentiate between naked scopes and resources
|
|
79
|
+
isResource: instance.Kind !== "alchemy::Scope",
|
|
80
|
+
parent: scope,
|
|
81
|
+
}, async (scope) => {
|
|
70
82
|
nestedScope = scope;
|
|
71
83
|
return await Provider.handler.bind(ctx)(instance.ID, state.props);
|
|
72
84
|
});
|
|
@@ -97,7 +109,7 @@ export async function destroy(...args) {
|
|
|
97
109
|
if (options?.strategy !== "parallel") {
|
|
98
110
|
const sorted = resources.sort((a, b) => b.Seq - a.Seq);
|
|
99
111
|
for (const resource of sorted) {
|
|
100
|
-
await destroy(resource);
|
|
112
|
+
await destroy(resource, options);
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
else {
|
|
@@ -3,6 +3,7 @@ import type { State, StateStore } from "../state";
|
|
|
3
3
|
export declare class FileSystemStateStore implements StateStore {
|
|
4
4
|
readonly scope: Scope;
|
|
5
5
|
readonly dir: string;
|
|
6
|
+
private initialized;
|
|
6
7
|
constructor(scope: Scope);
|
|
7
8
|
init(): Promise<void>;
|
|
8
9
|
deinit(): Promise<void>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import fs from "node:fs
|
|
1
|
+
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { deserialize, serialize } from "../serde";
|
|
4
4
|
import { ignore } from "../util/ignore";
|
|
@@ -6,23 +6,27 @@ const stateRootDir = path.join(process.cwd(), ".alchemy");
|
|
|
6
6
|
export class FileSystemStateStore {
|
|
7
7
|
scope;
|
|
8
8
|
dir;
|
|
9
|
+
initialized = false;
|
|
9
10
|
constructor(scope) {
|
|
10
11
|
this.scope = scope;
|
|
11
12
|
this.dir = path.join(stateRootDir, ...scope.chain);
|
|
12
13
|
}
|
|
13
14
|
async init() {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
if (this.initialized) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
this.initialized = true;
|
|
19
|
+
await fs.promises.mkdir(this.dir, { recursive: true });
|
|
16
20
|
}
|
|
17
21
|
async deinit() {
|
|
18
|
-
await ignore("ENOENT", () => fs.rmdir(this.dir));
|
|
22
|
+
await ignore("ENOENT", () => fs.promises.rmdir(this.dir));
|
|
19
23
|
}
|
|
20
24
|
async count() {
|
|
21
25
|
return Object.keys(await this.list()).length;
|
|
22
26
|
}
|
|
23
27
|
async list() {
|
|
24
28
|
try {
|
|
25
|
-
const files = await fs.readdir(this.dir, {
|
|
29
|
+
const files = await fs.promises.readdir(this.dir, {
|
|
26
30
|
withFileTypes: true,
|
|
27
31
|
});
|
|
28
32
|
return files
|
|
@@ -39,7 +43,7 @@ export class FileSystemStateStore {
|
|
|
39
43
|
}
|
|
40
44
|
async get(key) {
|
|
41
45
|
try {
|
|
42
|
-
const content = await fs.readFile(
|
|
46
|
+
const content = await fs.promises.readFile(this.getPath(key), "utf8");
|
|
43
47
|
const state = (await deserialize(this.scope, JSON.parse(content)));
|
|
44
48
|
if (state.output === undefined) {
|
|
45
49
|
state.output = {};
|
|
@@ -55,10 +59,19 @@ export class FileSystemStateStore {
|
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
async set(key, value) {
|
|
58
|
-
|
|
62
|
+
await this.init();
|
|
63
|
+
await fs.promises.writeFile(this.getPath(key), JSON.stringify(await serialize(this.scope, value), null, 2));
|
|
59
64
|
}
|
|
60
65
|
async delete(key) {
|
|
61
|
-
|
|
66
|
+
try {
|
|
67
|
+
return await fs.promises.unlink(this.getPath(key));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code === "ENOENT") {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
62
75
|
}
|
|
63
76
|
async all() {
|
|
64
77
|
return this.getBatch(await this.list());
|
|
@@ -72,16 +85,13 @@ export class FileSystemStateStore {
|
|
|
72
85
|
return [[id, s]];
|
|
73
86
|
}))).flat());
|
|
74
87
|
}
|
|
75
|
-
|
|
88
|
+
getPath(key) {
|
|
76
89
|
if (key.includes(":")) {
|
|
77
90
|
throw new Error(`ID cannot include colons: ${key}`);
|
|
78
91
|
}
|
|
79
92
|
if (key.includes("/")) {
|
|
80
93
|
key = key.replaceAll("/", ":");
|
|
81
94
|
}
|
|
82
|
-
|
|
83
|
-
const dir = path.dirname(file);
|
|
84
|
-
await fs.mkdir(dir, { recursive: true });
|
|
85
|
-
return file;
|
|
95
|
+
return path.join(this.dir, `${key}.json`);
|
|
86
96
|
}
|
|
87
97
|
}
|
package/lib/scope.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { PendingResource, ResourceID } from "./resource";
|
|
|
2
2
|
import type { StateStore, StateStoreType } from "./state";
|
|
3
3
|
export type ScopeOptions = {
|
|
4
4
|
appName?: string;
|
|
5
|
-
stage
|
|
5
|
+
stage?: string;
|
|
6
6
|
parent?: Scope;
|
|
7
7
|
scopeName?: string;
|
|
8
8
|
password?: string;
|
package/lib/scope.js
CHANGED
|
@@ -2,6 +2,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import { destroy } from "./destroy";
|
|
3
3
|
import { FileSystemStateStore } from "./fs/file-system-state-store";
|
|
4
4
|
const scopeStorage = new AsyncLocalStorage();
|
|
5
|
+
// TODO: support browser
|
|
6
|
+
const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
|
|
5
7
|
export class Scope {
|
|
6
8
|
static get() {
|
|
7
9
|
return scopeStorage.getStore();
|
|
@@ -24,14 +26,14 @@ export class Scope {
|
|
|
24
26
|
isErrored = false;
|
|
25
27
|
constructor(options) {
|
|
26
28
|
this.appName = options.appName;
|
|
27
|
-
this.stage = options
|
|
29
|
+
this.stage = options?.stage ?? DEFAULT_STAGE;
|
|
28
30
|
this.scopeName = options.scopeName ?? null;
|
|
29
31
|
this.parent = options.parent ?? Scope.get();
|
|
30
32
|
this.quiet = options.quiet ?? this.parent?.quiet ?? false;
|
|
31
33
|
if (this.parent && !this.scopeName) {
|
|
32
34
|
throw new Error("Scope name is required when creating a child scope");
|
|
33
35
|
}
|
|
34
|
-
this.password = options.password;
|
|
36
|
+
this.password = options.password ?? this.parent?.password;
|
|
35
37
|
this.state = options.stateStore
|
|
36
38
|
? options.stateStore(this)
|
|
37
39
|
: new FileSystemStateStore(this);
|
|
@@ -65,6 +67,7 @@ export class Scope {
|
|
|
65
67
|
await this.state.init?.();
|
|
66
68
|
}
|
|
67
69
|
async deinit() {
|
|
70
|
+
await this.parent?.state.delete(this.scopeName);
|
|
68
71
|
await this.state.deinit?.();
|
|
69
72
|
}
|
|
70
73
|
fqn(resourceID) {
|
package/lib/state.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Resource, ResourceProps } from "./resource";
|
|
2
2
|
import type { Scope } from "./scope";
|
|
3
|
-
export
|
|
3
|
+
export type State<Kind extends string = string, Props extends ResourceProps | undefined = ResourceProps | undefined, Out extends Resource = Resource> = {
|
|
4
4
|
status: `creating` | `created` | `updating` | `updated` | `deleting` | `deleted`;
|
|
5
5
|
kind: Kind;
|
|
6
6
|
id: string;
|
|
@@ -10,7 +10,7 @@ export interface State<Kind extends string = string, Props extends ResourceProps
|
|
|
10
10
|
props: Props;
|
|
11
11
|
oldProps?: Props;
|
|
12
12
|
output: Out;
|
|
13
|
-
}
|
|
13
|
+
};
|
|
14
14
|
export type StateStoreType = (scope: Scope) => StateStore;
|
|
15
15
|
export interface StateStore {
|
|
16
16
|
/** Initialize the state container if one is required */
|
package/lib/test/bun.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Scope } from "../scope";
|
|
2
2
|
import type { StateStoreType } from "../state";
|
|
3
3
|
/**
|
|
4
4
|
* Extend the Alchemy interface to include test functionality
|
|
@@ -12,11 +12,6 @@ declare module "../alchemy" {
|
|
|
12
12
|
* Options for configuring test behavior
|
|
13
13
|
*/
|
|
14
14
|
export interface TestOptions {
|
|
15
|
-
/**
|
|
16
|
-
* Whether to automatically destroy resources after test.
|
|
17
|
-
* @default true.
|
|
18
|
-
*/
|
|
19
|
-
destroy?: boolean;
|
|
20
15
|
/**
|
|
21
16
|
* Whether to suppress logging output.
|
|
22
17
|
* @default false.
|
package/lib/test/bun.js
CHANGED
|
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, it } from "bun:test";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { alchemy } from "../alchemy";
|
|
5
5
|
import { R2RestStateStore } from "../cloudflare";
|
|
6
|
-
import {
|
|
6
|
+
import { Scope } from "../scope";
|
|
7
7
|
/**
|
|
8
8
|
* Add test functionality to alchemy instance
|
|
9
9
|
*/
|
|
@@ -32,7 +32,6 @@ alchemy.test = test;
|
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
34
|
export function test(meta, defaultOptions) {
|
|
35
|
-
let isFailed = false;
|
|
36
35
|
defaultOptions = defaultOptions ?? {};
|
|
37
36
|
if (defaultOptions.stateStore === undefined &&
|
|
38
37
|
// process.env.CI &&
|
|
@@ -52,29 +51,17 @@ export function test(meta, defaultOptions) {
|
|
|
52
51
|
return test;
|
|
53
52
|
};
|
|
54
53
|
// Create local test scope based on filename
|
|
55
|
-
const
|
|
54
|
+
const scope = new Scope({
|
|
55
|
+
scopeName: `${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`,
|
|
56
56
|
// parent: globalTestScope,
|
|
57
57
|
stateStore: defaultOptions?.stateStore,
|
|
58
58
|
});
|
|
59
|
-
test.scope = localTestScope;
|
|
60
59
|
test.beforeAll = (fn) => {
|
|
61
|
-
return beforeAll(
|
|
62
|
-
test.scope.enter();
|
|
63
|
-
await fn(test.scope);
|
|
64
|
-
});
|
|
60
|
+
return beforeAll(() => scope.run(() => fn(scope)));
|
|
65
61
|
};
|
|
66
62
|
test.afterAll = (fn) => {
|
|
67
|
-
return afterAll(
|
|
68
|
-
test.scope.enter();
|
|
69
|
-
await fn(test.scope);
|
|
70
|
-
});
|
|
63
|
+
return afterAll(() => scope.run(() => fn(scope)));
|
|
71
64
|
};
|
|
72
|
-
// Clean up test scope after all tests complete
|
|
73
|
-
afterAll(async () => {
|
|
74
|
-
if (defaultOptions?.destroy !== false && !isFailed) {
|
|
75
|
-
await alchemy.destroy(test.scope);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
65
|
return test;
|
|
79
66
|
function test(...args) {
|
|
80
67
|
const testName = args[0];
|
|
@@ -87,33 +74,24 @@ export function test(meta, defaultOptions) {
|
|
|
87
74
|
: {};
|
|
88
75
|
// Merge options with defaults
|
|
89
76
|
const options = {
|
|
90
|
-
destroy: true,
|
|
91
77
|
quiet: false,
|
|
92
78
|
password: "test-password",
|
|
93
79
|
...spread(defaultOptions),
|
|
94
80
|
...spread(_options),
|
|
95
81
|
};
|
|
96
82
|
const fn = typeof args[1] === "function" ? args[1] : args[2];
|
|
97
|
-
return alchemy.run(testName, {
|
|
83
|
+
return it(testName, async () => alchemy.run(testName, {
|
|
98
84
|
...options,
|
|
99
|
-
parent:
|
|
85
|
+
parent: scope,
|
|
100
86
|
}, async (scope) => {
|
|
101
|
-
|
|
87
|
+
try {
|
|
102
88
|
// Enter test scope since bun calls from different scope
|
|
103
|
-
scope.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
finally {
|
|
112
|
-
if (options.destroy !== false) {
|
|
113
|
-
await destroy(scope);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}, timeout);
|
|
117
|
-
});
|
|
89
|
+
await scope.run(() => fn(scope));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(err);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}), timeout);
|
|
118
96
|
}
|
|
119
97
|
}
|