alchemy 2.0.0-beta.36 → 2.0.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/bin/exec.js +166 -43
  2. package/bin/exec.js.map +1 -1
  3. package/lib/AWS/Lambda/Function.d.ts.map +1 -1
  4. package/lib/AWS/Lambda/Function.js +22 -2
  5. package/lib/AWS/Lambda/Function.js.map +1 -1
  6. package/lib/Apply.d.ts.map +1 -1
  7. package/lib/Apply.js +6 -1
  8. package/lib/Apply.js.map +1 -1
  9. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.d.ts +54 -0
  10. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.d.ts.map +1 -0
  11. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.js +17 -0
  12. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.js.map +1 -0
  13. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.d.ts +37 -0
  14. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.d.ts.map +1 -0
  15. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.js +45 -0
  16. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.js.map +1 -0
  17. package/lib/Cloudflare/AnalyticsEngine/index.d.ts +3 -0
  18. package/lib/Cloudflare/AnalyticsEngine/index.d.ts.map +1 -0
  19. package/lib/Cloudflare/AnalyticsEngine/index.js +3 -0
  20. package/lib/Cloudflare/AnalyticsEngine/index.js.map +1 -0
  21. package/lib/Cloudflare/D1/D1Connection.d.ts +47 -3
  22. package/lib/Cloudflare/D1/D1Connection.d.ts.map +1 -1
  23. package/lib/Cloudflare/D1/D1Connection.js +62 -5
  24. package/lib/Cloudflare/D1/D1Connection.js.map +1 -1
  25. package/lib/Cloudflare/Local/SidecarHandlers.js +9 -1
  26. package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -1
  27. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  28. package/lib/Cloudflare/Providers.js +4 -1
  29. package/lib/Cloudflare/Providers.js.map +1 -1
  30. package/lib/Cloudflare/R2/R2Bucket.d.ts.map +1 -1
  31. package/lib/Cloudflare/R2/R2Bucket.js +16 -0
  32. package/lib/Cloudflare/R2/R2Bucket.js.map +1 -1
  33. package/lib/Cloudflare/StateStore/Api.d.ts +6 -1
  34. package/lib/Cloudflare/StateStore/Api.d.ts.map +1 -1
  35. package/lib/Cloudflare/StateStore/Api.js +23 -1
  36. package/lib/Cloudflare/StateStore/Api.js.map +1 -1
  37. package/lib/Cloudflare/StateStore/Store.d.ts +16 -1
  38. package/lib/Cloudflare/StateStore/Store.d.ts.map +1 -1
  39. package/lib/Cloudflare/StateStore/Store.js +22 -5
  40. package/lib/Cloudflare/StateStore/Store.js.map +1 -1
  41. package/lib/Cloudflare/Workers/CronEventSource.d.ts +35 -0
  42. package/lib/Cloudflare/Workers/CronEventSource.d.ts.map +1 -0
  43. package/lib/Cloudflare/Workers/CronEventSource.js +55 -0
  44. package/lib/Cloudflare/Workers/CronEventSource.js.map +1 -0
  45. package/lib/Cloudflare/Workers/InferEnv.d.ts +37 -2
  46. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  47. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
  48. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +2 -1
  49. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
  50. package/lib/Cloudflare/Workers/Rpc.d.ts +76 -3
  51. package/lib/Cloudflare/Workers/Rpc.d.ts.map +1 -1
  52. package/lib/Cloudflare/Workers/Rpc.js +197 -13
  53. package/lib/Cloudflare/Workers/Rpc.js.map +1 -1
  54. package/lib/Cloudflare/Workers/Worker.d.ts +22 -2
  55. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  56. package/lib/Cloudflare/Workers/Worker.js +174 -43
  57. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  58. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -1
  59. package/lib/Cloudflare/Workers/WorkerBundle.js +15 -5
  60. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -1
  61. package/lib/Cloudflare/Workers/Workflow.d.ts +50 -47
  62. package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
  63. package/lib/Cloudflare/Workers/Workflow.js +41 -35
  64. package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
  65. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  66. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  67. package/lib/Cloudflare/Workers/index.js +1 -0
  68. package/lib/Cloudflare/Workers/index.js.map +1 -1
  69. package/lib/Cloudflare/index.d.ts +1 -0
  70. package/lib/Cloudflare/index.d.ts.map +1 -1
  71. package/lib/Cloudflare/index.js +1 -0
  72. package/lib/Cloudflare/index.js.map +1 -1
  73. package/lib/Deploy.d.ts +4 -1
  74. package/lib/Deploy.d.ts.map +1 -1
  75. package/lib/Deploy.js +2 -2
  76. package/lib/Deploy.js.map +1 -1
  77. package/lib/Destroy.d.ts +4 -1
  78. package/lib/Destroy.d.ts.map +1 -1
  79. package/lib/Destroy.js +2 -2
  80. package/lib/Destroy.js.map +1 -1
  81. package/lib/Output.d.ts +45 -5
  82. package/lib/Output.d.ts.map +1 -1
  83. package/lib/Output.js +100 -4
  84. package/lib/Output.js.map +1 -1
  85. package/lib/Plan.d.ts.map +1 -1
  86. package/lib/Plan.js +93 -12
  87. package/lib/Plan.js.map +1 -1
  88. package/lib/Platform.d.ts.map +1 -1
  89. package/lib/Platform.js +16 -3
  90. package/lib/Platform.js.map +1 -1
  91. package/lib/Ref.d.ts +4 -1
  92. package/lib/Ref.d.ts.map +1 -1
  93. package/lib/Ref.js +1 -1
  94. package/lib/Ref.js.map +1 -1
  95. package/lib/Resource.d.ts +8 -0
  96. package/lib/Resource.d.ts.map +1 -1
  97. package/lib/Resource.js +10 -0
  98. package/lib/Resource.js.map +1 -1
  99. package/lib/RuntimeContext.d.ts +3 -1
  100. package/lib/RuntimeContext.d.ts.map +1 -1
  101. package/lib/RuntimeContext.js.map +1 -1
  102. package/lib/Secret.d.ts +50 -0
  103. package/lib/Secret.d.ts.map +1 -0
  104. package/lib/Secret.js +23 -0
  105. package/lib/Secret.js.map +1 -0
  106. package/lib/Serverless/Function.d.ts +9 -1
  107. package/lib/Serverless/Function.d.ts.map +1 -1
  108. package/lib/Stack.d.ts +14 -5
  109. package/lib/Stack.d.ts.map +1 -1
  110. package/lib/Stack.js +31 -19
  111. package/lib/Stack.js.map +1 -1
  112. package/lib/State/HttpStateApi.d.ts +15 -1
  113. package/lib/State/HttpStateApi.d.ts.map +1 -1
  114. package/lib/State/HttpStateApi.js +11 -0
  115. package/lib/State/HttpStateApi.js.map +1 -1
  116. package/lib/State/HttpStateStore.d.ts.map +1 -1
  117. package/lib/State/HttpStateStore.js +11 -0
  118. package/lib/State/HttpStateStore.js.map +1 -1
  119. package/lib/State/InMemoryState.d.ts +2 -2
  120. package/lib/State/InMemoryState.d.ts.map +1 -1
  121. package/lib/State/InMemoryState.js +12 -6
  122. package/lib/State/InMemoryState.js.map +1 -1
  123. package/lib/State/LocalState.d.ts.map +1 -1
  124. package/lib/State/LocalState.js +11 -2
  125. package/lib/State/LocalState.js.map +1 -1
  126. package/lib/State/State.d.ts +21 -0
  127. package/lib/State/State.d.ts.map +1 -1
  128. package/lib/Test/Bun.d.ts.map +1 -1
  129. package/lib/Test/Bun.js +30 -7
  130. package/lib/Test/Bun.js.map +1 -1
  131. package/lib/Test/Core.d.ts +17 -2
  132. package/lib/Test/Core.d.ts.map +1 -1
  133. package/lib/Test/Core.js +29 -11
  134. package/lib/Test/Core.js.map +1 -1
  135. package/lib/Test/Vitest.d.ts.map +1 -1
  136. package/lib/Test/Vitest.js +24 -5
  137. package/lib/Test/Vitest.js.map +1 -1
  138. package/lib/Util/LocalhostDns.d.ts +14 -10
  139. package/lib/Util/LocalhostDns.d.ts.map +1 -1
  140. package/lib/Util/LocalhostDns.js +27 -36
  141. package/lib/Util/LocalhostDns.js.map +1 -1
  142. package/lib/Util/effect.d.ts +7 -0
  143. package/lib/Util/effect.d.ts.map +1 -1
  144. package/lib/Util/effect.js +6 -0
  145. package/lib/Util/effect.js.map +1 -1
  146. package/lib/Variable.d.ts +29 -0
  147. package/lib/Variable.d.ts.map +1 -0
  148. package/lib/Variable.js +9 -0
  149. package/lib/Variable.js.map +1 -0
  150. package/lib/index.d.ts +3 -0
  151. package/lib/index.d.ts.map +1 -1
  152. package/lib/index.js +3 -0
  153. package/lib/index.js.map +1 -1
  154. package/lib/tsconfig.test.tsbuildinfo +1 -1
  155. package/package.json +6 -7
  156. package/src/AWS/Lambda/Function.ts +30 -2
  157. package/src/Apply.ts +8 -1
  158. package/src/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.ts +82 -0
  159. package/src/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.ts +106 -0
  160. package/src/Cloudflare/AnalyticsEngine/index.ts +2 -0
  161. package/src/Cloudflare/D1/D1Connection.ts +95 -12
  162. package/src/Cloudflare/Local/SidecarHandlers.ts +9 -1
  163. package/src/Cloudflare/Providers.ts +5 -0
  164. package/src/Cloudflare/R2/R2Bucket.ts +31 -0
  165. package/src/Cloudflare/StateStore/Api.ts +34 -1
  166. package/src/Cloudflare/StateStore/Store.ts +40 -10
  167. package/src/Cloudflare/Workers/CronEventSource.ts +89 -0
  168. package/src/Cloudflare/Workers/InferEnv.ts +94 -20
  169. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +9 -1
  170. package/src/Cloudflare/Workers/Rpc.ts +227 -18
  171. package/src/Cloudflare/Workers/Worker.ts +277 -63
  172. package/src/Cloudflare/Workers/WorkerBundle.ts +15 -5
  173. package/src/Cloudflare/Workers/Workflow.ts +74 -70
  174. package/src/Cloudflare/Workers/index.ts +1 -0
  175. package/src/Cloudflare/index.ts +1 -0
  176. package/src/Deploy.ts +5 -1
  177. package/src/Destroy.ts +5 -1
  178. package/src/Output.ts +121 -12
  179. package/src/Plan.ts +101 -12
  180. package/src/Platform.ts +19 -4
  181. package/src/Ref.ts +10 -5
  182. package/src/Resource.ts +22 -0
  183. package/src/RuntimeContext.ts +1 -0
  184. package/src/Secret.ts +87 -0
  185. package/src/Serverless/Function.ts +10 -1
  186. package/src/Stack.ts +52 -29
  187. package/src/State/HttpStateApi.ts +21 -0
  188. package/src/State/HttpStateStore.ts +21 -0
  189. package/src/State/InMemoryState.ts +29 -8
  190. package/src/State/LocalState.ts +28 -4
  191. package/src/State/State.ts +21 -0
  192. package/src/Test/Bun.ts +43 -7
  193. package/src/Test/Core.ts +29 -8
  194. package/src/Test/Vitest.ts +33 -4
  195. package/src/Util/LocalhostDns.ts +30 -36
  196. package/src/Util/effect.ts +25 -0
  197. package/src/Variable.ts +44 -0
  198. package/src/index.ts +3 -0
package/src/Platform.ts CHANGED
@@ -242,8 +242,18 @@ export const Platform = <
242
242
  }),
243
243
  )
244
244
  ).pipe(
245
- Effect.tap(
246
- (resource) => hooks.onCreate?.(resource as R, props) ?? Effect.void,
245
+ Effect.tap((resource) =>
246
+ hooks.onCreate
247
+ ? Effect.flatMap(
248
+ // `props` may itself be an Effect (e.g. when wrapped by
249
+ // `Cloudflare.Vite` via `Effect.map`); resolve it before
250
+ // handing it to the hook so `onCreate` always sees the
251
+ // plain props object — the second call site (in
252
+ // `cls.make`) already does this.
253
+ Effect.isEffect(props) ? props : Effect.succeed(props ?? {}),
254
+ (resolved) => hooks.onCreate!(resource as R, resolved),
255
+ )
256
+ : Effect.void,
247
257
  ),
248
258
  );
249
259
  return Object.assign(
@@ -324,8 +334,13 @@ export const Platform = <
324
334
  yield* impl.pipe(
325
335
  Effect.flatMap((impl) =>
326
336
  impl?.fetch
327
- ? (runtimeContext.serve?.(impl.fetch) ??
328
- Effect.die("No serve handler"))
337
+ ? // Hand the full impl to `serve` so the runtime can
338
+ // expose any non-handler methods on the impl shape
339
+ // (e.g. RPC methods on a Cloudflare Worker) alongside
340
+ // the standard `fetch` handler.
341
+ (runtimeContext.serve?.(impl.fetch, {
342
+ shape: impl as Record<string, unknown>,
343
+ }) ?? Effect.die("No serve handler"))
329
344
  : Effect.void,
330
345
  ),
331
346
  Effect.provide(
package/src/Ref.ts CHANGED
@@ -22,11 +22,16 @@ export interface RefMetadata<R extends ResourceLike> {
22
22
  stage?: string;
23
23
  }
24
24
 
25
- export const ref = <R extends ResourceLike>({
26
- stack,
27
- id,
28
- stage,
29
- }: RefMetadata<R>): Ref<R> => {
25
+ export const ref = <R extends ResourceLike>(
26
+ id: string,
27
+ {
28
+ stack,
29
+ stage,
30
+ }: {
31
+ stack?: string;
32
+ stage?: string;
33
+ } = {},
34
+ ): Ref<R> => {
30
35
  const ref = new Proxy(
31
36
  {},
32
37
  {
package/src/Resource.ts CHANGED
@@ -7,6 +7,7 @@ import type { Input, InputProps } from "./Input.ts";
7
7
  import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
8
8
  import * as Output from "./Output.ts";
9
9
  import { Provider } from "./Provider.ts";
10
+ import { ref as makeRef } from "./Ref.ts";
10
11
  import { RemovalPolicy } from "./RemovalPolicy.ts";
11
12
  import { Self } from "./Self.ts";
12
13
  import { Stack } from "./Stack.ts";
@@ -47,6 +48,10 @@ export type ResourceClassWithMethods<
47
48
  Effect.Effect<ResourceConstructor<R>> & {
48
49
  Self: Self<R>;
49
50
  Provider: Provider<R>;
51
+ ref(
52
+ id: string,
53
+ options?: { stage?: string; stack?: string },
54
+ ): Effect.Effect<R>;
50
55
  } & Methods;
51
56
 
52
57
  export type ResourceClass<R extends ResourceLike> = ResourceConstructor<
@@ -56,6 +61,10 @@ export type ResourceClass<R extends ResourceLike> = ResourceConstructor<
56
61
  Effect.Effect<ResourceConstructor<R>> & {
57
62
  Self: Self<R>;
58
63
  Provider: Provider<R>;
64
+ ref(
65
+ id: string,
66
+ options?: { stage?: string; stack?: string },
67
+ ): Effect.Effect<R>;
59
68
  };
60
69
 
61
70
  export type LogicalId = string;
@@ -286,6 +295,19 @@ export function Resource<R extends ResourceLike>(
286
295
  constructor(id, props),
287
296
  );
288
297
  },
298
+ /**
299
+ * Build a typed reference to a deployed instance of this resource
300
+ * — in the current stack/stage by default, or in another via
301
+ * `options`. Resolves to the same shape as `yield*
302
+ * MyResource("id", props)` so downstream code can read attributes
303
+ * (`ref.someAttr`) exactly the way it would for a locally-declared
304
+ * resource.
305
+ */
306
+ ref: (
307
+ id: string,
308
+ options?: { stage?: string; stack?: string },
309
+ ): Effect.Effect<R> =>
310
+ Effect.succeed(Output.of(makeRef<R>(id, options)) as unknown as R),
289
311
  Type: type,
290
312
  Provider: ProviderTag,
291
313
  Self: self,
@@ -14,6 +14,7 @@ export interface BaseRuntimeContext {
14
14
  exports?: Effect.Effect<Record<string, any>>;
15
15
  serve?<Req = never>(
16
16
  handler: HttpEffect<Req>,
17
+ options?: { shape?: Record<string, unknown> },
17
18
  ): Effect.Effect<void, never, Req>;
18
19
  }
19
20
 
package/src/Secret.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as Config from "effect/Config";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Redacted from "effect/Redacted";
4
+ import * as Output from "./Output.ts";
5
+
6
+ /**
7
+ * The shapes a {@link secret} can be derived from.
8
+ *
9
+ * - `string` — wrapped in `Redacted.make` and bound as a literal.
10
+ * - `Redacted<string>` — bound as-is.
11
+ * - `Effect<string | Redacted<string>>` — resolved at deploy time.
12
+ * - `Config<string | Redacted<string>>` — resolved against the active
13
+ * `ConfigProvider` at deploy time.
14
+ */
15
+ export type SecretInput<R = never> =
16
+ | string
17
+ | Redacted.Redacted<string>
18
+ | Effect.Effect<string | Redacted.Redacted<string>, any, R>
19
+ | Config.Config<string | Redacted.Redacted<string>>;
20
+
21
+ /**
22
+ * Bind a secret value into the active {@link RuntimeContext} (e.g. a
23
+ * Cloudflare Worker's `env`) under the given `name` so the resource
24
+ * provider deploys it as a secret (e.g. `secret_text` for Cloudflare).
25
+ *
26
+ * The result is an `Output<Redacted<string>>` — `yield*`-ing it returns
27
+ * an `Accessor<Redacted<string>>` that resolves the value at runtime.
28
+ *
29
+ * @example One-arg shortcut — reads `Config.redacted("API_KEY")`
30
+ * ```ts
31
+ * const apiKey = yield* Alchemy.Secret("API_KEY");
32
+ * ```
33
+ *
34
+ * @example From a literal
35
+ * ```ts
36
+ * const apiKey = yield* Alchemy.Secret("API_KEY", "sk-123");
37
+ * ```
38
+ *
39
+ * @example From an Effect
40
+ * ```ts
41
+ * const apiKey = yield* Alchemy.Secret(
42
+ * "API_KEY",
43
+ * Effect.succeed(Redacted.make("sk-123")),
44
+ * );
45
+ * ```
46
+ *
47
+ * @example From a Config
48
+ * ```ts
49
+ * const apiKey = yield* Alchemy.Secret("API_KEY", Config.redacted("API_KEY"));
50
+ * ```
51
+ */
52
+ export function Secret(
53
+ name: string,
54
+ ): Output.Output<Redacted.Redacted<string>, never>;
55
+ export function Secret<R = never>(
56
+ name: string,
57
+ value: SecretInput<R>,
58
+ ): Output.Output<Redacted.Redacted<string>, R>;
59
+ export function Secret<R = never>(
60
+ name: string,
61
+ value?: SecretInput<R>,
62
+ ): Output.Output<Redacted.Redacted<string>, R> {
63
+ const source = value ?? Config.redacted(name);
64
+ const base = toOutput<string | Redacted.Redacted<string>, R>(source);
65
+ return Output.named(
66
+ base.pipe(
67
+ Output.map((v) => (Redacted.isRedacted(v) ? v : Redacted.make(v))),
68
+ ) as Output.Output<Redacted.Redacted<string>, R>,
69
+ name,
70
+ );
71
+ }
72
+
73
+ /** @internal */
74
+ export const toOutput = <T, R = never>(
75
+ source: T | Effect.Effect<T, any, R> | Config.Config<T> | Output.Output<T, R>,
76
+ ): Output.Output<T, R> => {
77
+ if (Output.isOutput(source)) {
78
+ return source;
79
+ }
80
+ if (Config.isConfig(source)) {
81
+ return Output.asOutput(source.asEffect() as any) as Output.Output<T, R>;
82
+ }
83
+ if (Effect.isEffect(source)) {
84
+ return Output.asOutput(source as any) as Output.Output<T, R>;
85
+ }
86
+ return Output.asOutput(source as T) as Output.Output<T, R>;
87
+ };
@@ -3,7 +3,16 @@ import type { HttpEffect } from "../Http.ts";
3
3
  import type { BaseRuntimeContext } from "../RuntimeContext.ts";
4
4
 
5
5
  export interface FunctionContext extends BaseRuntimeContext {
6
- serve<Req = never>(handler: HttpEffect<Req>): Effect.Effect<void, never, Req>;
6
+ /**
7
+ * Register an HTTP handler. The optional `shape` payload is the user's
8
+ * full default-export shape — Cloudflare Workers use it to expose
9
+ * non-handler methods (e.g. `greet`) as RPC methods on the deployed
10
+ * `WorkerEntrypoint`. Other platforms (Lambda, etc.) ignore it.
11
+ */
12
+ serve<Req = never>(
13
+ handler: HttpEffect<Req>,
14
+ options?: { shape?: Record<string, unknown> },
15
+ ): Effect.Effect<void, never, Req>;
7
16
  listen<A, Req = never>(
8
17
  handler: FunctionListener<A, Req>,
9
18
  ): Effect.Effect<void, never, Req>;
package/src/Stack.ts CHANGED
@@ -6,7 +6,7 @@ import * as Layer from "effect/Layer";
6
6
  import * as Logger from "effect/Logger";
7
7
  import * as Option from "effect/Option";
8
8
  import { Path } from "effect/Path";
9
- import type { Scope } from "effect/Scope";
9
+ import * as Scope from "effect/Scope";
10
10
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
11
11
  import type { HttpClient } from "effect/unstable/http/HttpClient";
12
12
  import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
@@ -18,19 +18,18 @@ import { Profile, ProfileLive } from "./Auth/Profile.ts";
18
18
  import { Cli } from "./Cli/Cli.ts";
19
19
  import type { Input, InputProps } from "./Input.ts";
20
20
  import * as Output from "./Output.ts";
21
- import { ref } from "./Ref.ts";
22
21
  import type { ResourceBinding, ResourceLike } from "./Resource.ts";
23
22
  import { Stage } from "./Stage.ts";
24
23
  import type { State } from "./State/State.ts";
25
24
  import { loadConfigProvider } from "./Util/ConfigProvider.ts";
26
- import { taggedFunction } from "./Util/effect.ts";
25
+ import { effectClass, taggedFunction } from "./Util/effect.ts";
27
26
  import { fileLogger } from "./Util/FileLogger.ts";
28
27
  import { PlatformServices } from "./Util/PlatformServices.ts";
29
28
 
30
29
  export type StackServices =
31
30
  | Stack
32
31
  | Stage
33
- | Scope
32
+ | Scope.Scope
34
33
  | FileSystem
35
34
  | Path
36
35
  | AlchemyContext
@@ -46,7 +45,7 @@ export type StackEffect<A, Err = never, Req = never> = Effect.Effect<
46
45
  Err,
47
46
  | PlatformServices
48
47
  | HttpClient
49
- | Scope
48
+ | Scope.Scope
50
49
  | AuthProviders
51
50
  | AlchemyContext
52
51
  | Cli
@@ -98,6 +97,7 @@ export const Stack: Context.ServiceClass<
98
97
  (stackName: string): Effect.Effect<Self> & {
99
98
  new (_: never): Output.ToOutput<Shape>;
100
99
  make: <A, Req>(
100
+ options: StackProps<NoInfer<Req>>,
101
101
  effect: Effect.Effect<A, never, Req>,
102
102
  ) => Effect.Effect<CompiledStack<A>>;
103
103
  stage: {
@@ -114,36 +114,46 @@ export const Stack: Context.ServiceClass<
114
114
  taggedFunction(
115
115
  Context.Service<Stack, Omit<StackSpec, "output">>()("Stack"),
116
116
  <A, Req>(
117
- stackName: string,
118
- options: {
119
- providers: Layer.Layer<NoInfer<Req>, never, StackServices>;
120
- state: Layer.Layer<State, never, StackServices>;
121
- },
122
- eff: Effect.Effect<A, never, Req>,
123
- ) =>
124
- eff.pipe(
117
+ stackName?: string,
118
+ options?: StackProps<NoInfer<Req>>,
119
+ eff?: Effect.Effect<A, never, Req>,
120
+ ) => {
121
+ if (!stackName) {
122
+ return (stackName: string) =>
123
+ Object.assign(
124
+ // by default, reference the stack at the "current" stage of the importer
125
+ Output.stackRef<A>(stackName).pipe(effectClass),
126
+ {
127
+ stage: createStageProxy(stackName),
128
+ make: <Req = never>(
129
+ options: StackProps<NoInfer<Req>>,
130
+ eff: Effect.Effect<A, never, Req>,
131
+ ) => Stack(stackName, options, eff),
132
+ },
133
+ );
134
+ }
135
+ return eff!.pipe(
125
136
  make({
126
137
  name: stackName,
127
- ...options,
138
+ ...options!,
128
139
  }),
129
140
  (eff) =>
130
141
  Object.assign(eff, {
131
- stage: new Proxy(
132
- {},
133
- {
134
- get: (_, stage: string) =>
135
- ref({
136
- stack: stackName,
137
- stage,
138
- id: stackName,
139
- }),
140
- },
141
- ),
142
+ stage: createStageProxy(stackName),
142
143
  }),
143
- ),
144
+ );
145
+ },
144
146
  ),
145
147
  ) as any;
146
148
 
149
+ const createStageProxy = (stackName: string) =>
150
+ new Proxy(
151
+ {},
152
+ {
153
+ get: (_, stage: string) => Output.stackRef(stackName, { stage }),
154
+ },
155
+ );
156
+
147
157
  export interface StackSpec<Output = any> {
148
158
  name: string;
149
159
  stage: string;
@@ -283,9 +293,18 @@ export const evalStack = <A, B, Err, Req>(
283
293
  options: {
284
294
  stage: string;
285
295
  dev?: boolean;
296
+ /**
297
+ * Optional caller-supplied scope. When provided, scoped resources
298
+ * (e.g. the dev sidecar process) live until the caller closes this
299
+ * scope instead of being torn down when `evalStack` resolves. Used
300
+ * by the test harness so the sidecar survives across `beforeAll`,
301
+ * tests, and `afterAll`. When omitted, behaves as before with a
302
+ * private scope closed on completion.
303
+ */
304
+ scope?: Scope.Scope;
286
305
  },
287
- ) =>
288
- Effect.gen(function* () {
306
+ ) => {
307
+ const body = Effect.gen(function* () {
289
308
  const stack = yield* effect;
290
309
  const configProvider = yield* loadConfigProvider(Option.none());
291
310
 
@@ -305,5 +324,9 @@ export const evalStack = <A, B, Err, Req>(
305
324
  ),
306
325
  Effect.provide(Layer.succeed(Stage, options.stage)),
307
326
  Effect.provide(Layer.provideMerge(alchemy({ dev: options.dev }), platform)),
308
- Effect.scoped,
309
327
  );
328
+
329
+ return options.scope === undefined
330
+ ? Effect.scoped(body)
331
+ : Scope.provide(body, options.scope);
332
+ };
@@ -155,6 +155,25 @@ export const GetReplacedResources = HttpApiEndpoint.get(
155
155
  },
156
156
  );
157
157
 
158
+ export const GetStackOutput = HttpApiEndpoint.get(
159
+ "getStackOutput",
160
+ "/state/stacks/:stack/stages/:stage/output",
161
+ {
162
+ params: StackStage,
163
+ success: Schema.UndefinedOr(ResourceStateSchema),
164
+ },
165
+ );
166
+
167
+ export const SetStackOutput = HttpApiEndpoint.put(
168
+ "setStackOutput",
169
+ "/state/stacks/:stack/stages/:stage/output",
170
+ {
171
+ params: StackStage,
172
+ payload: ResourceStateSchema,
173
+ success: ResourceStateSchema,
174
+ },
175
+ );
176
+
158
177
  /** Response shape for the unauthenticated `/version` probe. */
159
178
  export const VersionResponse = Schema.Struct({
160
179
  version: Schema.Number,
@@ -179,6 +198,8 @@ export class StateGroup extends HttpApiGroup.make("state")
179
198
  .add(DeleteState)
180
199
  .add(GetReplacedResources)
181
200
  .add(DeleteStack)
201
+ .add(GetStackOutput)
202
+ .add(SetStackOutput)
182
203
  .middleware(StateAuth) {}
183
204
 
184
205
  export class VersionGroup extends HttpApiGroup.make("version").add(
@@ -129,6 +129,27 @@ export const makeHttpStateStore = ({
129
129
  query: request.stage === undefined ? {} : { stage: request.stage },
130
130
  })
131
131
  .pipe(Effect.asVoid, mapStateStoreError),
132
+ getOutput: (request) =>
133
+ state
134
+ .getStackOutput({
135
+ params: { stack: request.stack, stage: request.stage },
136
+ })
137
+ .pipe(
138
+ Effect.map((s) =>
139
+ s == null ? undefined : reviveStateRecursive(s),
140
+ ),
141
+ mapStateStoreError,
142
+ ),
143
+ setOutput: (request) =>
144
+ state
145
+ .setStackOutput({
146
+ params: { stack: request.stack, stage: request.stage },
147
+ payload: encodeState(request.value as any),
148
+ })
149
+ .pipe(
150
+ Effect.map(() => request.value),
151
+ mapStateStoreError,
152
+ ),
132
153
  };
133
154
  return service;
134
155
  });
@@ -13,14 +13,18 @@ export const inMemoryState = (
13
13
  StackId,
14
14
  Record<StageId, Record<Fqn, ResourceState>>
15
15
  > = {},
16
+ initialOutputs: Record<StackId, Record<StageId, unknown>> = {},
16
17
  ) =>
17
18
  Layer.effect(
18
19
  State,
19
- Effect.succeed(InMemoryService(initialState)).pipe(recordStateStoreInit),
20
+ Effect.succeed(InMemoryService(initialState, initialOutputs)).pipe(
21
+ recordStateStoreInit,
22
+ ),
20
23
  );
21
24
 
22
25
  export const InMemoryService = (
23
26
  state: Record<StackId, Record<StageId, Record<Fqn, ResourceState>>> = {},
27
+ outputs: Record<StackId, Record<StageId, unknown>> = {},
24
28
  ) =>
25
29
  State.of({
26
30
  id: "inmemory",
@@ -60,12 +64,13 @@ export const InMemoryService = (
60
64
  stage: string;
61
65
  fqn: string;
62
66
  value: V;
63
- }) => {
64
- const stackState = (state[stack] ??= {});
65
- const stageState = (stackState[stage] ??= {});
66
- stageState[fqn] = value;
67
- return Effect.succeed(value);
68
- },
67
+ }) =>
68
+ Effect.sync(() => {
69
+ const stackState = (state[stack] ??= {});
70
+ const stageState = (stackState[stage] ??= {});
71
+ stageState[fqn] = value;
72
+ return value;
73
+ }),
69
74
  delete: ({
70
75
  stack,
71
76
  stage,
@@ -74,7 +79,7 @@ export const InMemoryService = (
74
79
  stack: string;
75
80
  stage: string;
76
81
  fqn: string;
77
- }) => Effect.succeed(delete state[stack]?.[stage]?.[fqn]),
82
+ }) => Effect.sync(() => delete state[stack]?.[stage]?.[fqn]),
78
83
  deleteStack: ({ stack, stage }: { stack: string; stage?: string }) =>
79
84
  Effect.sync(() => {
80
85
  if (stage === undefined) {
@@ -87,4 +92,20 @@ export const InMemoryService = (
87
92
  Effect.succeed(
88
93
  Array.from(Object.keys(state[stack]?.[stage] ?? {}) ?? []),
89
94
  ),
95
+ getOutput: ({ stack, stage }: { stack: string; stage: string }) =>
96
+ Effect.succeed(outputs[stack]?.[stage]),
97
+ setOutput: ({
98
+ stack,
99
+ stage,
100
+ value,
101
+ }: {
102
+ stack: string;
103
+ stage: string;
104
+ value: unknown;
105
+ }) =>
106
+ Effect.sync(() => {
107
+ const stackOutputs = (outputs[stack] ??= {});
108
+ stackOutputs[stage] = value;
109
+ return value;
110
+ }),
90
111
  });
@@ -46,6 +46,9 @@ export const makeLocalState = () =>
46
46
  fqn: string;
47
47
  }) => path.join(stateDir, stack, stage, `${encodeFqn(fqn)}.json`);
48
48
 
49
+ const outputFile = ({ stack, stage }: { stack: string; stage: string }) =>
50
+ path.join(stateDir, stack, stage, `__stack_output__.json`);
51
+
49
52
  const created = new Set<string>();
50
53
 
51
54
  const ensure = (dir: string) =>
@@ -107,11 +110,32 @@ export const makeLocalState = () =>
107
110
  list: (request) =>
108
111
  fs.readDirectory(stageDir(request)).pipe(
109
112
  recover,
110
- Effect.map(
111
- (files) =>
112
- files?.map((file) => decodeFqn(file.replace(/\.json$/, ""))) ??
113
- [],
113
+ Effect.map((files) =>
114
+ (files ?? [])
115
+ // Filter the bookkeeping file before decoding — `decodeFqn`
116
+ // replaces `__` with `/`, which would turn the literal name
117
+ // `__stack_output__` into `/stack_output/` and slip past the
118
+ // filter, leaving the engine to look up a non-existent
119
+ // resource.
120
+ .filter((file) => file !== "__stack_output__.json")
121
+ .map((file) => decodeFqn(file.replace(/\.json$/, ""))),
122
+ ),
123
+ ),
124
+ getOutput: (request) =>
125
+ fs.readFile(outputFile(request)).pipe(
126
+ Effect.map((file) => JSON.parse(file.toString(), reviveState)),
127
+ recover,
128
+ ),
129
+ setOutput: (request) =>
130
+ ensure(stageDir(request)).pipe(
131
+ Effect.flatMap(() =>
132
+ fs.writeFileString(
133
+ outputFile(request),
134
+ JSON.stringify(encodeState(request.value as any), null, 2),
135
+ ),
114
136
  ),
137
+ recover,
138
+ Effect.map(() => request.value),
115
139
  ),
116
140
  };
117
141
  return state;
@@ -87,4 +87,25 @@ export interface StateService {
87
87
  stack: string;
88
88
  stage: string;
89
89
  }): Effect.Effect<readonly string[], StateStoreError, never>;
90
+ /**
91
+ * Read the persisted stack output for `(stack, stage)`. Returns
92
+ * `undefined` when the stack has not been deployed (or has been
93
+ * destroyed) at this stage.
94
+ *
95
+ * Stack outputs are written by `apply` once the deploy succeeds and
96
+ * read by cross-stack references (`yield* OtherStack` /
97
+ * `OtherStack.stage.<name>` / `Output.stackRef(...)`).
98
+ */
99
+ getOutput(request: {
100
+ stack: string;
101
+ stage: string;
102
+ }): Effect.Effect<unknown, StateStoreError, never>;
103
+ /**
104
+ * Persist the resolved stack output for `(stack, stage)`.
105
+ */
106
+ setOutput(request: {
107
+ stack: string;
108
+ stage: string;
109
+ value: unknown;
110
+ }): Effect.Effect<unknown, StateStoreError, never>;
90
111
  }
package/src/Test/Bun.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import bun from "bun:test";
2
2
  import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Scope from "effect/Scope";
3
5
  import type { HookOptions } from "node:test";
4
6
 
5
7
  import type { AlchemyContext } from "../AlchemyContext.ts";
6
8
  import type { CompiledStack } from "../Stack.ts";
7
9
  import type { Stage } from "../Stage.ts";
10
+ import { installLocalhostDns } from "../Util/LocalhostDns.ts";
8
11
  import * as Core from "./Core.ts";
9
12
 
13
+ installLocalhostDns();
14
+
10
15
  export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
11
16
  export type ScratchStack = Core.ScratchStack;
12
17
  export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
@@ -106,7 +111,15 @@ const DEFAULT_HOOK_TIMEOUT: HookOptions = { timeout: 120_000 };
106
111
  * ```
107
112
  */
108
113
  export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
109
- const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options);
114
+ // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
115
+ // Scoped resources in dev mode (the Cloudflare sidecar process and its
116
+ // workerd children) must outlive a single `Effect.runPromise` boundary,
117
+ // otherwise the proxy is killed the moment `beforeAll(deploy(Stack))`
118
+ // resolves and every later `HttpClient.get(workerUrl)` hits a dead port.
119
+ // The scope is closed by `destroy(...)` (or never — the next test run
120
+ // reclaims any leaked sidecar via the lock file).
121
+ const sharedScope = Scope.makeUnsafe("sequential");
122
+ const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options, sharedScope);
110
123
 
111
124
  const test = ((name, eff, opts) => {
112
125
  bun.test(name, () => runEff(eff), opts);
@@ -130,10 +143,11 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
130
143
  fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
131
144
  ) => {
132
145
  const scratch = Core.scratchStack(options, name);
133
- return Core.run(Core.withProviders(fn(scratch), options, scratch.name), {
134
- ...options,
135
- state: scratch.state,
136
- });
146
+ return Core.run(
147
+ Core.withProviders(fn(scratch), options, scratch.name),
148
+ { ...options, state: scratch.state },
149
+ sharedScope,
150
+ );
137
151
  };
138
152
 
139
153
  const provider = ((name, fn, opts) => {
@@ -175,13 +189,35 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
175
189
  bun.afterEach(() => runEff(eff), hookOptions);
176
190
  };
177
191
 
192
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
193
+ // for each worker. We close the shared scope only AFTER destroy completes.
194
+ // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
195
+ // the destroy wrapper AND the fallback cleanup hook below to call it.
196
+ const closeScope = Effect.suspend(() =>
197
+ Scope.close(sharedScope, Exit.void),
198
+ ).pipe(Effect.ignore);
199
+
200
+ // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
201
+ // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
202
+ // child process leaks past the test process. Register an `afterAll` that
203
+ // closes it. We defer registration to a microtask so it runs AFTER any
204
+ // user-registered `afterAll` (including `destroy(Stack)`); bun runs
205
+ // afterAll hooks in registration order.
206
+ queueMicrotask(() => {
207
+ bun.afterAll(() => Effect.runPromise(closeScope), DEFAULT_HOOK_TIMEOUT);
208
+ });
209
+
178
210
  return {
179
211
  test,
180
212
  beforeAll,
181
213
  beforeEach,
182
214
  afterAll,
183
215
  afterEach,
184
- deploy: (stack, callOpts) => Core.deploy(options, stack, callOpts),
185
- destroy: (stack, callOpts) => Core.destroy(options, stack, callOpts),
216
+ deploy: (stack, callOpts) =>
217
+ Core.deploy(options, stack, { ...callOpts, scope: sharedScope }),
218
+ destroy: (stack, callOpts) =>
219
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
220
+ Effect.ensuring(closeScope),
221
+ ),
186
222
  };
187
223
  };