Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/alchemy/src/Cloudflare/Hyperdrive/Connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)`.
Expand All @@ -41,7 +43,7 @@ import type { Connection } from "./Connection.ts";
export interface Connect extends Binding.Service<
Connect,
"Cloudflare.Hyperdrive.Connect",
(connection: Connection) => Effect.Effect<ConnectClient>
(connection: Connection | Ref) => Effect.Effect<ConnectClient>
> {}

export const Connect = Binding.Service<Connect>(
Expand Down
33 changes: 31 additions & 2 deletions packages/alchemy/src/Cloudflare/Hyperdrive/ConnectBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ 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,
Effect.gen(function* () {
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: [
Expand All @@ -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),
});
}

Expand Down Expand Up @@ -87,3 +90,29 @@ export const getHyperdriveDevOrigin = (connection: Connection) => {
}),
) as unknown as Record<string, Required<DevOrigin>>;
};

/**
* 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<string, Required<DevOrigin>> =>
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<string, Required<DevOrigin>>;
10 changes: 7 additions & 3 deletions packages/alchemy/src/Cloudflare/Hyperdrive/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand Down
175 changes: 175 additions & 0 deletions packages/alchemy/src/Cloudflare/Hyperdrive/Ref.ts
Original file line number Diff line number Diff line change
@@ -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<Ref>("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.
}),
});
1 change: 1 addition & 0 deletions packages/alchemy/src/Cloudflare/Hyperdrive/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./Connect.ts";
export * from "./ConnectBinding.ts";
export * from "./Connection.ts";
export * from "./Ref.ts";
2 changes: 2 additions & 0 deletions packages/alchemy/src/Cloudflare/Providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export const providers = () =>
Healthcheck.Healthcheck,
HostnameTlsSetting.HostnameTlsSetting,
Hyperdrive.Connection,
Hyperdrive.Ref,
Iam.ResourceGroup,
Iam.UserGroup,
Iam.UserGroupMembership,
Expand Down Expand Up @@ -514,6 +515,7 @@ export const providers = () =>
HostnameTlsSetting.HostnameTlsSettingProvider(),
Hyperdrive.ConnectionProvider(),
Hyperdrive.ConnectionProvider(),
Hyperdrive.RefProvider(),
Iam.ResourceGroupProvider(),
Iam.UserGroupMembershipProvider(),
Iam.UserGroupProvider(),
Expand Down
4 changes: 3 additions & 1 deletion packages/alchemy/src/Cloudflare/Workers/InferEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export type GetBindingType<T> =
// `StreamNs.StreamBinding`).
T extends StreamNs.StreamBinding
? StreamBinding
: T extends HyperdriveNs.Connection
: T extends
| HyperdriveNs.Connection
| HyperdriveNs.Ref
? Hyperdrive
: T extends VersionMetadataBinding
? WorkerVersionMetadata
Expand Down
13 changes: 13 additions & 0 deletions packages/alchemy/src/Cloudflare/Workers/LocalWorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down
12 changes: 9 additions & 3 deletions packages/alchemy/src/Cloudflare/Workers/WorkerAsyncBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/alchemy/src/Cloudflare/Workers/WorkerBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -193,6 +194,7 @@ export type WorkerBindingResource =
| PipelinesStream
| LegacyPipeline
| Hyperdrive
| HyperdriveRef
| VectorizeIndex
| Secret
| Worker
Expand Down
Loading