diff --git a/bun.lock b/bun.lock index 008535d89..434510686 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "distilled/packages/aws": { "name": "@distilled.cloud/aws", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@aws-crypto/crc32": "catalog:", "@aws-crypto/util": "catalog:", @@ -63,7 +63,7 @@ }, "distilled/packages/axiom": { "name": "@distilled.cloud/axiom", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -79,7 +79,7 @@ }, "distilled/packages/azure": { "name": "@distilled.cloud/azure", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -95,7 +95,7 @@ }, "distilled/packages/cloudflare": { "name": "@distilled.cloud/cloudflare", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -114,7 +114,7 @@ }, "distilled/packages/coinbase": { "name": "@distilled.cloud/coinbase", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -131,7 +131,7 @@ }, "distilled/packages/core": { "name": "@distilled.cloud/core", - "version": "0.25.2", + "version": "0.25.1", "devDependencies": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -144,7 +144,7 @@ }, "distilled/packages/expo-eas": { "name": "@distilled.cloud/expo-eas", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -160,7 +160,7 @@ }, "distilled/packages/fly-io": { "name": "@distilled.cloud/fly-io", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -176,7 +176,7 @@ }, "distilled/packages/gcp": { "name": "@distilled.cloud/gcp", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -192,7 +192,7 @@ }, "distilled/packages/kubernetes": { "name": "@distilled.cloud/kubernetes", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -208,7 +208,7 @@ }, "distilled/packages/mongodb-atlas": { "name": "@distilled.cloud/mongodb-atlas", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -224,7 +224,7 @@ }, "distilled/packages/neon": { "name": "@distilled.cloud/neon", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -240,7 +240,7 @@ }, "distilled/packages/planetscale": { "name": "@distilled.cloud/planetscale", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -256,7 +256,7 @@ }, "distilled/packages/posthog": { "name": "@distilled.cloud/posthog", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -274,7 +274,7 @@ }, "distilled/packages/prisma-postgres": { "name": "@distilled.cloud/prisma-postgres", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -290,7 +290,7 @@ }, "distilled/packages/stripe": { "name": "@distilled.cloud/stripe", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -306,7 +306,7 @@ }, "distilled/packages/supabase": { "name": "@distilled.cloud/supabase", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -322,7 +322,7 @@ }, "distilled/packages/turso": { "name": "@distilled.cloud/turso", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -338,7 +338,7 @@ }, "distilled/packages/typesense": { "name": "@distilled.cloud/typesense", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -355,7 +355,7 @@ }, "distilled/packages/workos": { "name": "@distilled.cloud/workos", - "version": "0.25.2", + "version": "0.25.1", "dependencies": { "@distilled.cloud/core": "workspace:*", }, @@ -635,6 +635,34 @@ "vite": "catalog:", }, }, + "examples/cloudflare-tanstack-rpc-drizzle": { + "name": "cloudflare-tanstack-rpc-drizzle-example", + "version": "0.0.0", + "dependencies": { + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "@tanstack/react-router": "^1.167.4", + "@tanstack/react-start": "^1.166.15", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.13.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/pg": "^8.11.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "drizzle-kit": "1.0.0-rc.1", + "typescript": "^5.9.3", + "vite": "catalog:", + }, + }, "examples/cloudflare-tanstack-start-solid": { "name": "cloudflare-tanstack-start-solid-example", "version": "0.0.0", @@ -926,6 +954,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@effect/atom-react": ">=4.0.0-beta.78 || >=4.0.0", "@effect/language-service": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-bun": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-node": ">=4.0.0-beta.78 || >=4.0.0", @@ -1248,6 +1277,8 @@ "@ec-ts/vfs": ["@ec-ts/vfs@1.0.0", "", { "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-GQYRPMp58p9ak+TOwSLB/HZ+iknFyTRjqMTdXDkitNN3h5yjHuO+yeeO+/cuXiv7dyXGLYs4HaYrFgRKImp8yg=="], + "@effect/atom-react": ["@effect/atom-react@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83", "react": "^19.2.4", "scheduler": "*" } }, "sha512-Vpk90KP32fKxTgDbQPwgI9o4DzPUwxRVVf1wzlHHq7p4GgxGKEKJNGkKcinH9Br2MJB2JItaIs6cluFnlszxBw=="], + "@effect/language-service": ["@effect/language-service@0.77.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-QP2bri8DdcK7Eo+SqFS2yNeD0Ch9kKHYxq2jeE9CaPpBknevCNFb3+hT6qSsPt2P6yOkhNP83KMy5Uk7DGBXlg=="], "@effect/platform-bun": ["@effect/platform-bun@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-Mop8U1Ad1FFyL6C4VWWCCYG3Mh7BHvGsfhYAIfBZffEDRjgUME1Ol8rno2CoHYzJ6qaUOL8D4djtPGkwS68/Qw=="], @@ -2494,6 +2525,8 @@ "cloudflare-tanstack-example": ["cloudflare-tanstack-example@workspace:examples/cloudflare-tanstack"], + "cloudflare-tanstack-rpc-drizzle-example": ["cloudflare-tanstack-rpc-drizzle-example@workspace:examples/cloudflare-tanstack-rpc-drizzle"], + "cloudflare-tanstack-start-solid-example": ["cloudflare-tanstack-start-solid-example@workspace:examples/cloudflare-tanstack-start-solid"], "cloudflare-vue": ["cloudflare-vue@workspace:examples/cloudflare-vue"], @@ -4484,6 +4517,8 @@ "cloudflare-tanstack-example/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "cloudflare-tanstack-rpc-drizzle-example/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "cloudflare-vue/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "cloudflare-vue/oxfmt": ["oxfmt@0.42.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.42.0", "@oxfmt/binding-android-arm64": "0.42.0", "@oxfmt/binding-darwin-arm64": "0.42.0", "@oxfmt/binding-darwin-x64": "0.42.0", "@oxfmt/binding-freebsd-x64": "0.42.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.42.0", "@oxfmt/binding-linux-arm-musleabihf": "0.42.0", "@oxfmt/binding-linux-arm64-gnu": "0.42.0", "@oxfmt/binding-linux-arm64-musl": "0.42.0", "@oxfmt/binding-linux-ppc64-gnu": "0.42.0", "@oxfmt/binding-linux-riscv64-gnu": "0.42.0", "@oxfmt/binding-linux-riscv64-musl": "0.42.0", "@oxfmt/binding-linux-s390x-gnu": "0.42.0", "@oxfmt/binding-linux-x64-gnu": "0.42.0", "@oxfmt/binding-linux-x64-musl": "0.42.0", "@oxfmt/binding-openharmony-arm64": "0.42.0", "@oxfmt/binding-win32-arm64-msvc": "0.42.0", "@oxfmt/binding-win32-ia32-msvc": "0.42.0", "@oxfmt/binding-win32-x64-msvc": "0.42.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg=="], diff --git a/examples/cloudflare-tanstack-rpc-drizzle/.gitignore b/examples/cloudflare-tanstack-rpc-drizzle/.gitignore new file mode 100644 index 000000000..3be7d6309 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/.gitignore @@ -0,0 +1,5 @@ +node_modules +.tanstack +.nitro +.output +dist diff --git a/examples/cloudflare-tanstack-rpc-drizzle/README.md b/examples/cloudflare-tanstack-rpc-drizzle/README.md new file mode 100644 index 000000000..adfc45e41 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/README.md @@ -0,0 +1,98 @@ +# cloudflare-tanstack-rpc-drizzle + +A full-stack example that wires together four pieces: + +- **TanStack Start** (React) frontend, deployed as a Cloudflare Worker + assets via `Cloudflare.Vite`. +- **Effect RPC** backend, deployed as a separate `Cloudflare.RpcWorker`. +- **Drizzle + Neon Postgres**, reached through a `Cloudflare.Hyperdrive` pool, with migrations generated by `Drizzle.Schema`. +- **Atom RPC** — Effect 4's native `effect/unstable/reactivity/AtomRpc` plus the React bindings from `@effect/atom-react` — for reactive queries and mutations in the browser. + +## Architecture + +``` +Browser (React) + │ useAtomValue / useAtomSet + ▼ +AtomRpc client (TodoRpcs) src/rpc-client.ts + │ HTTP POST /rpc (JSON) + ▼ +TanStack Start worker src/routes/rpc.ts (server route) + │ env.BACKEND.fetch(...) (private service binding) + ▼ +Backend RpcWorker src/backend.ts + │ Drizzle.postgres over Hyperdrive + ▼ +Neon Postgres branch +``` + +The browser cannot use a Cloudflare service binding directly, so the `AtomRpc` +client points at a same-origin `/rpc` route, and the TanStack Start server +forwards that request to the private `BACKEND` worker over the service binding. +This keeps the backend off the public internet and avoids CORS. + +## Atom RPC + +Effect 4 ships atom RPC in core — no third-party `@effect-atom` package is +needed (that one targets Effect 3). The only extra dependency is +`@effect/atom-react`, which provides the React hooks (`useAtomValue`, +`useAtomSet`, `RegistryProvider`) and is versioned in lockstep with `effect`. + +```ts +// src/rpc-client.ts +export class TodoClient extends AtomRpc.Service()("TodoClient", { + group: TodoRpcs, + protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), +}) {} + +export const listTodosAtom = TodoClient.query("listTodos", undefined, { + reactivityKeys: ["todos"], +}); +export const createTodoAtom = TodoClient.mutation("createTodo"); +``` + +```tsx +// src/routes/index.tsx +const todos = AsyncResult.getOrElse(useAtomValue(listTodosAtom), () => []); +const createTodo = useAtomSet(createTodoAtom); +// ... +createTodo({ payload: { text }, reactivityKeys: ["todos"] }); +``` + +`reactivityKeys: ["todos"]` ties the list query to the mutations: when a +mutation runs with the same key, the list query is invalidated and refetched +automatically. + +The same `TodoRpcs` group is the single source of truth — it is served by the +backend and consumed by the browser client, so one `Schema` codec round-trips +every value over the wire. + +## Running + +Requires Cloudflare and Neon credentials (same as the other Neon examples): + +- `CLOUDFLARE_API_TOKEN` (or `alchemy login`) +- `NEON_API_KEY` + +```sh +bun install + +# Deploy the backend RpcWorker, Neon branch, Hyperdrive, and the TanStack site. +bun alchemy deploy + +# Tear everything down. +bun alchemy destroy +``` + +`Drizzle.Schema` generates migration SQL into `./migrations` on deploy, and +`Neon.Branch` applies any pending migrations transactionally before the workers +go live. + +## Tests + +```sh +bun test # deploys, exercises /rpc end to end, then destroys +NO_DESTROY=1 bun test # keep the deployment around between runs +``` diff --git a/examples/cloudflare-tanstack-rpc-drizzle/alchemy.run.ts b/examples/cloudflare-tanstack-rpc-drizzle/alchemy.run.ts new file mode 100644 index 000000000..e3ec953d2 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/alchemy.run.ts @@ -0,0 +1,53 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import Backend from "./src/backend/api.ts"; +import { Hyperdrive, NeonDatabase } from "./src/backend/database.ts"; + +/** + * The TanStack Start frontend. Deployed as a Cloudflare Worker + static assets + * by `Cloudflare.Vite`. The `Backend` RPC worker is injected as a private + * `BACKEND` service binding; the `/rpc` server route proxies the browser's + * `AtomRpc` traffic to it. + */ +export class Website extends Cloudflare.Vite()("Website", { + compatibility: { + flags: ["nodejs_compat", "enable_request_signal"], + }, + env: { + BACKEND: Backend, + }, + assets: { + runWorkerFirst: ["/rpc", "/rpc/"], + }, +}) {} + +export type WebsiteEnv = Cloudflare.InferEnv; + +export default Alchemy.Stack( + "CloudflareTanstackRpcDrizzleExample", + { + providers: Layer.mergeAll( + Cloudflare.providers(), + Drizzle.providers(), + Neon.providers(), + ), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const { branch } = yield* NeonDatabase; + const hd = yield* Hyperdrive; + const backend = yield* Backend; + const website = yield* Website; + + return { + websiteUrl: website.url.as(), + backendUrl: backend.url.as(), + branchId: branch.branchId, + hyperdriveId: hd.hyperdriveId, + }; + }), +); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/migrations/.gitkeep b/examples/cloudflare-tanstack-rpc-drizzle/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/migration.sql b/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/migration.sql new file mode 100644 index 000000000..56d164e08 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE "todos" ( + "id" serial PRIMARY KEY, + "text" text NOT NULL, + "done" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + diff --git a/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/snapshot.json b/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/snapshot.json new file mode 100644 index 000000000..2968eac25 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/migrations/20260616175556_migration/snapshot.json @@ -0,0 +1,79 @@ +{ + "dialect": "postgres", + "id": "edc39bcb-0839-4baa-a331-9ce9b6cd6429", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], + "version": "8", + "ddl": [ + { + "isRlsEnabled": false, + "name": "todos", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "todos" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "todos" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "done", + "entityType": "columns", + "schema": "public", + "table": "todos" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "todos" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "todos_pkey", + "schema": "public", + "table": "todos", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/examples/cloudflare-tanstack-rpc-drizzle/package.json b/examples/cloudflare-tanstack-rpc-drizzle/package.json new file mode 100644 index 000000000..90a887f75 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/package.json @@ -0,0 +1,44 @@ +{ + "name": "cloudflare-tanstack-rpc-drizzle-example", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/cloudflare-tanstack-rpc-drizzle" + }, + "type": "module", + "scripts": { + "dev": "alchemy dev", + "build": "vite build", + "preview": "vite preview", + "deploy": "alchemy deploy", + "destroy": "alchemy destroy", + "test": "bun test" + }, + "dependencies": { + "@effect/atom-react": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "@tanstack/react-router": "^1.167.4", + "@tanstack/react-start": "^1.166.15", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.13.0", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/pg": "^8.11.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "drizzle-kit": "1.0.0-rc.1", + "typescript": "^5.9.3", + "vite": "catalog:" + } +} \ No newline at end of file diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/backend/api.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/api.ts new file mode 100644 index 000000000..b720f99af --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/api.ts @@ -0,0 +1,82 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import { eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { Hyperdrive } from "./database.ts"; +import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts"; +import { relations, Todos } from "./schema.ts"; + +/** + * The RPC backend. A {@link Cloudflare.RpcWorker} that serves {@link TodoRpcs} + * over HTTP (JSON), backed by Drizzle talking to Neon Postgres through a + * Hyperdrive pool. It is bound to the frontend as a private service binding — + * the browser never reaches it directly; the TanStack `/rpc` route proxies to + * it (see `src/routes/rpc.ts`). + */ +export default class Backend extends Cloudflare.RpcWorker()( + "Backend", + { + main: import.meta.filename, + schema: TodoRpcs, + }, + Effect.gen(function* () { + const conn = yield* Cloudflare.Hyperdrive.bind(Hyperdrive); + const db = yield* Drizzle.postgres(conn.connectionString, { relations }); + + // DB failures are unexpected here, so we `orDie` them into defects. That + // keeps each handler's typed error channel aligned with its RPC schema + // (`never` for list/create, `TodoNotFound` for toggle/delete). + const handlers = TodoRpcs.toLayer({ + listTodos: () => + db + .select() + .from(Todos) + .orderBy(Todos.id) + .pipe( + Effect.map((rows) => rows.map((row) => new Todo(row))), + Effect.orDie, + ), + + createTodo: ({ text }) => + db + .insert(Todos) + .values({ text }) + .returning() + .pipe( + Effect.map(([row]) => new Todo(row)), + Effect.orDie, + ), + + toggleTodo: ({ id, done }) => + db + .update(Todos) + .set({ done }) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.flatMap(([row]) => + row ? Effect.succeed(new Todo(row)) : new TodoNotFound({ id }), + ), + Effect.orDie, + ), + + deleteTodo: ({ id }) => + db + .delete(Todos) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.flatMap(([row]) => + row ? Effect.succeed(row.id) : new TodoNotFound({ id }), + ), + Effect.orDie, + ), + }); + + return RpcServer.toHttpEffect(TodoRpcs).pipe( + Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)), + ); + }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)), +) {} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/backend/database.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/database.ts new file mode 100644 index 000000000..fcc337074 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/database.ts @@ -0,0 +1,47 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; + +/** + * A Drizzle schema + Neon project + feature branch. The branch's + * `migrationsDir` is wired to the schema resource's `out` output, so the + * provider order becomes: + * + * 1. `Drizzle.Schema` regenerates pending migration SQL files. + * 2. `Neon.Branch` scans the directory and applies any new migrations + * transactionally. + */ +export const NeonDatabase = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + + const schema = yield* Drizzle.Schema("app-schema", { + schema: "./src/backend/schema.ts", + out: "./migrations", + }); + + const project = stage.startsWith("pr-") + ? yield* Neon.Project.ref("app-db", { stage: `staging-${stage}` }) + : yield* Neon.Project("app-db", { + region: "aws-us-east-1", + }); + + const branch = yield* Neon.Branch("app-branch", { + project, + migrationsDir: schema.out, + }); + + return { project, branch, schema }; +}); + +/** + * A Hyperdrive pool in front of the Neon branch. The backend worker binds to + * this so Drizzle talks to Postgres through Cloudflare's connection pooler. + */ +export const Hyperdrive = Effect.gen(function* () { + const { branch } = yield* NeonDatabase; + return yield* Cloudflare.Hyperdrive("app-hyperdrive", { + origin: branch.origin, + }); +}); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/backend/rpc.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/rpc.ts new file mode 100644 index 000000000..380e4b81a --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/rpc.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; + +/** + * Shared domain + RPC contract. + * + * This module is the single source of truth imported by BOTH ends: + * - the backend `Cloudflare.RpcWorker` ({@link ./backend.ts}) serves it, and + * - the browser `AtomRpc` client ({@link ./rpc-client.ts}) consumes it. + * + * One `Schema` codec round-trips every value over the wire, so the React UI is + * fully typed against the same shapes the Postgres-backed handlers return. + */ + +/** A single todo item, encoded over the wire and decoded back into this class. */ +export class Todo extends Schema.Class("Todo")({ + id: Schema.Number, + text: Schema.String, + done: Schema.Boolean, + createdAt: Schema.Date, +}) {} + +/** Raised when a mutation targets a todo id that no longer exists. */ +export class TodoNotFound extends Schema.TaggedErrorClass()( + "TodoNotFound", + { id: Schema.Number }, +) {} + +export class TodoRpcs extends RpcGroup.make( + Rpc.make("listTodos", { + success: Schema.Array(Todo), + }), + Rpc.make("createTodo", { + payload: { text: Schema.String }, + success: Todo, + }), + Rpc.make("toggleTodo", { + payload: { id: Schema.Number, done: Schema.Boolean }, + success: Todo, + error: TodoNotFound, + }), + Rpc.make("deleteTodo", { + payload: { id: Schema.Number }, + success: Schema.Number, + error: TodoNotFound, + }), +) {} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/backend/schema.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/schema.ts new file mode 100644 index 000000000..a43eb6cc3 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/backend/schema.ts @@ -0,0 +1,14 @@ +import { defineRelations } from "drizzle-orm"; +import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const Todos = pgTable("todos", { + id: serial("id").primaryKey(), + text: text("text").notNull(), + done: boolean("done").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type TodoRow = typeof Todos.$inferSelect; + +export const relations = defineRelations({ Todos }, () => ({})); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/routeTree.gen.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/routeTree.gen.ts new file mode 100644 index 000000000..70ed2c420 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/routeTree.gen.ts @@ -0,0 +1,86 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// 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 RpcRouteImport } from './routes/rpc' +import { Route as IndexRouteImport } from './routes/index' + +const RpcRoute = RpcRouteImport.update({ + id: '/rpc', + path: '/rpc', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/rpc': typeof RpcRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/rpc': typeof RpcRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/rpc': typeof RpcRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/rpc' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/rpc' + id: '__root__' | '/' | '/rpc' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + RpcRoute: typeof RpcRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/rpc': { + id: '/rpc' + path: '/rpc' + fullPath: '/rpc' + preLoaderRoute: typeof RpcRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + RpcRoute: RpcRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/router.tsx b/examples/cloudflare-tanstack-rpc-drizzle/src/router.tsx new file mode 100644 index 000000000..6f7766f8c --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/router.tsx @@ -0,0 +1,15 @@ +import { createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/routes/__root.tsx b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/__root.tsx new file mode 100644 index 000000000..c03a1c6ee --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/__root.tsx @@ -0,0 +1,52 @@ +import { RegistryProvider } from "@effect/atom-react"; +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "TanStack Start + Effect RPC + Drizzle" }, + ], + }), + component: RootComponent, +}); + +function RootComponent() { + return ( + + {/* One AtomRegistry for the whole app — every AtomRpc query/mutation + atom resolves against this registry. */} + + + + + ); +} + +function Document(props: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {props.children} + + + + ); +} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/routes/index.tsx b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/index.tsx new file mode 100644 index 000000000..899ec3c12 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/index.tsx @@ -0,0 +1,159 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { createFileRoute } from "@tanstack/react-router"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useState } from "react"; +import { + createTodoAtom, + deleteTodoAtom, + listTodosAtom, + toggleTodoAtom, +} from "../rpc-client.ts"; + +export const Route = createFileRoute("/")({ + component: Home, +}); + +function Home() { + return ( +
+

Todos

+

+ TanStack Start UI → AtomRpc client → Effect RPC + worker → Drizzle → Neon Postgres. +

+
+ + +
+
+ ); +} + +function TodoForm() { + const createTodo = useAtomSet(createTodoAtom); + + const [text, setText] = useState(""); + + const submit = (e: React.SubmitEvent) => { + e.preventDefault(); + const value = text.trim(); + if (!value) return; + createTodo({ payload: { text: value }, reactivityKeys: ["todos"] }); + setText(""); + }; + + return ( +
+ setText(e.target.value)} + placeholder="What needs doing?" + style={{ + flex: 1, + padding: "0.6rem 0.75rem", + borderRadius: 8, + border: "1px solid #cbd5e1", + fontSize: "1rem", + }} + /> + +
+ ); +} + +function TodoList() { + const atom = useAtomValue(listTodosAtom); + const toggleTodo = useAtomSet(toggleTodoAtom); + const deleteTodo = useAtomSet(deleteTodoAtom); + + const todos = AsyncResult.getOrElse(atom, () => []); + + if ( + (AsyncResult.isWaiting(atom) && !todos.length) || + typeof window === "undefined" + ) { + return ( +

Loading todos…

+ ); + } + + if (!todos.length) { + return ( +

+ No todos yet — add one above. +

+ ); + } + + return ( +
    + {todos.map((todo) => ( +
  • + + toggleTodo({ + payload: { id: todo.id, done: !todo.done }, + reactivityKeys: ["todos"], + }) + } + style={{ width: 18, height: 18 }} + /> + + {todo.text} + + +
  • + ))} +
+ ); +} + +const primaryButton: React.CSSProperties = { + padding: "0.6rem 1.1rem", + border: "none", + borderRadius: 8, + background: "#0f172a", + color: "#fff", + cursor: "pointer", + fontSize: "1rem", +}; + +const ghostButton: React.CSSProperties = { + padding: "0.35rem 0.6rem", + border: "none", + borderRadius: 6, + background: "transparent", + color: "#ef4444", + cursor: "pointer", + fontSize: "0.875rem", +}; diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/routes/rpc.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/rpc.ts new file mode 100644 index 000000000..1a1a7f82c --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/routes/rpc.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import type { WebsiteEnv } from "../../alchemy.run.ts"; + +/** + * Same-origin proxy for the browser's `AtomRpc` client. The browser can't use a + * Cloudflare service binding directly, so the `AtomRpc` protocol points at this + * `/rpc` route and we forward the request body to the private `BACKEND` worker + * over the service binding. This keeps the backend off the public internet and + * avoids CORS. + */ +export const Route = createFileRoute("/rpc")({ + server: { + handlers: { + POST: async ({ request }) => { + return await (env as WebsiteEnv).BACKEND.fetch("https://backend/rpc", { + method: request.method, + headers: { + "content-type": + request.headers.get("content-type") ?? "application/json", + }, + body: await request.text(), + signal: request.signal, + redirect: "manual", + }); + }, + }, + }, +}); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/src/rpc-client.ts b/examples/cloudflare-tanstack-rpc-drizzle/src/rpc-client.ts new file mode 100644 index 000000000..9cbb65900 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/src/rpc-client.ts @@ -0,0 +1,34 @@ +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as AtomRpc from "effect/unstable/reactivity/AtomRpc"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { TodoRpcs } from "./backend/rpc.ts"; + +/** + * The browser-side reactive RPC client. Effect 4 ships atom RPC natively in + * `effect/unstable/reactivity/AtomRpc` — `AtomRpc.Service` turns an `RpcGroup` + * into a `.query()` / `.mutation()` client whose results are atoms. + * + * The transport (`protocol`) is a plain HTTP client over `fetch`, pointed at + * the same-origin `/rpc` route which proxies to the backend service binding. + * Serialization MUST match the backend (`RpcSerialization.layerJson`). + */ +export class TodoClient extends AtomRpc.Service()("TodoClient", { + group: TodoRpcs, + protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), +}) {} + +/** + * Query/mutation atoms, created once at module scope so their identity is stable + * across renders. `reactivityKeys: ["todos"]` ties the list query to the + * mutations below — when a mutation runs with the same key, the list refetches. + */ +export const listTodosAtom = TodoClient.query("listTodos", undefined, { + reactivityKeys: ["todos"], +}); +export const createTodoAtom = TodoClient.mutation("createTodo"); +export const toggleTodoAtom = TodoClient.mutation("toggleTodo"); +export const deleteTodoAtom = TodoClient.mutation("deleteTodo"); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/test/integ.test.ts b/examples/cloudflare-tanstack-rpc-drizzle/test/integ.test.ts new file mode 100644 index 000000000..c7b9e2346 --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/test/integ.test.ts @@ -0,0 +1,105 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Neon from "alchemy/Neon"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import Stack from "../alchemy.run.ts"; +import { TodoRpcs } from "../src/backend/rpc.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Layer.mergeAll( + Cloudflare.providers(), + Drizzle.providers(), + Neon.providers(), + ), + state: Alchemy.localState(), +}); + +const stack = beforeAll(deploy(Stack)); +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); + +// Fresh workers.dev URLs take a few seconds to start answering. +const coldStartRetry = Effect.retry({ + schedule: Schedule.spaced("500 millis").pipe( + Schedule.both(Schedule.recurs(20)), + ), +}); + +test( + "deploys and exposes urls + db identifiers", + Effect.gen(function* () { + const { websiteUrl, backendUrl, branchId, hyperdriveId } = yield* stack; + expect(websiteUrl).toBeString(); + expect(backendUrl).toBeString(); + expect(branchId).toBeString(); + expect(hyperdriveId).toBeString(); + }), + { timeout: 180_000 }, +); + +test( + "todo CRUD round-trips through the /rpc proxy into Drizzle/Neon", + Effect.gen(function* () { + const { websiteUrl } = yield* stack; + const rpcUrl = new URL("/rpc", websiteUrl).toString(); + + yield* Effect.gen(function* () { + const client = yield* RpcClient.make(TodoRpcs); + + // Cold-start the worker on the first call. + yield* client.listTodos().pipe(coldStartRetry); + + const created = yield* client.createTodo({ text: "write the example" }); + expect(created.id).toBeNumber(); + expect(created.text).toBe("write the example"); + expect(created.done).toBe(false); + + const afterCreate = yield* client.listTodos(); + expect(afterCreate.some((t) => t.id === created.id)).toBe(true); + + const toggled = yield* client.toggleTodo({ + id: created.id, + done: true, + }); + expect(toggled.done).toBe(true); + + const deletedId = yield* client.deleteTodo({ id: created.id }); + expect(deletedId).toBe(created.id); + + const afterDelete = yield* client.listTodos(); + expect(afterDelete.some((t) => t.id === created.id)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(protocol(rpcUrl))); + }), + { timeout: 180_000 }, +); + +test( + "toggling a missing todo fails with TodoNotFound", + Effect.gen(function* () { + const { websiteUrl } = yield* stack; + const rpcUrl = new URL("/rpc", websiteUrl).toString(); + + yield* Effect.gen(function* () { + const client = yield* RpcClient.make(TodoRpcs); + const result = yield* client + .toggleTodo({ id: 2_147_483_000, done: true }) + .pipe(Effect.flip); + expect(result._tag).toBe("TodoNotFound"); + }).pipe(Effect.scoped, Effect.provide(protocol(rpcUrl))); + }), + { timeout: 180_000 }, +); + +// Same TodoRpcs schema the app uses; drives the deployed `/rpc` proxy end to +// end (frontend proxy -> backend RpcWorker -> Drizzle -> Neon Postgres). +const protocol = (url: string) => + RpcClient.layerProtocolHttp({ url }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ); diff --git a/examples/cloudflare-tanstack-rpc-drizzle/tsconfig.json b/examples/cloudflare-tanstack-rpc-drizzle/tsconfig.json new file mode 100644 index 000000000..910a43e8f --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "alchemy.run.ts", + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "vite.config.ts" + ], + "compilerOptions": { + "rootDir": ".", + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "jsx": "react-jsx", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "types": ["bun", "vite/client", "@cloudflare/workers-types"] + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/examples/cloudflare-tanstack-rpc-drizzle/vite.config.ts b/examples/cloudflare-tanstack-rpc-drizzle/vite.config.ts new file mode 100644 index 000000000..d37915e1c --- /dev/null +++ b/examples/cloudflare-tanstack-rpc-drizzle/vite.config.ts @@ -0,0 +1,7 @@ +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tanstackStart(), viteReact()], +}); diff --git a/package.json b/package.json index d95819207..0edfbcf5d 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@distilled.cloud/core": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", + "@effect/atom-react": ">=4.0.0-beta.78 || >=4.0.0", "@effect/language-service": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-bun": ">=4.0.0-beta.78 || >=4.0.0", "@effect/platform-node-shared": ">=4.0.0-beta.78 || >=4.0.0", diff --git a/website/src/content/docs/guides/full-stack-tanstack-rpc-drizzle.mdx b/website/src/content/docs/guides/full-stack-tanstack-rpc-drizzle.mdx new file mode 100644 index 000000000..95253c7d3 --- /dev/null +++ b/website/src/content/docs/guides/full-stack-tanstack-rpc-drizzle.mdx @@ -0,0 +1,538 @@ +--- +title: Full-stack TanStack Start + RPC + Drizzle +description: Build a reactive full-stack app on Cloudflare — a TanStack Start UI that drives an Effect RPC backend over Drizzle and Neon Postgres, with browser state wired through Effect 4's native atom RPC. +sidebar: + order: 6 +--- + +This guide ties four pieces into one deployable app: + +- **[TanStack Start](/guides/frontends)** — the React frontend, deployed as a + Cloudflare Worker + assets via `Cloudflare.Vite`. +- **[Effect RPC](/guides/effect-rpc)** — a typed backend served by a separate + `Cloudflare.RpcWorker`. +- **[Drizzle + Neon Postgres](/guides/shared-database)** — reached through a + `Cloudflare.Hyperdrive` pool, with migrations generated by `Drizzle.Schema`. +- **Atom RPC** — Effect 4's native `effect/unstable/reactivity/AtomRpc`, plus + the React bindings from `@effect/atom-react`, for reactive queries and + mutations in the browser. + +The full project lives in +[`examples/cloudflare-tanstack-rpc-drizzle`](https://github.com/alchemy-run/alchemy-effect/tree/main/examples/cloudflare-tanstack-rpc-drizzle). +We'll build a Todo app and follow a single value — a `Todo` — from the +Postgres row all the way to a checkbox in the browser. + +## The shape + +Data flows through five hops, and one `RpcGroup` value pins the types at every +boundary: + +``` +Browser (React) + │ useAtomValue / useAtomSet (src/routes/index.tsx) + ▼ +AtomRpc client (TodoRpcs) (src/rpc-client.ts) + │ HTTP POST /rpc (JSON) + ▼ +TanStack Start worker — /rpc proxy (src/routes/rpc.ts) + │ env.BACKEND.fetch(...) (private service binding) + ▼ +Backend RpcWorker (TodoRpcs) (src/backend/api.ts) + │ Drizzle.postgres over Hyperdrive + ▼ +Neon Postgres branch +``` + +The browser can't talk to a Cloudflare service binding directly, so the atom +client posts to a same-origin `/rpc` route and the frontend Worker forwards the +request to the private backend over its service binding. The backend stays off +the public internet and there's no CORS to configure. + +## 1. The shared RPC contract + +Everything starts from one module imported by **both** ends — the backend that +serves the procedures and the browser client that calls them. One `Schema` +codec round-trips every value, so the React UI is typed against the exact +shapes the Postgres-backed handlers return. + +```typescript +// src/backend/rpc.ts +import * as Schema from "effect/Schema"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; + +export class Todo extends Schema.Class("Todo")({ + id: Schema.Number, + text: Schema.String, + done: Schema.Boolean, + createdAt: Schema.Date, +}) {} + +export class TodoNotFound extends Schema.TaggedErrorClass()( + "TodoNotFound", + { id: Schema.Number }, +) {} + +export class TodoRpcs extends RpcGroup.make( + Rpc.make("listTodos", { success: Schema.Array(Todo) }), + Rpc.make("createTodo", { payload: { text: Schema.String }, success: Todo }), + Rpc.make("toggleTodo", { + payload: { id: Schema.Number, done: Schema.Boolean }, + success: Todo, + error: TodoNotFound, + }), + Rpc.make("deleteTodo", { + payload: { id: Schema.Number }, + success: Schema.Number, + error: TodoNotFound, + }), +) {} +``` + +`TodoRpcs` is a plain value-level description — nothing executes yet. See the +[Effect RPC guide](/guides/effect-rpc) for a deeper tour of `Rpc.make` and +schema-backed errors. + +## 2. The database + +The Drizzle table is ordinary Postgres schema: + +```typescript +// src/backend/schema.ts +import { defineRelations } from "drizzle-orm"; +import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const Todos = pgTable("todos", { + id: serial("id").primaryKey(), + text: text("text").notNull(), + done: boolean("done").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const relations = defineRelations({ Todos }, () => ({})); +``` + +`Drizzle.Schema` turns that table into migration SQL at deploy time, and +`Neon.Branch` applies any pending migrations transactionally before the workers +go live. A `Cloudflare.Hyperdrive` pool sits in front of the branch so the +Worker reaches Postgres through Cloudflare's connection pooler: + +```typescript +// src/backend/database.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; + +export const NeonDatabase = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + + const schema = yield* Drizzle.Schema("app-schema", { + schema: "./src/backend/schema.ts", + out: "./migrations", + }); + + const project = stage.startsWith("pr-") + ? yield* Neon.Project.ref("app-db", { stage: `staging-${stage}` }) + : yield* Neon.Project("app-db", { region: "aws-us-east-1" }); + + const branch = yield* Neon.Branch("app-branch", { + project, + migrationsDir: schema.out, + }); + + return { project, branch, schema }; +}); + +export const Hyperdrive = Effect.gen(function* () { + const { branch } = yield* NeonDatabase; + return yield* Cloudflare.Hyperdrive("app-hyperdrive", { + origin: branch.origin, + }); +}); +``` + +:::tip +The `pr-*` branch above lets ephemeral preview stages reference a shared +`staging` Postgres project instead of provisioning their own. That pattern has +its own walk-through in [Shared database across stages](/guides/shared-database). +::: + +## 3. The backend RPC Worker + +`Cloudflare.RpcWorker` takes the `RpcGroup` directly in props and serves it. +Inside Init we bind Hyperdrive once and build a Drizzle client; the handlers +then run a query per procedure. Database failures are unexpected, so we +`Effect.orDie` them into defects — that keeps each handler's typed error channel +aligned with its RPC schema (`never` for list/create, `TodoNotFound` for +toggle/delete). + +```typescript +// src/backend/api.ts +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import { eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { Hyperdrive } from "./database.ts"; +import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts"; +import { relations, Todos } from "./schema.ts"; + +export default class Backend extends Cloudflare.RpcWorker()( + "Backend", + { main: import.meta.filename, schema: TodoRpcs }, + Effect.gen(function* () { + const conn = yield* Cloudflare.Hyperdrive.bind(Hyperdrive); + const db = yield* Drizzle.postgres(conn.connectionString, { relations }); + + const handlers = TodoRpcs.toLayer({ + listTodos: () => + db + .select() + .from(Todos) + .orderBy(Todos.id) + .pipe( + Effect.map((rows) => rows.map((row) => new Todo(row))), + Effect.orDie, + ), + + createTodo: ({ text }) => + db + .insert(Todos) + .values({ text }) + .returning() + .pipe( + Effect.map(([row]) => new Todo(row)), + Effect.orDie, + ), + + toggleTodo: ({ id, done }) => + db + .update(Todos) + .set({ done }) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.flatMap(([row]) => + row ? Effect.succeed(new Todo(row)) : new TodoNotFound({ id }), + ), + Effect.orDie, + ), + + deleteTodo: ({ id }) => + db + .delete(Todos) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.flatMap(([row]) => + row ? Effect.succeed(row.id) : new TodoNotFound({ id }), + ), + Effect.orDie, + ), + }); + + return RpcServer.toHttpEffect(TodoRpcs).pipe( + Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)), + ); + }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)), +) {} +``` + +A few things worth calling out: + +- `new Todo(row)` works because the Drizzle row's shape matches the `Todo` + schema (including `createdAt: Schema.Date`). The class instance is then + encoded by the RPC server and decoded back into a `Todo` on the client. +- A missing row yields `new TodoNotFound({ id })` — a yieldable tagged error — + which the client receives as a typed value, not an HTTP status code. +- The serialization (`RpcSerialization.layerJson`) **must** match the client. + +## 4. Wire it into the Stack + +The frontend is a `Cloudflare.Vite` Worker. The backend is injected as a +private `BACKEND` service binding via `env`, exactly like binding any other +resource into an SSR Worker (see [Frontend frameworks](/guides/frontends)): + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import Backend from "./src/backend/api.ts"; +import { Hyperdrive, NeonDatabase } from "./src/backend/database.ts"; + +export class Website extends Cloudflare.Vite()("Website", { + compatibility: { flags: ["nodejs_compat", "enable_request_signal"] }, + env: { BACKEND: Backend }, + assets: { runWorkerFirst: true }, +}) {} + +export type WebsiteEnv = Cloudflare.InferEnv; + +export default Alchemy.Stack( + "CloudflareTanstackRpcDrizzleExample", + { + providers: Layer.mergeAll( + Cloudflare.providers(), + Drizzle.providers(), + Neon.providers(), + ), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const { branch } = yield* NeonDatabase; + const hd = yield* Hyperdrive; + const backend = yield* Backend; + const website = yield* Website; + + return { + websiteUrl: website.url.as(), + backendUrl: backend.url.as(), + branchId: branch.branchId, + hyperdriveId: hd.hyperdriveId, + }; + }), +); +``` + +`WebsiteEnv` is the typed environment for the frontend Worker. The `/rpc` route +reads `env.BACKEND` from it. + +## 5. The `/rpc` proxy route + +The browser can't open a service binding, so a TanStack Start server route +forwards RPC traffic to the backend. Declaring an `ANY` handler lets it pass +through whatever method/body the RPC protocol sends: + +```typescript +// src/routes/rpc.ts +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "../env.ts"; + +export const Route = createFileRoute("/rpc")({ + server: { + handlers: { + ANY: async ({ request }) => { + const contentType = request.headers.get("content-type"); + return await env.BACKEND.fetch("https://backend/rpc", { + method: request.method, + headers: contentType ? { "content-type": contentType } : undefined, + body: request.body ? await request.text() : undefined, + signal: request.signal, + redirect: "manual", + }); + }, + }, + }, +}); +``` + +`env` is a small proxy over `cloudflare:workers` so the binding resolves both in +`alchemy dev` and in production: + +```typescript +// src/env.ts +import * as cf from "cloudflare:workers"; +import type { WebsiteEnv } from "../alchemy.run.ts"; + +export const env = new Proxy({} as WebsiteEnv, { + get(_, prop) { + return cf.env[prop as keyof typeof cf.env]; + }, +}); +``` + +## 6. The atom RPC client + +Effect 4 ships atom RPC in core — there's no third-party `@effect-atom` package +to add (that one targets Effect 3). `AtomRpc.Service` turns the shared +`RpcGroup` into a client whose `.query()` and `.mutation()` methods return +**atoms**. The transport is a plain HTTP client over `fetch`, pointed at the +same-origin `/rpc` route: + +```typescript +// src/rpc-client.ts +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as AtomRpc from "effect/unstable/reactivity/AtomRpc"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { TodoRpcs } from "./backend/rpc.ts"; + +export class TodoClient extends AtomRpc.Service()("TodoClient", { + group: TodoRpcs, + protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), +}) {} + +// Created once at module scope so their identity is stable across renders. +export const listTodosAtom = TodoClient.query("listTodos", undefined, { + reactivityKeys: ["todos"], +}); +export const createTodoAtom = TodoClient.mutation("createTodo"); +export const toggleTodoAtom = TodoClient.mutation("toggleTodo"); +export const deleteTodoAtom = TodoClient.mutation("deleteTodo"); +``` + +The `reactivityKeys: ["todos"]` on the query is the key to reactivity: when a +mutation runs with a matching key (step 8), the list query is invalidated and +refetched automatically — no manual cache busting. + +:::note +`RpcSerialization.layerJson` here must match the backend's serialization from +step 3. Mismatch the codecs and the wire won't decode. +::: + +## 7. Provide a registry + +Atoms resolve against an `AtomRegistry`. `@effect/atom-react` — versioned in +lockstep with `effect`, so no version juggling — provides `RegistryProvider`. +Wrap it once at the root: + +```tsx +// src/routes/__root.tsx +import { RegistryProvider } from "@effect/atom-react"; +import { Outlet, createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ component: RootComponent }); + +function RootComponent() { + return ( + // One AtomRegistry for the whole app — every query/mutation atom + // resolves against it. + + + + ); +} +``` + +## 8. The UI + +`useAtomValue` subscribes to the list query and re-renders as its +`AsyncResult` moves through waiting → success. `useAtomSet` turns a mutation +atom into a setter; calling it with `reactivityKeys: ["todos"]` invalidates the +list: + +```tsx +// src/routes/index.tsx +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useState } from "react"; +import { + createTodoAtom, + deleteTodoAtom, + listTodosAtom, + toggleTodoAtom, +} from "../rpc-client.ts"; + +function TodoForm() { + const createTodo = useAtomSet(createTodoAtom); + const [text, setText] = useState(""); + + return ( +
{ + e.preventDefault(); + const value = text.trim(); + if (!value) return; + createTodo({ payload: { text: value }, reactivityKeys: ["todos"] }); + setText(""); + }} + > + setText(e.target.value)} /> + +
+ ); +} + +function TodoList() { + const atom = useAtomValue(listTodosAtom); + const toggleTodo = useAtomSet(toggleTodoAtom); + const deleteTodo = useAtomSet(deleteTodoAtom); + + const todos = AsyncResult.getOrElse(atom, () => []); + + if (AsyncResult.isWaiting(atom) && !todos.length) { + return

Loading todos…

; + } + + return ( +
    + {todos.map((todo) => ( +
  • + + toggleTodo({ + payload: { id: todo.id, done: !todo.done }, + reactivityKeys: ["todos"], + }) + } + /> + {todo.text} + +
  • + ))} +
+ ); +} +``` + +`todos` are decoded `Todo` instances — the same class the backend constructed +from a Postgres row, round-tripped through the shared `Schema` codec. No +hand-written DTOs, no `fetch` URLs, no response parsing. + +## 9. Deploy + +Deploying provisions the Neon branch, runs the generated migrations, creates the +Hyperdrive pool, and uploads both Workers. It needs Cloudflare and Neon +credentials: + +```sh +# CLOUDFLARE_API_TOKEN (or `wrangler login`) and NEON_API_KEY in the environment +bun install +bun alchemy deploy +``` + +Open the `websiteUrl` from the deploy output and add a few todos — each +checkbox toggle and delete round-trips through the full stack and the list +refreshes itself. + +## Recap + +- One `RpcGroup` (`TodoRpcs`) is the single source of truth — served by the + backend, consumed by the browser, typed end to end. +- The **backend** is a `Cloudflare.RpcWorker` running Drizzle over a Neon branch + via Hyperdrive; DB errors `orDie` into defects so typed channels match the RPC + schemas. +- The **frontend** is a `Cloudflare.Vite` Worker that binds the backend + privately and exposes a same-origin `/rpc` proxy. +- The **browser** uses Effect 4's native `AtomRpc` (`effect/unstable/reactivity`) + plus `@effect/atom-react` hooks; `reactivityKeys` wire mutations to refetch + the affected query. + +## Where to go next + +- [Effect RPC](/guides/effect-rpc) — the RPC server/client model in depth, + including streaming and Durable Object backends. +- [Frontend frameworks](/guides/frontends) — `Cloudflare.Vite` and + `Cloudflare.StaticSite` for every framework. +- [Shared database across stages](/guides/shared-database) — the `pr-*` branch + referencing pattern used in `database.ts`.