diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/Connect.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/Connect.ts index 54e503b0f7..bdb820dbaf 100644 --- a/packages/alchemy/src/Cloudflare/Hyperdrive/Connect.ts +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/Connect.ts @@ -4,6 +4,7 @@ import * as Redacted from "effect/Redacted"; import * as Binding from "../../Binding.ts"; import type { RuntimeContext } from "../../RuntimeContext.ts"; import type { Connection } from "./Connection.ts"; +import type { Ref } from "./Ref.ts"; /** * A typed accessor for a Cloudflare Hyperdrive runtime binding inside a @@ -22,8 +23,9 @@ import type { Connection } from "./Connection.ts"; * @category Storage & Databases */ /** - * Bind a {@link Connection} to a Worker and obtain the Effect-native - * Hyperdrive client (connection string, host, port, …). + * Bind a {@link Connection} (or a read-only {@link Ref} to an existing + * config) to a Worker and obtain the Effect-native Hyperdrive client + * (connection string, host, port, …). * * `Connect` is a single identifier that is simultaneously the binding's Context * tag, its type, and the callable — `yield* Cloudflare.Hyperdrive.Connect(conn)`. @@ -41,7 +43,7 @@ import type { Connection } from "./Connection.ts"; export interface Connect extends Binding.Service< Connect, "Cloudflare.Hyperdrive.Connect", - (connection: Connection) => Effect.Effect + (connection: Connection | Ref) => Effect.Effect > {} export const Connect = Binding.Service( diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts index 36fe9f9477..5b7f193a85 100644 --- a/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts @@ -7,6 +7,7 @@ import { Worker, WorkerEnvironment } from "../Workers/Worker.ts"; import { Connect, type ConnectClient } from "./Connect.ts"; import type { Connection } from "./Connection.ts"; import { defaultPort, type DevOrigin } from "./Connection.ts"; +import { isHyperdriveRef, type Ref } from "./Ref.ts"; export const ConnectBinding = Layer.effect( Connect, @@ -14,7 +15,7 @@ export const ConnectBinding = Layer.effect( const env = yield* WorkerEnvironment; const host = yield* Worker; - return Effect.fn(function* (connection: Connection) { + return Effect.fn(function* (connection: Connection | Ref) { if (!globalThis.__ALCHEMY_RUNTIME__) { yield* host.bind`${connection}`({ bindings: [ @@ -24,7 +25,9 @@ export const ConnectBinding = Layer.effect( id: connection.hyperdriveId as unknown as string, }, ], - hyperdrives: getHyperdriveDevOrigin(connection), + hyperdrives: isHyperdriveRef(connection) + ? getHyperdriveRefDevOrigin(connection) + : getHyperdriveDevOrigin(connection), }); } @@ -87,3 +90,29 @@ export const getHyperdriveDevOrigin = (connection: Connection) => { }), ) as unknown as Record>; }; + +/** + * Dev-origin record for a read-only {@link Ref}. The Cloudflare API never + * returns the origin credentials of an existing config, so the ref can only + * contribute a local passthrough origin when its `dev` override is set; + * without one it contributes no entry and the local worker provider rejects + * the binding with an actionable error in dev mode. + */ +export const getHyperdriveRefDevOrigin = (ref: Ref) => + Output.map( + Output.all(ref.hyperdriveId, ref.dev), + ([id, dev]): Record> => + dev + ? { + [id]: { + scheme: dev.scheme, + host: dev.host, + port: dev.port ?? defaultPort(dev.scheme), + user: dev.user, + database: dev.database, + password: dev.password, + sslmode: dev.sslmode ?? "prefer", + }, + } + : {}, + ) as unknown as Record>; diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/Connection.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/Connection.ts index d7e7fa6970..f23bc3b8e9 100644 --- a/packages/alchemy/src/Cloudflare/Hyperdrive/Connection.ts +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/Connection.ts @@ -239,7 +239,7 @@ export const ProviderLive = () => ); } const name = yield* createConfigName(id, olds?.name); - const match = yield* findByName(name); + const match = yield* findConfigByName(name); if (match) { return { hyperdriveId: match.id, @@ -286,7 +286,7 @@ export const ProviderLive = () => .pipe( Effect.catchTag("InvalidHyperdriveConfig", (originalError) => Effect.gen(function* () { - const match = yield* findByName(name); + const match = yield* findConfigByName(name); if (!match) { return yield* Effect.fail(originalError); } @@ -372,7 +372,11 @@ const createConfigName = (id: string, name: string | undefined) => return yield* createPhysicalName({ id, lowercase: true }); }); -const findByName = (name: string) => +/** + * Look up an existing Hyperdrive config by name in the ambient account. + * Returns undefined when no config matches. + */ +export const findConfigByName = (name: string) => Effect.gen(function* () { const { accountId } = yield* yield* CloudflareEnvironment; return yield* hyperdrive.listConfigs.items({ accountId }).pipe( diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/Ref.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/Ref.ts new file mode 100644 index 0000000000..b2e651d8fd --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/Ref.ts @@ -0,0 +1,175 @@ +import * as hyperdrive from "@distilled.cloud/cloudflare/hyperdrive"; +import * as Effect from "effect/Effect"; + +import * as Provider from "../../Provider.ts"; +import { isResourceOfType, Resource } from "../../Resource.ts"; +import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; +import type { Providers } from "../Providers.ts"; +import { findConfigByName, type DevOrigin } from "./Connection.ts"; + +export type RefProps = { + /** + * Cloud id of the existing Hyperdrive configuration. Takes precedence + * over `name` when both are provided. + */ + hyperdriveId?: string; + /** + * Name of the existing Hyperdrive configuration, used to look it up when + * `hyperdriveId` is not provided. + */ + name?: string; + /** + * Local development override. The Cloudflare API never returns the origin + * credentials of an existing config, so `alchemy dev` can only emulate + * the binding when a `dev` origin is declared here. + */ + dev?: DevOrigin; +}; + +export type Ref = Resource< + "Cloudflare.Hyperdrive.Ref", + RefProps, + { + hyperdriveId: string; + name: string; + accountId: string; + dev: DevOrigin | undefined; + }, + never, + Providers +>; + +/** + * A read-only reference to an existing Cloudflare Hyperdrive configuration. + * + * Binds a config created outside of Alchemy (dashboard, another stack, + * another tool) to a Worker without ever managing its lifecycle: deploys + * only observe the config, and destroying the stack leaves it untouched. + * Use {@link Connection} when Alchemy should own the config. + * + * ### Referencing an existing config + * **Example:** By cloud id + * ```typescript + * const hd = yield* Cloudflare.Hyperdrive.Ref("shared-db", { + * hyperdriveId: "a76a99bc342644deb02c38d66082262a", + * }); + * ``` + * + * **Example:** By name + * ```typescript + * const hd = yield* Cloudflare.Hyperdrive.Ref("shared-db", { + * name: "shared-mysql", + * }); + * ``` + * + * ### Binding to a Worker + * **Example:** Using the referenced config inside a Worker + * ```typescript + * const hd = yield* Cloudflare.Hyperdrive.Connect(SharedDb); + * const url = yield* hd.connectionString; + * ``` + * + * ### Local development + * **Example:** Dev origin override + * ```typescript + * const hd = yield* Cloudflare.Hyperdrive.Ref("shared-db", { + * name: "shared-mysql", + * dev: { + * scheme: "mysql", + * host: "localhost", + * port: 3306, + * database: "app", + * user: "root", + * password: yield* Config.redacted("DEV_DB_PASSWORD"), + * }, + * }); + * ``` + * + * @resource + * @product Hyperdrive + * @category Storage & Databases + */ +export const Ref = Resource("Cloudflare.Hyperdrive.Ref"); + +export const isHyperdriveRef = (value: unknown): value is Ref => + isResourceOfType(value, "Cloudflare.Hyperdrive.Ref"); + +/** + * Observe-only provider: `reconcile` resolves the referenced config from + * the cloud and echoes it into attributes, and `delete` only drops the + * state row — the config itself is never created, updated, or deleted. + */ +export const RefProvider = () => + Provider.succeed(Ref, { + read: Effect.fn(function* ({ output, olds }) { + const { accountId } = yield* yield* CloudflareEnvironment; + const hyperdriveId = output?.hyperdriveId ?? olds?.hyperdriveId; + if (hyperdriveId) { + return yield* hyperdrive.getConfig({ accountId, hyperdriveId }).pipe( + Effect.map((config) => ({ + hyperdriveId: config.id, + name: config.name, + accountId, + dev: output?.dev, + })), + Effect.catchTag("HyperdriveConfigNotFound", () => + Effect.succeed(undefined), + ), + ); + } + if (olds?.name) { + const match = yield* findConfigByName(olds.name); + if (match) { + return { + hyperdriveId: match.id, + name: match.name, + accountId, + dev: output?.dev, + }; + } + } + return undefined; + }), + reconcile: Effect.fn(function* ({ id, news }) { + const { accountId } = yield* yield* CloudflareEnvironment; + // Resolve from `news` (not `output`) so retargeting the ref to a + // different config is an ordinary update. + if (news.hyperdriveId) { + const config = yield* hyperdrive.getConfig({ + accountId, + hyperdriveId: news.hyperdriveId, + }); + return { + hyperdriveId: config.id, + name: config.name, + accountId, + dev: news.dev, + }; + } + if (news.name) { + const match = yield* findConfigByName(news.name); + if (!match) { + return yield* Effect.fail( + new Error( + `Hyperdrive.Ref "${id}": no Hyperdrive config named "${news.name}" exists in account ${accountId}`, + ), + ); + } + return { + hyperdriveId: match.id, + name: match.name, + accountId, + dev: news.dev, + }; + } + return yield* Effect.fail( + new Error( + `Hyperdrive.Ref "${id}" requires \`hyperdriveId\` or \`name\` to identify the existing config`, + ), + ); + }), + delete: Effect.fn(function* () { + // Read-only reference: the underlying config is never owned by this + // resource, so destroy only drops the state row. + }), + }); diff --git a/packages/alchemy/src/Cloudflare/Hyperdrive/index.ts b/packages/alchemy/src/Cloudflare/Hyperdrive/index.ts index 3335e918b6..96163cd656 100644 --- a/packages/alchemy/src/Cloudflare/Hyperdrive/index.ts +++ b/packages/alchemy/src/Cloudflare/Hyperdrive/index.ts @@ -1,3 +1,4 @@ export * from "./Connect.ts"; export * from "./ConnectBinding.ts"; export * from "./Connection.ts"; +export * from "./Ref.ts"; diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 42091a2586..6681a07984 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -249,6 +249,7 @@ export const providers = () => Healthcheck.Healthcheck, HostnameTlsSetting.HostnameTlsSetting, Hyperdrive.Connection, + Hyperdrive.Ref, Iam.ResourceGroup, Iam.UserGroup, Iam.UserGroupMembership, @@ -514,6 +515,7 @@ export const providers = () => HostnameTlsSetting.HostnameTlsSettingProvider(), Hyperdrive.ConnectionProvider(), Hyperdrive.ConnectionProvider(), + Hyperdrive.RefProvider(), Iam.ResourceGroupProvider(), Iam.UserGroupMembershipProvider(), Iam.UserGroupProvider(), diff --git a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts index df754db55a..60749aa55f 100644 --- a/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts +++ b/packages/alchemy/src/Cloudflare/Workers/InferEnv.ts @@ -122,7 +122,9 @@ export type GetBindingType = // `StreamNs.StreamBinding`). T extends StreamNs.StreamBinding ? StreamBinding - : T extends HyperdriveNs.Connection + : T extends + | HyperdriveNs.Connection + | HyperdriveNs.Ref ? Hyperdrive : T extends VersionMetadataBinding ? WorkerVersionMetadata diff --git a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts index 843e2432f9..daedfefacc 100644 --- a/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts +++ b/packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts @@ -402,6 +402,19 @@ export const LocalWorkerProvider = () => } durableObjectNamespaces[className].container = dev; } + for (const binding of bindingDescriptors) { + // A managed Hyperdrive Connection always contributes a passthrough + // origin; only a read-only Hyperdrive.Ref without a `dev` override + // can be missing one (the Cloudflare API never returns the origin + // credentials of an existing config). + if (binding.type === "hyperdrive" && !hyperdrives[binding.id]) { + return yield* Effect.die( + `Hyperdrive binding "${binding.name}" has no local dev origin: ` + + `a Hyperdrive.Ref can only run in dev mode when its \`dev\` ` + + `origin override is set.`, + ); + } + } const dev: | DevServerOptions | { readonly mode: "external"; readonly url?: string } = diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts index 48304e12bf..81f70c1909 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts @@ -22,8 +22,12 @@ import type { ContainerApplication } from "../Containers/ContainerApplication.ts import { isDatabase } from "../D1/Database.ts"; import { isSendEmail } from "../Email/SendEmail.ts"; import { isApp } from "../Flagship/App.ts"; -import { getHyperdriveDevOrigin } from "../Hyperdrive/ConnectBinding.ts"; +import { + getHyperdriveDevOrigin, + getHyperdriveRefDevOrigin, +} from "../Hyperdrive/ConnectBinding.ts"; import { isHyperdriveConnection } from "../Hyperdrive/Connection.ts"; +import { isHyperdriveRef } from "../Hyperdrive/Ref.ts"; import { isImages } from "../Images/Images.ts"; import { isNamespace as isKVNamespace } from "../KV/Namespace.ts"; import { isLegacyPipeline } from "../Pipelines/LegacyPipeline.ts"; @@ -222,7 +226,9 @@ export const bindWorkerAsyncBindings = Effect.fn(function* ( bindings: [resolvedBindingMeta], hyperdrives: isHyperdriveConnection(binding) ? getHyperdriveDevOrigin(binding) - : undefined, + : isHyperdriveRef(binding) + ? getHyperdriveRefDevOrigin(binding) + : undefined, // Dev-only local-emulation opt-out channel (like `hyperdrives`): // worker-only bindings and `SendEmail` descriptors piped through // `Alchemy.remote()` carry the internal `devRemote` flag on their @@ -542,7 +548,7 @@ const toBinding = ( name: bindingName, namespace: binding.name, }; - } else if (isHyperdriveConnection(binding)) { + } else if (isHyperdriveConnection(binding) || isHyperdriveRef(binding)) { return { type: "hyperdrive", name: bindingName, diff --git a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts index d572946f7e..38b0776cc4 100644 --- a/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts +++ b/packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts @@ -16,6 +16,7 @@ import type { Database as D1Database } from "../D1/Database.ts"; import { SendEmail } from "../Email/SendEmail.ts"; import type { App as FlagshipApp } from "../Flagship/App.ts"; import type { Connection as Hyperdrive } from "../Hyperdrive/Connection.ts"; +import type { Ref as HyperdriveRef } from "../Hyperdrive/Ref.ts"; import type { ImagesBinding } from "../Images/ImagesBinding.ts"; import type { Namespace } from "../KV/Namespace.ts"; import type { LegacyPipeline } from "../Pipelines/LegacyPipeline.ts"; @@ -193,6 +194,7 @@ export type WorkerBindingResource = | PipelinesStream | LegacyPipeline | Hyperdrive + | HyperdriveRef | VectorizeIndex | Secret | Worker diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.local.test.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.local.test.ts new file mode 100644 index 0000000000..c838fe6bdc --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.local.test.ts @@ -0,0 +1,145 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts"; +import * as Neon from "@/Neon/index.ts"; +import * as Test from "@/Test/Alchemy"; +import * as hyperdrive from "@distilled.cloud/cloudflare/hyperdrive"; +import { expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import HyperdriveRefLocalWorker, { + LocalHyperdriveRef, + REF_LOCAL_CONFIG_NAME, +} from "./fixtures/ref-local-worker.ts"; + +// `dev: true` runs local providers behind the RPC sidecar proxy, matching +// the process topology of the real `alchemy dev` command. Hyperdrive.Ref is +// mode-agnostic (a single observe-only provider), so it still resolves the +// real cloud config even in dev; only the worker binding is emulated. +const { test } = Test.make({ + providers: Layer.merge(Cloudflare.providers(), Neon.providers()), + dev: true, +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ + status: number; + body: string; +}> {} + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : res.text.pipe( + Effect.flatMap((body) => + Effect.fail(new WorkerNotReady({ status: res.status, body })), + ), + ), + ), + Effect.retry({ + while: (e): e is WorkerNotReady => e instanceof WorkerNotReady, + // Cap the backoff: an uncapped exponential turns a persistent + // non-200 into an apparent hang. + schedule: Schedule.max([ + Schedule.min([ + Schedule.exponential("500 millis"), + Schedule.spaced("2 seconds"), + ]), + Schedule.recurs(10), + ]), + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +type QueryBody = { + row: { sum: number; db: string }; + host: string; +}; + +test.provider( + "Ref with a dev override round-trips SQL through the local passthrough", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + // A real reachable Postgres origin for the config under reference. + const { origin, databaseName } = yield* stack.deploy( + Effect.gen(function* () { + const project = yield* Neon.Project("HyperdriveRefLocalProject"); + return { origin: project.origin, databaseName: project.databaseName }; + }), + ); + + // The referenced config is created OUTSIDE the stack; reruns + // self-heal by dropping any leftover from an interrupted run first. + const leftover = yield* Cloudflare.Hyperdrive.findConfigByName( + REF_LOCAL_CONFIG_NAME, + ); + if (leftover) { + yield* hyperdrive.deleteConfig({ + accountId, + hyperdriveId: leftover.id, + }); + } + const created = yield* hyperdrive.createConfig({ + accountId, + name: REF_LOCAL_CONFIG_NAME, + origin: { + scheme: origin.scheme, + host: origin.host, + port: origin.port, + database: origin.database, + user: origin.user, + password: Redacted.value(origin.password), + }, + }); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const ref = yield* LocalHyperdriveRef; + const worker = yield* HyperdriveRefLocalWorker; + return { ref, worker }; + }), + ); + + // Mode-agnostic observe: the ref resolves the REAL config even under + // dev, while the worker itself is served locally. + expect(deployed.ref.hyperdriveId).toBe(created.id); + expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/); + + // The binding passes through to the `dev` origin. + const body = (yield* getJsonReady( + `${deployed.worker.url}/query`, + )) as QueryBody; + expect(body.row.sum).toBe(2); + expect(body.row.db).toBe(databaseName); + expect(body.host).toBe(origin.host); + + yield* stack.destroy(); + + // Destroy never touches the referenced config. + const survived = yield* hyperdrive.getConfig({ + accountId, + hyperdriveId: created.id, + }); + expect(survived.id).toBe(created.id); + + yield* hyperdrive.deleteConfig({ accountId, hyperdriveId: created.id }); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.test.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.test.ts new file mode 100644 index 0000000000..4f1dfa6085 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/Ref.test.ts @@ -0,0 +1,146 @@ +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Neon from "@/Neon"; +import * as Test from "@/Test/Alchemy"; +import * as hyperdrive from "@distilled.cloud/cloudflare/hyperdrive"; +import { assert, expect } from "alchemy-test"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import HyperdriveRefWorker, { + REF_CONFIG_NAME, + RefByName, +} from "./fixtures/ref-worker.ts"; + +const { test } = Test.make({ + providers: Layer.merge(Cloudflare.providers(), Neon.providers()), +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ + status: number; + body: string; +}> {} + +const getJsonReady = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const res = yield* client.get(url).pipe( + Effect.flatMap((res) => + res.status === 200 + ? Effect.succeed(res) + : res.text.pipe( + Effect.flatMap((body) => + Effect.fail(new WorkerNotReady({ status: res.status, body })), + ), + ), + ), + Effect.retry({ + while: (e): e is WorkerNotReady => e instanceof WorkerNotReady, + // Bounded spaced schedule — rides out fresh workers.dev cold-start + // 404s without blowing past the test timeout on a real failure. + schedule: Schedule.max([ + Schedule.spaced("2 seconds"), + Schedule.recurs(30), + ]), + }), + ); + return yield* res.json; + }).pipe(Effect.orDie); + +test.provider( + "Ref binds an existing config without managing it", + (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + // A real reachable Postgres origin for the config under reference. + const { origin } = yield* stack.deploy( + Effect.gen(function* () { + const project = yield* Neon.Project("HyperdriveRefProject"); + return { origin: project.origin }; + }), + ); + + // The referenced config is created OUTSIDE the stack (standing in for + // a dashboard-created config shared with another system). Reruns + // self-heal: drop any leftover from an interrupted run first. + const leftover = + yield* Cloudflare.Hyperdrive.findConfigByName(REF_CONFIG_NAME); + if (leftover) { + yield* hyperdrive.deleteConfig({ + accountId, + hyperdriveId: leftover.id, + }); + } + const created = yield* hyperdrive.createConfig({ + accountId, + name: REF_CONFIG_NAME, + origin: { + scheme: origin.scheme, + host: origin.host, + port: origin.port, + database: origin.database, + user: origin.user, + password: Redacted.value(origin.password), + }, + }); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + // Keep the origin project deployed alongside the refs. + yield* Neon.Project("HyperdriveRefProject"); + const refById = yield* Cloudflare.Hyperdrive.Ref( + "HyperdriveRefById", + { + hyperdriveId: created.id, + }, + ); + const refByName = yield* RefByName; + const worker = yield* HyperdriveRefWorker; + return { refById, refByName, worker }; + }), + ); + + // Both addressing modes resolve the same out-of-band config. + expect(deployed.refById.hyperdriveId).toEqual(created.id); + expect(deployed.refById.name).toEqual(REF_CONFIG_NAME); + expect(deployed.refByName.hyperdriveId).toEqual(created.id); + + // The deployed worker's runtime binding resolves the referenced config. + const meta = (yield* getJsonReady(`${deployed.worker.url}/meta`)) as { + host: string; + port: number; + database: string; + }; + expect(meta.host).toBeTruthy(); + expect(meta.port).toBeGreaterThan(0); + expect(meta.database).toBe(origin.database); + + yield* stack.destroy(); + + // The core Ref semantic: destroy dropped the state rows but never + // touched the referenced config. + const survived = yield* hyperdrive.getConfig({ + accountId, + hyperdriveId: created.id, + }); + expect(survived.id).toEqual(created.id); + expect(survived.name).toEqual(REF_CONFIG_NAME); + assert("host" in survived.origin, "origin must have a host"); + expect(survived.origin.host).toEqual(origin.host); + + yield* hyperdrive.deleteConfig({ accountId, hyperdriveId: created.id }); + }).pipe(logLevel), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-local-worker.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-local-worker.ts new file mode 100644 index 0000000000..b9c1ea2e01 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-local-worker.ts @@ -0,0 +1,60 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Neon from "@/Neon/index.ts"; +import * as SQL from "@/SQL/Postgres.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Name of the Hyperdrive config `Ref.local.test.ts` creates out-of-band + * before deploying this fixture. + */ +export const REF_LOCAL_CONFIG_NAME = "alchemy-hyperdrive-ref-local-test"; + +/** + * Read-only reference with a `dev` origin override: the Cloudflare API + * never returns the origin credentials of an existing config, so local dev + * can only passthrough when the ref declares where to connect. The dev + * origin is the same real Neon Postgres the referenced config fronts. + */ +export const LocalHyperdriveRef = Effect.gen(function* () { + const project = yield* Neon.Project("HyperdriveRefLocalProject"); + return yield* Cloudflare.Hyperdrive.Ref("HyperdriveRefLocal", { + name: REF_LOCAL_CONFIG_NAME, + dev: project.origin, + }); +}); + +/** + * Effect-native Worker binding the referenced Hyperdrive config. `/query` + * runs a trivial SQL statement through `SQL.Postgres` over the binding's + * connection string — under `alchemy dev` that string points straight at + * the `dev` origin (the local runtime's origin passthrough). + */ +export default class HyperdriveRefLocalWorker extends Cloudflare.Worker()( + "HyperdriveRefLocalWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const hd = yield* Cloudflare.Hyperdrive.Connect(LocalHyperdriveRef); + const sql = yield* SQL.Postgres({ url: hd.connectionString }); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://x"); + if (url.pathname === "/query") { + const rows = (yield* sql` + SELECT 1 + 1 AS sum, current_database() AS db + `) as ReadonlyArray<{ sum: number; db: string }>; + const host = yield* hd.host; + return yield* HttpServerResponse.json({ row: rows[0], host }); + } + return HttpServerResponse.text("Not Found", { status: 404 }); + }).pipe( + Effect.catchCause((cause) => + HttpServerResponse.json({ error: String(cause) }, { status: 500 }), + ), + ), + }; + }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), +) {} diff --git a/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-worker.ts b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-worker.ts new file mode 100644 index 0000000000..4715f866b5 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Hyperdrive/fixtures/ref-worker.ts @@ -0,0 +1,53 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Name of the Hyperdrive config `Ref.test.ts` creates out-of-band (directly + * via distilled, standing in for a dashboard-created config) before + * deploying this fixture. + */ +export const REF_CONFIG_NAME = "alchemy-hyperdrive-ref-test"; + +/** + * Read-only reference to the out-of-band config, addressed by name. + */ +export const RefByName = Cloudflare.Hyperdrive.Ref("HyperdriveRefByName", { + name: REF_CONFIG_NAME, +}); + +/** + * Worker binding the referenced config both ways: `env.HD` exercises the + * env-binding classifier and `Connect` the Effect-native binding. `/meta` + * reports the runtime binding's discrete fields (never secret material) so + * the test can prove the binding resolves at runtime. + */ +export default class HyperdriveRefWorker extends Cloudflare.Worker()( + "HyperdriveRefWorker", + { + main: import.meta.url, + env: { HD: RefByName }, + }, + Effect.gen(function* () { + const hd = yield* Cloudflare.Hyperdrive.Connect(RefByName); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://x"); + if (url.pathname === "/meta") { + const host = yield* hd.host; + const port = yield* hd.port; + const database = yield* hd.database; + return yield* HttpServerResponse.json({ host, port, database }); + } + return HttpServerResponse.text("Not Found", { status: 404 }); + }).pipe( + Effect.catchCause((cause) => + HttpServerResponse.json({ error: String(cause) }, { status: 500 }), + ), + ), + }; + }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), +) {}