alchemy 0.8.0 → 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/api-error.js +1 -1
- package/lib/cloudflare/bindings.d.ts +2 -1
- package/lib/cloudflare/bound.d.ts +2 -1
- package/lib/cloudflare/index.d.ts +3 -0
- package/lib/cloudflare/index.js +3 -0
- package/lib/cloudflare/pipeline.js +0 -3
- package/lib/cloudflare/vectorize-index.d.ts +136 -0
- package/lib/cloudflare/vectorize-index.js +170 -0
- package/lib/cloudflare/vectorize-metadata-index.d.ts +95 -0
- package/lib/cloudflare/vectorize-metadata-index.js +120 -0
- package/lib/cloudflare/vite-site.d.ts +39 -0
- package/lib/cloudflare/vite-site.js +57 -0
- package/lib/cloudflare/worker.js +7 -0
- package/lib/cloudflare/wrangler.json.d.ts +7 -0
- package/lib/cloudflare/wrangler.json.js +11 -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 +18 -37
- package/package.json +1 -1
- package/src/alchemy.ts +51 -45
- package/src/apply.ts +6 -2
- package/src/cloudflare/api-error.ts +1 -1
- package/src/cloudflare/bindings.ts +2 -0
- package/src/cloudflare/bound.ts +8 -5
- package/src/cloudflare/index.ts +3 -0
- package/src/cloudflare/pipeline.ts +0 -4
- package/src/cloudflare/vectorize-index.ts +331 -0
- package/src/cloudflare/vectorize-metadata-index.ts +239 -0
- package/src/cloudflare/vite-site.ts +111 -0
- package/src/cloudflare/worker.ts +6 -0
- package/src/cloudflare/wrangler.json.ts +19 -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 +31 -54
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Resource } from "../resource";
|
|
2
|
+
import { CloudflareApi, createCloudflareApi, } from "./api";
|
|
3
|
+
import { CloudflareApiError, handleApiError } from "./api-error";
|
|
4
|
+
import { VectorizeIndex } from "./vectorize-index";
|
|
5
|
+
/**
|
|
6
|
+
* Creates and manages Cloudflare Vectorize Metadata Indexes.
|
|
7
|
+
*
|
|
8
|
+
* Vectorize Metadata Indexes enable filtering based on metadata properties when querying vectors.
|
|
9
|
+
* Each Vectorize Index can have up to 10 metadata indexes.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // First create a Vectorize Index
|
|
13
|
+
* const vectorIndex = await VectorizeIndex("documents", {
|
|
14
|
+
* name: "documents",
|
|
15
|
+
* config: {
|
|
16
|
+
* dimensions: 768,
|
|
17
|
+
* metric: "cosine"
|
|
18
|
+
* }
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // Then create a metadata index for the "category" property
|
|
22
|
+
* const categoryIndex = await VectorizeMetadataIndex("category-index", {
|
|
23
|
+
* index: vectorIndex,
|
|
24
|
+
* propertyName: "category",
|
|
25
|
+
* indexType: "string"
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // Create a metadata index for a numeric property
|
|
30
|
+
* const yearIndex = await VectorizeMetadataIndex("year-index", {
|
|
31
|
+
* index: vectorIndex,
|
|
32
|
+
* propertyName: "year",
|
|
33
|
+
* indexType: "number"
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* @see https://developers.cloudflare.com/vectorize/
|
|
37
|
+
*/
|
|
38
|
+
export const VectorizeMetadataIndex = Resource("cloudflare::VectorizeMetadataIndex", async function (id, props) {
|
|
39
|
+
const api = await createCloudflareApi(props);
|
|
40
|
+
const indexName = props.index.name;
|
|
41
|
+
const propertyName = props.propertyName;
|
|
42
|
+
if (this.phase === "delete") {
|
|
43
|
+
// Delete metadata index
|
|
44
|
+
try {
|
|
45
|
+
await deleteMetadataIndex(api, indexName, propertyName);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof CloudflareApiError &&
|
|
49
|
+
error.status === 400 &&
|
|
50
|
+
error.message.includes("does not exist")) {
|
|
51
|
+
// Index doesn't exist, which is what we want
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return this.destroy();
|
|
58
|
+
}
|
|
59
|
+
else if (this.phase === "update") {
|
|
60
|
+
// Update operation is not supported
|
|
61
|
+
throw new Error("Updating Vectorize metadata indexes is not supported by the Cloudflare API. " +
|
|
62
|
+
"To change a metadata index, delete it and create a new one with the desired configuration.");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const indexData = await createMetadataIndex(api, indexName, props);
|
|
66
|
+
return this({
|
|
67
|
+
id: propertyName, // Use propertyName as ID
|
|
68
|
+
index: props.index,
|
|
69
|
+
propertyName: props.propertyName,
|
|
70
|
+
indexType: props.indexType,
|
|
71
|
+
accountId: api.accountId,
|
|
72
|
+
mutationId: indexData.result.mutationId,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* Create a new Vectorize metadata index
|
|
78
|
+
*/
|
|
79
|
+
export async function createMetadataIndex(api, indexName, props) {
|
|
80
|
+
// Create new metadata index
|
|
81
|
+
const createPayload = {
|
|
82
|
+
propertyName: props.propertyName,
|
|
83
|
+
indexType: props.indexType,
|
|
84
|
+
};
|
|
85
|
+
const createResponse = await api.post(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`, createPayload);
|
|
86
|
+
if (!createResponse.ok) {
|
|
87
|
+
return await handleApiError(createResponse, "creating", "Vectorize metadata index", props.propertyName);
|
|
88
|
+
}
|
|
89
|
+
return (await createResponse.json());
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Delete a Vectorize metadata index
|
|
93
|
+
*/
|
|
94
|
+
export async function deleteMetadataIndex(api, indexName, propertyName) {
|
|
95
|
+
const deleteResponse = await api.post(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/delete`, {
|
|
96
|
+
propertyName,
|
|
97
|
+
});
|
|
98
|
+
if (!deleteResponse.ok) {
|
|
99
|
+
await handleApiError(deleteResponse, "deleting", "Vectorize metadata index", propertyName);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* List all metadata indexes for a Vectorize index
|
|
104
|
+
*/
|
|
105
|
+
export async function listMetadataIndexes(api, indexName) {
|
|
106
|
+
const response = await api.get(`/accounts/${api.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/list`);
|
|
107
|
+
if (response.status === 410) {
|
|
108
|
+
// Gone
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new CloudflareApiError(`Failed to list metadata indexes: ${response.statusText}`, response);
|
|
113
|
+
}
|
|
114
|
+
const data = (await response.json());
|
|
115
|
+
if (!data.success) {
|
|
116
|
+
const errorMessage = data.errors?.[0]?.message || "Unknown error";
|
|
117
|
+
throw new Error(`Failed to list metadata indexes: ${errorMessage}`);
|
|
118
|
+
}
|
|
119
|
+
return data.result.metadataIndexes;
|
|
120
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/cloudflare/worker.js
CHANGED
|
@@ -353,6 +353,13 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
|
|
|
353
353
|
pipeline: binding.name,
|
|
354
354
|
});
|
|
355
355
|
}
|
|
356
|
+
else if (binding.type === "vectorize") {
|
|
357
|
+
meta.bindings.push({
|
|
358
|
+
type: "vectorize",
|
|
359
|
+
name: bindingName,
|
|
360
|
+
index_name: binding.name,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
356
363
|
else {
|
|
357
364
|
// @ts-expect-error - we should never reach here
|
|
358
365
|
throw new Error(`Unsupported binding type: ${binding.type}`);
|
|
@@ -114,6 +114,13 @@ export interface WranglerJsonSpec {
|
|
|
114
114
|
binding: string;
|
|
115
115
|
class_name: string;
|
|
116
116
|
}[];
|
|
117
|
+
/**
|
|
118
|
+
* Vectorize index bindings
|
|
119
|
+
*/
|
|
120
|
+
vectorize_indexes?: {
|
|
121
|
+
binding: string;
|
|
122
|
+
index_name: string;
|
|
123
|
+
}[];
|
|
117
124
|
/**
|
|
118
125
|
* Plain text bindings (vars)
|
|
119
126
|
*/
|
|
@@ -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
|
}
|
|
@@ -50,6 +50,7 @@ function processBindings(spec, bindings) {
|
|
|
50
50
|
const workflows = [];
|
|
51
51
|
const d1Databases = [];
|
|
52
52
|
const queues = [];
|
|
53
|
+
const vectorizeIndexes = [];
|
|
53
54
|
// Process each binding
|
|
54
55
|
for (const [bindingName, binding] of Object.entries(bindings)) {
|
|
55
56
|
if (typeof binding === "string") {
|
|
@@ -120,6 +121,12 @@ function processBindings(spec, bindings) {
|
|
|
120
121
|
queue: binding.name,
|
|
121
122
|
});
|
|
122
123
|
}
|
|
124
|
+
else if (binding.type === "vectorize") {
|
|
125
|
+
vectorizeIndexes.push({
|
|
126
|
+
binding: bindingName,
|
|
127
|
+
index_name: binding.name,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
123
130
|
}
|
|
124
131
|
// Add collected bindings to the spec
|
|
125
132
|
if (kvNamespaces.length > 0) {
|
|
@@ -139,4 +146,7 @@ function processBindings(spec, bindings) {
|
|
|
139
146
|
if (queues.length > 0) {
|
|
140
147
|
spec.queues = queues;
|
|
141
148
|
}
|
|
149
|
+
if (vectorizeIndexes.length > 0) {
|
|
150
|
+
spec.vectorize_indexes = vectorizeIndexes;
|
|
151
|
+
}
|
|
142
152
|
}
|
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,65 +51,47 @@ 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];
|
|
81
68
|
const _options = typeof args[1] === "object" ? args[1] : undefined;
|
|
69
|
+
const timeout = typeof args[args.length - 1] === "number"
|
|
70
|
+
? args[args.length - 1]
|
|
71
|
+
: 120000;
|
|
82
72
|
const spread = (obj) => obj && typeof obj === "object"
|
|
83
73
|
? Object.fromEntries(Object.entries(obj).flatMap(([k, v]) => v !== undefined ? [[k, v]] : []))
|
|
84
74
|
: {};
|
|
85
75
|
// Merge options with defaults
|
|
86
76
|
const options = {
|
|
87
|
-
destroy: true,
|
|
88
77
|
quiet: false,
|
|
89
78
|
password: "test-password",
|
|
90
79
|
...spread(defaultOptions),
|
|
91
80
|
...spread(_options),
|
|
92
81
|
};
|
|
93
82
|
const fn = typeof args[1] === "function" ? args[1] : args[2];
|
|
94
|
-
return alchemy.run(testName, {
|
|
83
|
+
return it(testName, async () => alchemy.run(testName, {
|
|
95
84
|
...options,
|
|
96
|
-
parent:
|
|
85
|
+
parent: scope,
|
|
97
86
|
}, async (scope) => {
|
|
98
|
-
|
|
87
|
+
try {
|
|
99
88
|
// Enter test scope since bun calls from different scope
|
|
100
|
-
scope.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
finally {
|
|
109
|
-
if (options.destroy !== false) {
|
|
110
|
-
await destroy(scope);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
});
|
|
89
|
+
await scope.run(() => fn(scope));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(err);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}), timeout);
|
|
115
96
|
}
|
|
116
97
|
}
|