diff --git a/alchemy-web/src/content/docs/providers/cloudflare/hyperdrive.md b/alchemy-web/src/content/docs/providers/cloudflare/hyperdrive.md index 074e5aa69..d02cd343d 100644 --- a/alchemy-web/src/content/docs/providers/cloudflare/hyperdrive.md +++ b/alchemy-web/src/content/docs/providers/cloudflare/hyperdrive.md @@ -38,6 +38,23 @@ const db = await Hyperdrive("my-postgres-db", { }); ``` +## Local Development Only + +For local-first development when you don't have production credentials yet, you can omit the `origin` property and only provide `dev.origin`. This matches the wrangler.jsonc pattern where you can use a local connection string without needing a real Hyperdrive ID. + +```ts +const db = await Hyperdrive("my-postgres-db", { + name: "my-postgres-db", + dev: { + origin: "postgres://postgres:postgres@localhost:5432/postgres", + }, +}); +``` + +:::caution +When you're ready to deploy to production, you'll need to add the `origin` property with your production database connection. Alchemy will throw a helpful error if you try to deploy without it. +::: + ## With Explicit Origin Object If you'd prefer to set parameters explicitly, you can use an object. diff --git a/alchemy/src/cloudflare/hyperdrive.ts b/alchemy/src/cloudflare/hyperdrive.ts index eb23536fd..90272ee3e 100644 --- a/alchemy/src/cloudflare/hyperdrive.ts +++ b/alchemy/src/cloudflare/hyperdrive.ts @@ -151,8 +151,11 @@ export interface HyperdriveProps extends CloudflareApiOptions { /** * Database connection origin configuration + * + * Optional in local mode - if not provided, dev.origin will be used. + * Required for production deployments. */ - origin: HyperdriveOriginInput; + origin?: HyperdriveOriginInput; /** * Caching configuration @@ -311,13 +314,30 @@ export async function Hyperdrive( id: string, props: HyperdriveProps, ): Promise { - const origin = normalizeHyperdriveOrigin(props.origin); + // In local mode, origin can be omitted if dev.origin is provided + const devOrigin = props.dev?.origin; + const productionOrigin = props.origin; + + if (!Scope.current.local && !productionOrigin) { + throw new Error( + `Hyperdrive "${id}" requires 'origin' for production deployment. ` + + `Add the production database connection to enable deployment.\n\n` + + `For local development only, you can omit 'origin' and only provide 'dev.origin'.`, + ); + } + + // Use dev.origin as fallback if origin is not provided (local mode only) + const origin = productionOrigin + ? normalizeHyperdriveOrigin(productionOrigin) + : normalizeHyperdriveOrigin(devOrigin!); + const dev = { origin: toConnectionString( - normalizeHyperdriveOrigin(props.dev?.origin ?? origin), + normalizeHyperdriveOrigin(devOrigin ?? productionOrigin!), ), force: Scope.current.local, }; + return await _Hyperdrive(id, { ...props, origin, diff --git a/alchemy/test/cloudflare/hyperdrive.test.ts b/alchemy/test/cloudflare/hyperdrive.test.ts index 11260a72c..75efc1ca0 100644 --- a/alchemy/test/cloudflare/hyperdrive.test.ts +++ b/alchemy/test/cloudflare/hyperdrive.test.ts @@ -168,6 +168,25 @@ describe.concurrent("Hyperdrive Resource", () => { } }); + test("hyperdrive with no production origin throws", async (scope) => { + try { + const project = await NeonProject(`${testId}-dev-only`, { + name: `Hyperdrive Test Dev Only ${BRANCH_PREFIX}`, + }); + + await expect( + Hyperdrive(`${testId}-dev`, { + name: `test-hyperdrive-dev-${BRANCH_PREFIX}`, + dev: { + origin: project.connection_uris[0].connection_parameters, + }, + }), + ).rejects.toThrowError(/requires 'origin' for production deployment/); + } finally { + await destroy(scope); + } + }); + describe("normalizeHyperdriveOrigin", () => { it("normalizes postgres string origin", () => { const origin = normalizeHyperdriveOrigin( diff --git a/bun.lock b/bun.lock index db904edfb..8b37c9f41 100644 --- a/bun.lock +++ b/bun.lock @@ -5956,6 +5956,8 @@ "ajv-keywords/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "alchemy/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], + "alchemy/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], "alchemy-web/@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-pQ8bokC59GEiXvyXpC4swBNoL7C/EknP+82KFzQwgR/Aeo5N1oPiAoPHgJbpPya/YF4E26WODdCQfBQDvLRfuw=="], @@ -6994,6 +6996,8 @@ "alchemy-web/sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "alchemy/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "alchemy/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "alchemy/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/examples/cloudflare-dev-only-hyperdrive/README.md b/examples/cloudflare-dev-only-hyperdrive/README.md new file mode 100644 index 000000000..fd2713b49 --- /dev/null +++ b/examples/cloudflare-dev-only-hyperdrive/README.md @@ -0,0 +1,19 @@ +# Dev only hyperdrive example + +This example provisions a Prisma Postgres database and reference it using hyperdrive ONLY in dev mode. +This may not be the most useful example for end users, it primarily serves to improve coverage during Alchemy's smoke tests. + +## Usage + +```bash +bun i +bun alchemy deploy +``` + +The script prints the generated database connection string to stdout. + +To tear down the resources: + +```bash +bun run destroy +``` diff --git a/examples/cloudflare-dev-only-hyperdrive/alchemy.run.ts b/examples/cloudflare-dev-only-hyperdrive/alchemy.run.ts new file mode 100644 index 000000000..edb5c5c5e --- /dev/null +++ b/examples/cloudflare-dev-only-hyperdrive/alchemy.run.ts @@ -0,0 +1,23 @@ +import alchemy from "alchemy"; +import { Hyperdrive, Worker } from "alchemy/cloudflare"; +import { Connection, Database, Project } from "alchemy/prisma-postgres"; + +const app = await alchemy("alchemy-dev-only-hyperdrive"); + +const project = await Project("project"); + +const database = await Database("database", { + project, + region: "us-east-1", +}); + +const connection = await Connection("connection", { database }); + +const db = await Hyperdrive("dev-only-hyperdrive", { + origin: app.local ? undefined : connection.connectionString.unencrypted, + dev: { + origin: connection.connectionString.unencrypted, + }, +}); + +await app.finalize(); diff --git a/examples/cloudflare-dev-only-hyperdrive/package.json b/examples/cloudflare-dev-only-hyperdrive/package.json new file mode 100644 index 000000000..84589136d --- /dev/null +++ b/examples/cloudflare-dev-only-hyperdrive/package.json @@ -0,0 +1,18 @@ +{ + "name": "cloudflare-dev-only-hyperdrive", + "version": "0.0.0", + "description": "Alchemy Cloudflare Dev Only Hyperdrive Example", + "type": "module", + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy --env-file ../../.env", + "destroy": "alchemy destroy --env-file ../../.env" + }, + "devDependencies": { + "alchemy": "workspace:*", + "typescript": "catalog:" + }, + "dependencies": { + "pg": "^8.16.3" + } +} diff --git a/examples/cloudflare-dev-only-hyperdrive/src/worker.ts b/examples/cloudflare-dev-only-hyperdrive/src/worker.ts new file mode 100644 index 000000000..f8086c8ef --- /dev/null +++ b/examples/cloudflare-dev-only-hyperdrive/src/worker.ts @@ -0,0 +1,30 @@ +import { Client } from "pg"; +import type { worker } from "../alchemy.run.ts"; + +export default { + async fetch(_request: Request, env: typeof worker.Env): Promise { + const client = new Client({ + connectionString: env.HYPERDRIVE.connectionString, + }); + + try { + // Connect to the database + await client.connect(); + console.log("Connected to PostgreSQL database"); + + // Perform a simple query + const result = await client.query("SELECT * FROM pg_tables"); + + return Response.json({ + success: true, + result: result.rows, + }); + } catch (error: any) { + console.error("Database error:", error.message); + + return new Response("Internal error occurred", { status: 500 }); + } finally { + await client.end(); + } + }, +}; diff --git a/examples/cloudflare-dev-only-hyperdrive/tsconfig.json b/examples/cloudflare-dev-only-hyperdrive/tsconfig.json new file mode 100644 index 000000000..e69b430af --- /dev/null +++ b/examples/cloudflare-dev-only-hyperdrive/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["./alchemy.run.ts"] +} diff --git a/examples/cloudflare-tanstack-start/src/routeTree.gen.ts b/examples/cloudflare-tanstack-start/src/routeTree.gen.ts index 4c0f35839..cb17a21b3 100644 --- a/examples/cloudflare-tanstack-start/src/routeTree.gen.ts +++ b/examples/cloudflare-tanstack-start/src/routeTree.gen.ts @@ -8,150 +8,150 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as ApiDemoNamesRouteImport } from "./routes/api.demo-names"; -import { Route as DemoStartServerFuncsRouteImport } from "./routes/demo.start.server-funcs"; -import { Route as DemoStartApiRequestRouteImport } from "./routes/demo.start.api-request"; -import { Route as ApiTestEnvRouteImport } from "./routes/api.test.env"; -import { Route as ApiTestKvIdRouteImport } from "./routes/api.test.kv.$id"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiDemoNamesRouteImport } from './routes/api.demo-names' +import { Route as DemoStartServerFuncsRouteImport } from './routes/demo.start.server-funcs' +import { Route as DemoStartApiRequestRouteImport } from './routes/demo.start.api-request' +import { Route as ApiTestEnvRouteImport } from './routes/api.test.env' +import { Route as ApiTestKvIdRouteImport } from './routes/api.test.kv.$id' const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) const ApiDemoNamesRoute = ApiDemoNamesRouteImport.update({ - id: "/api/demo-names", - path: "/api/demo-names", + id: '/api/demo-names', + path: '/api/demo-names', getParentRoute: () => rootRouteImport, -} as any); +} as any) const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({ - id: "/demo/start/server-funcs", - path: "/demo/start/server-funcs", + id: '/demo/start/server-funcs', + path: '/demo/start/server-funcs', getParentRoute: () => rootRouteImport, -} as any); +} as any) const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({ - id: "/demo/start/api-request", - path: "/demo/start/api-request", + id: '/demo/start/api-request', + path: '/demo/start/api-request', getParentRoute: () => rootRouteImport, -} as any); +} as any) const ApiTestEnvRoute = ApiTestEnvRouteImport.update({ - id: "/api/test/env", - path: "/api/test/env", + id: '/api/test/env', + path: '/api/test/env', getParentRoute: () => rootRouteImport, -} as any); +} as any) const ApiTestKvIdRoute = ApiTestKvIdRouteImport.update({ - id: "/api/test/kv/$id", - path: "/api/test/kv/$id", + id: '/api/test/kv/$id', + path: '/api/test/kv/$id', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/api/demo-names": typeof ApiDemoNamesRoute; - "/api/test/env": typeof ApiTestEnvRoute; - "/demo/start/api-request": typeof DemoStartApiRequestRoute; - "/demo/start/server-funcs": typeof DemoStartServerFuncsRoute; - "/api/test/kv/$id": typeof ApiTestKvIdRoute; + '/': typeof IndexRoute + '/api/demo-names': typeof ApiDemoNamesRoute + '/api/test/env': typeof ApiTestEnvRoute + '/demo/start/api-request': typeof DemoStartApiRequestRoute + '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute + '/api/test/kv/$id': typeof ApiTestKvIdRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/api/demo-names": typeof ApiDemoNamesRoute; - "/api/test/env": typeof ApiTestEnvRoute; - "/demo/start/api-request": typeof DemoStartApiRequestRoute; - "/demo/start/server-funcs": typeof DemoStartServerFuncsRoute; - "/api/test/kv/$id": typeof ApiTestKvIdRoute; + '/': typeof IndexRoute + '/api/demo-names': typeof ApiDemoNamesRoute + '/api/test/env': typeof ApiTestEnvRoute + '/demo/start/api-request': typeof DemoStartApiRequestRoute + '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute + '/api/test/kv/$id': typeof ApiTestKvIdRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/api/demo-names": typeof ApiDemoNamesRoute; - "/api/test/env": typeof ApiTestEnvRoute; - "/demo/start/api-request": typeof DemoStartApiRequestRoute; - "/demo/start/server-funcs": typeof DemoStartServerFuncsRoute; - "/api/test/kv/$id": typeof ApiTestKvIdRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/api/demo-names': typeof ApiDemoNamesRoute + '/api/test/env': typeof ApiTestEnvRoute + '/demo/start/api-request': typeof DemoStartApiRequestRoute + '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute + '/api/test/kv/$id': typeof ApiTestKvIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; + fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | "/" - | "/api/demo-names" - | "/api/test/env" - | "/demo/start/api-request" - | "/demo/start/server-funcs" - | "/api/test/kv/$id"; - fileRoutesByTo: FileRoutesByTo; + | '/' + | '/api/demo-names' + | '/api/test/env' + | '/demo/start/api-request' + | '/demo/start/server-funcs' + | '/api/test/kv/$id' + fileRoutesByTo: FileRoutesByTo to: - | "/" - | "/api/demo-names" - | "/api/test/env" - | "/demo/start/api-request" - | "/demo/start/server-funcs" - | "/api/test/kv/$id"; + | '/' + | '/api/demo-names' + | '/api/test/env' + | '/demo/start/api-request' + | '/demo/start/server-funcs' + | '/api/test/kv/$id' id: - | "__root__" - | "/" - | "/api/demo-names" - | "/api/test/env" - | "/demo/start/api-request" - | "/demo/start/server-funcs" - | "/api/test/kv/$id"; - fileRoutesById: FileRoutesById; + | '__root__' + | '/' + | '/api/demo-names' + | '/api/test/env' + | '/demo/start/api-request' + | '/demo/start/server-funcs' + | '/api/test/kv/$id' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - ApiDemoNamesRoute: typeof ApiDemoNamesRoute; - ApiTestEnvRoute: typeof ApiTestEnvRoute; - DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute; - DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute; - ApiTestKvIdRoute: typeof ApiTestKvIdRoute; + IndexRoute: typeof IndexRoute + ApiDemoNamesRoute: typeof ApiDemoNamesRoute + ApiTestEnvRoute: typeof ApiTestEnvRoute + DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute + DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute + ApiTestKvIdRoute: typeof ApiTestKvIdRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/api/demo-names": { - id: "/api/demo-names"; - path: "/api/demo-names"; - fullPath: "/api/demo-names"; - preLoaderRoute: typeof ApiDemoNamesRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/demo/start/server-funcs": { - id: "/demo/start/server-funcs"; - path: "/demo/start/server-funcs"; - fullPath: "/demo/start/server-funcs"; - preLoaderRoute: typeof DemoStartServerFuncsRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/demo/start/api-request": { - id: "/demo/start/api-request"; - path: "/demo/start/api-request"; - fullPath: "/demo/start/api-request"; - preLoaderRoute: typeof DemoStartApiRequestRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/api/test/env": { - id: "/api/test/env"; - path: "/api/test/env"; - fullPath: "/api/test/env"; - preLoaderRoute: typeof ApiTestEnvRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/api/test/kv/$id": { - id: "/api/test/kv/$id"; - path: "/api/test/kv/$id"; - fullPath: "/api/test/kv/$id"; - preLoaderRoute: typeof ApiTestKvIdRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/api/demo-names': { + id: '/api/demo-names' + path: '/api/demo-names' + fullPath: '/api/demo-names' + preLoaderRoute: typeof ApiDemoNamesRouteImport + parentRoute: typeof rootRouteImport + } + '/demo/start/server-funcs': { + id: '/demo/start/server-funcs' + path: '/demo/start/server-funcs' + fullPath: '/demo/start/server-funcs' + preLoaderRoute: typeof DemoStartServerFuncsRouteImport + parentRoute: typeof rootRouteImport + } + '/demo/start/api-request': { + id: '/demo/start/api-request' + path: '/demo/start/api-request' + fullPath: '/demo/start/api-request' + preLoaderRoute: typeof DemoStartApiRequestRouteImport + parentRoute: typeof rootRouteImport + } + '/api/test/env': { + id: '/api/test/env' + path: '/api/test/env' + fullPath: '/api/test/env' + preLoaderRoute: typeof ApiTestEnvRouteImport + parentRoute: typeof rootRouteImport + } + '/api/test/kv/$id': { + id: '/api/test/kv/$id' + path: '/api/test/kv/$id' + fullPath: '/api/test/kv/$id' + preLoaderRoute: typeof ApiTestKvIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -162,16 +162,16 @@ const rootRouteChildren: RootRouteChildren = { DemoStartApiRequestRoute: DemoStartApiRequestRoute, DemoStartServerFuncsRoute: DemoStartServerFuncsRoute, ApiTestKvIdRoute: ApiTestKvIdRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() -import type { getRouter } from "./router.tsx"; -import type { createStart } from "@tanstack/react-start"; -declare module "@tanstack/react-start" { +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { interface Register { - ssr: true; - router: Awaited>; + ssr: true + router: Awaited> } } diff --git a/package.json b/package.json index c61f7ed99..6ef657bbb 100644 --- a/package.json +++ b/package.json @@ -78,4 +78,4 @@ "dependencies": { "oxfmt": "^0.5.0" } -} +} \ No newline at end of file diff --git a/tests/smoke-test-flatten-website/package.json b/tests/smoke-test-flatten-website/package.json index 9df72576f..8e6080c1c 100644 --- a/tests/smoke-test-flatten-website/package.json +++ b/tests/smoke-test-flatten-website/package.json @@ -17,7 +17,7 @@ "react-dom": "^19.1.0" }, "devDependencies": { - "alchemy": "workspace:*", + "alchemy": "0.62.2", "@cloudflare/vite-plugin": "^1.7.4", "@cloudflare/workers-types": "^4.20250805.0", "miniflare": "^4.20250617.3", @@ -33,4 +33,4 @@ "typescript-eslint": "^8.30.1", "vite": "^6.3.5" } -} +} \ No newline at end of file