From f2b493c31e3568d590e35dd342e4c012be7a22b9 Mon Sep 17 00:00:00 2001 From: John Royal Date: Thu, 18 Jun 2026 16:27:09 -0400 Subject: [PATCH] chore(local): allow Artifacts.cached in RpcProvider --- packages/alchemy/src/Local/RpcProvider.ts | 10 ++- packages/alchemy/src/Local/RpcServer.ts | 10 ++- packages/alchemy/src/Stack.ts | 4 +- .../alchemy/test/Cloudflare/Providers.test.ts | 3 + .../alchemy/test/Local/RpcProvider.test.ts | 62 +++++++++++++++++-- packages/alchemy/test/test.resources.ts | 3 +- 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/alchemy/src/Local/RpcProvider.ts b/packages/alchemy/src/Local/RpcProvider.ts index 3d5533d4e..0e2e7e34d 100644 --- a/packages/alchemy/src/Local/RpcProvider.ts +++ b/packages/alchemy/src/Local/RpcProvider.ts @@ -6,6 +6,7 @@ import * as Predicate from "effect/Predicate"; import type { Scope } from "effect/Scope"; import * as Stream from "effect/Stream"; import { AlchemyContext } from "../AlchemyContext.ts"; +import { Artifacts, ArtifactStore, makeScopedArtifacts } from "../Artifacts.ts"; import { InstanceId } from "../InstanceId.ts"; import type { Platform } from "../Platform.ts"; import * as Provider from "../Provider.ts"; @@ -188,6 +189,7 @@ export const effect = < const client = yield* Effect.serviceOption(RpcProviderProxy); const context = yield* Effect.context(); const stack = yield* Stack; + const store = yield* ArtifactStore; if (client._tag === "None") { const provider = withDefaultList(yield* eff); @@ -203,7 +205,13 @@ export const effect = < layerFallback(Stage, stack.stage), Predicate.hasProperty(args[0], "instanceId") && Predicate.isString(args[0].instanceId) - ? layerFallback(InstanceId, args[0].instanceId) + ? Layer.merge( + layerFallback(InstanceId, args[0].instanceId), + Layer.succeed( + Artifacts, + makeScopedArtifacts(store, args[0].instanceId), + ), + ) : Layer.empty, ); return result.pipe( diff --git a/packages/alchemy/src/Local/RpcServer.ts b/packages/alchemy/src/Local/RpcServer.ts index aed964cef..4f48bc453 100644 --- a/packages/alchemy/src/Local/RpcServer.ts +++ b/packages/alchemy/src/Local/RpcServer.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import type * as Scope from "effect/Scope"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import type { HttpClient } from "effect/unstable/http/HttpClient"; +import { ArtifactStore, createArtifactStore } from "../Artifacts.ts"; import type { ProviderService } from "../Provider.ts"; import type { ResourceLike } from "../Resource.ts"; import { @@ -78,12 +79,19 @@ export const launch = ( | RpcServerEnvironment.RpcEnvironmentServices | PlatformServices | HttpClient + | ArtifactStore >, ) => serverPlatformLayer.pipe( Layer.provide(providers), Layer.provide(RpcServerEnvironment.fromEnv()), - Layer.provide(Layer.merge(PlatformServices, FetchHttpClient.layer)), + Layer.provide( + Layer.mergeAll( + PlatformServices, + FetchHttpClient.layer, + Layer.sync(ArtifactStore, createArtifactStore), + ), + ), Layer.launch, Effect.scoped, runMain, diff --git a/packages/alchemy/src/Stack.ts b/packages/alchemy/src/Stack.ts index 679127500..0f1dee018 100644 --- a/packages/alchemy/src/Stack.ts +++ b/packages/alchemy/src/Stack.ts @@ -13,7 +13,7 @@ import type { HttpClient } from "effect/unstable/http/HttpClient"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { ActionLike } from "./Action.ts"; import { AlchemyContext, AlchemyContextLive } from "./AlchemyContext.ts"; -import { provideFreshArtifactStore } from "./Artifacts.ts"; +import { type ArtifactStore, provideFreshArtifactStore } from "./Artifacts.ts"; import { AuthProviders } from "./Auth/AuthProvider.ts"; import { CredentialsStore, CredentialsStoreLive } from "./Auth/Credentials.ts"; import { AlchemyProfile, ProfileLive } from "./Auth/Profile.ts"; @@ -39,6 +39,7 @@ export type StackServices = | ChildProcessSpawner | AuthProviders | AlchemyProfile + | ArtifactStore | CredentialsStore | Cli; @@ -53,6 +54,7 @@ export type StackEffect = Effect.Effect< | Cli | AlchemyProfile | CredentialsStore + | ArtifactStore | State | Req >; diff --git a/packages/alchemy/test/Cloudflare/Providers.test.ts b/packages/alchemy/test/Cloudflare/Providers.test.ts index b02a11be4..32b5d7744 100644 --- a/packages/alchemy/test/Cloudflare/Providers.test.ts +++ b/packages/alchemy/test/Cloudflare/Providers.test.ts @@ -1,4 +1,5 @@ import { AlchemyContext } from "@/AlchemyContext.ts"; +import { ArtifactStore, createArtifactStore } from "@/Artifacts.ts"; import { AuthProviders } from "@/Auth/AuthProvider.ts"; import * as Cloudflare from "@/Cloudflare"; import { Stack } from "@/Stack.ts"; @@ -10,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import { v4 as uuidv4 } from "uuid"; + it.live( "building the Cloudflare provider layers should not fail for unknown profile", () => @@ -38,6 +40,7 @@ it.live( ALCHEMY_PROFILE: `non-existent-${uuidv4()}`, }), ), + Layer.sync(ArtifactStore, createArtifactStore), NodeServices.layer, FetchHttpClient.layer, ), diff --git a/packages/alchemy/test/Local/RpcProvider.test.ts b/packages/alchemy/test/Local/RpcProvider.test.ts index 49e9ecd61..0419b8870 100644 --- a/packages/alchemy/test/Local/RpcProvider.test.ts +++ b/packages/alchemy/test/Local/RpcProvider.test.ts @@ -1,4 +1,5 @@ import { AlchemyContext } from "@/AlchemyContext.ts"; +import * as Artifacts from "@/Artifacts.ts"; import { InstanceId } from "@/InstanceId.ts"; import * as RpcProvider from "@/Local/RpcProvider.ts"; import type { ProviderService } from "@/Provider.ts"; @@ -13,7 +14,7 @@ import * as Stream from "effect/Stream"; interface TestResource extends Resource< "Local.RpcProvider.Test", {}, - { ok: boolean } + { ok: boolean; artifact: string } > {} const TestResource = Resource("Local.RpcProvider.Test"); @@ -23,6 +24,7 @@ interface Capture { stack?: StackShape; stage?: string; instanceId?: string; + artifact?: string; } const defaultStack: StackShape = { @@ -49,7 +51,7 @@ describe("Local.RpcProvider.effect", () => { bindings: [], }), ); - expect(result).toEqual({ ok: true }); + expect(result).toMatchObject({ ok: true }); expect(capture.stack).toBe(defaultStack); expect(capture.stage).toBe(defaultStack.stage); expect(capture.instanceId).toBe("inst-from-arg"); @@ -97,7 +99,7 @@ describe("Local.RpcProvider.effect", () => { id: "r", instanceId: "inst-from-arg", props: {}, - output: { ok: true }, + output: { ok: true, artifact: "artifact" }, }).pipe(Stream.runCollect), ); expect(items.length).toBe(1); @@ -128,18 +130,69 @@ describe("Local.RpcProvider.effect", () => { expect(capture.instanceId).toBeUndefined(); }), ); + + it.effect("caches artifacts", () => + Effect.gen(function* () { + const [capture, result] = yield* useProvider((provider) => + provider.diff!({ + id: "r", + news: {}, + olds: undefined, + output: undefined, + oldBindings: [], + newBindings: [], + instanceId: "inst-from-arg", + }).pipe( + Effect.andThen( + Effect.all({ + sameInstanceId: provider.reconcile({ + id: "r", + instanceId: "inst-from-arg", + news: {}, + olds: undefined, + output: undefined, + session: undefined as any, + bindings: [], + }), + differentInstanceId: provider.reconcile({ + id: "r", + instanceId: "inst-from-arg-2", + news: {}, + olds: undefined, + output: undefined, + session: undefined as any, + bindings: [], + }), + }), + ), + ), + ); + expect(capture.artifact).toBeDefined(); + expect(result.sameInstanceId.artifact).toBeDefined(); + expect(result.differentInstanceId.artifact).toBeDefined(); + expect(capture.artifact).toBe(result.sameInstanceId.artifact); + expect(capture.artifact).not.toBe(result.differentInstanceId.artifact); + }), + ); }); +const artifact = Effect.sync(() => crypto.randomUUID()).pipe( + Artifacts.cached("artifact"), +); + const TestResourceProvider = (capture: Capture) => RpcProvider.effect( TestResource, "ignored://entry", Effect.succeed({ + diff: Effect.fn(function* () { + capture.artifact = yield* artifact; + }), reconcile: Effect.fn(function* (_input: any) { capture.stack = yield* Stack; capture.stage = yield* Stage; capture.instanceId = yield* InstanceId; - return { ok: true }; + return { ok: true, artifact: yield* artifact }; }), tail: (_input: any) => Stream.fromEffect( @@ -167,6 +220,7 @@ const useProvider = ( Layer.mergeAll( Layer.succeed(Stack, defaultStack), Layer.succeed(Stage, defaultStack.stage), + Layer.sync(Artifacts.ArtifactStore, Artifacts.createArtifactStore), Layer.succeed(AlchemyContext, { dotAlchemy: "/tmp/.alchemy", dev: false, diff --git a/packages/alchemy/test/test.resources.ts b/packages/alchemy/test/test.resources.ts index c55203639..3e956aee9 100644 --- a/packages/alchemy/test/test.resources.ts +++ b/packages/alchemy/test/test.resources.ts @@ -4,8 +4,8 @@ import * as Provider from "@/Provider.ts"; import { Resource, type ResourceBinding } from "@/Resource"; import * as State from "@/State/index"; import { isUnknown } from "@/Util/unknown"; -import * as Context from "effect/Context"; import { Data } from "effect"; +import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -639,6 +639,7 @@ export const OverrideStablesResource = Resource( export const overrideStablesResourceProvider = () => Provider.succeed(OverrideStablesResource, { + list: () => Effect.succeed([]), stables: ["providerStable", "sharedStable"], diff: Effect.fn(function* ({ news = {}, olds = {} }) { if (!isResolved(news)) return undefined;