| 1 | /** |
| 2 | * Node-side query path into the local workerd D1 simulator, built on the |
| 3 | * runtime's platform proxy (`PlatformProxy.open` — our `getPlatformProxy`). |
| 4 | * |
| 5 | * The simulator (a `d1` service routing to a per-database |
| 6 | * `D1DatabaseObject` Durable Object over DO SQLite) is only reachable from |
| 7 | * inside workerd — but alchemy's migration runner and the |
| 8 | * `QueryDatabaseLocal` capability run in Node. The platform proxy bridges |
| 9 | * the two: it hosts the binding in a scoped workerd instance and exposes it |
| 10 | * to Node. We bind the raw `d1` service (the same designator the |
| 11 | * `cloudflare-internal:d1-api` wrapped binding targets) so Node speaks the |
| 12 | * full D1 HTTP protocol (`POST /query`, multi-statement SQL) rather than |
| 13 | * the line-based `exec()` surface of the wrapped binding: |
| 14 | * |
| 15 | * Node ── proxy.env.D1_RAW.fetch({sql}) ──▶ `d1` service ──▶ D1DatabaseObject |
| 16 | * |
| 17 | * Data lands in the same `{storage}/d1` directory every local worker |
| 18 | * binding reads. |
| 19 | * |
| 20 | * NOT exported from `index.ts` — provider-internal scaffolding. |
| 21 | */ |
| 22 | import { open } from "@alchemy.run/cloudflare-runtime/core/platform-proxy"; |
| 23 | import { D1 } from "@alchemy.run/cloudflare-runtime/core/bindings"; |
| 24 | import { SERVICE_D1 } from "@alchemy.run/cloudflare-runtime/core/bindings/d1/D1Options"; |
| 25 | import type { BindingHook } from "@alchemy.run/cloudflare-runtime/core/PluginContext"; |
| 26 | import * as Data from "effect/Data"; |
| 27 | import * as Effect from "effect/Effect"; |
| 28 | import { gatewayName, localGatewayRuntime } from "../LocalGateway.ts"; |
| 29 | import type { D1QueryResult, D1SqlExecutor } from "./ApplyMigrations.ts"; |
| 30 | |
| 31 | export { localGatewayRuntime as localD1GatewayRuntime }; |
| 32 | |
| 33 | export class LocalD1QueryError extends Data.TaggedError("LocalD1QueryError")<{ |
| 34 | message: string; |
| 35 | cause?: unknown; |
| 36 | }> {} |
| 37 | |
| 38 | /** |
| 39 | * A single query or batch in the shape the cloud `d1.queryDatabase` op |
| 40 | * accepts — the local gateway speaks the same surface so client code can |
| 41 | * swap transports without translation. |
| 42 | */ |
| 43 | export type D1QueryBody = |
| 44 | | { sql: string; params?: unknown[] } |
| 45 | | { batch: Array<{ sql: string; params?: unknown[] }> }; |
| 46 | |
| 47 | interface D1StatementResponse { |
| 48 | success: boolean; |
| 49 | results?: unknown; |
| 50 | meta?: unknown; |
| 51 | error?: string; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * A plain service binding onto the raw `d1` service for `databaseId` — |
| 56 | * bypassing the `cloudflare-internal:d1-api` wrapper so the proxy's fetch |
| 57 | * passthrough can POST the full D1 HTTP protocol. |
| 58 | */ |
| 59 | const rawD1Binding = (databaseId: string): BindingHook => |
| 60 | Effect.succeed({ |
| 61 | name: "D1_RAW", |
| 62 | service: { |
| 63 | name: SERVICE_D1, |
| 64 | props: { json: JSON.stringify({ databaseId }) }, |
| 65 | }, |
| 66 | }); |
| 67 | |
| 68 | /** |
| 69 | * Boot a scoped platform proxy for `databaseId`, hand `use` a query |
| 70 | * function that tunnels {@link D1QueryBody} requests into the local |
| 71 | * simulator, and tear the instance down when `use` completes. |
| 72 | */ |
| 73 | export const withLocalD1Query = <A, E, R>( |
| 74 | databaseId: string, |
| 75 | use: ( |
| 76 | query: ( |
| 77 | body: D1QueryBody, |
| 78 | ) => Effect.Effect<D1QueryResult, LocalD1QueryError>, |
| 79 | ) => Effect.Effect<A, E, R>, |
| 80 | ) => |
| 81 | Effect.scoped( |
| 82 | Effect.gen(function* () { |
| 83 | const proxy = yield* open({ |
| 84 | name: gatewayName("alchemy-d1-gateway", databaseId), |
| 85 | bindings: [ |
| 86 | // The wrapped binding is unused by the gateway itself, but its |
| 87 | // hook registers the database with the D1 plugin so the `d1` |
| 88 | // service is emitted into this workerd config. |
| 89 | D1.local({ binding: "DB", id: databaseId }), |
| 90 | rawD1Binding(databaseId), |
| 91 | ], |
| 92 | }); |
| 93 | const raw = (proxy.env as Record<string, unknown>).D1_RAW as { |
| 94 | fetch: (input: string, init?: RequestInit) => Promise<Response>; |
| 95 | }; |
| 96 | |
| 97 | const query = (body: D1QueryBody) => |
| 98 | Effect.tryPromise({ |
| 99 | try: async () => { |
| 100 | const response = await raw.fetch("http://d1/query", { |
| 101 | method: "POST", |
| 102 | headers: { "content-type": "application/json" }, |
| 103 | // The DO accepts a single D1Query or an array (one |
| 104 | // transaction) — a batch maps to the array form. |
| 105 | body: JSON.stringify("batch" in body ? body.batch : body), |
| 106 | }); |
| 107 | return (await response.json()) as unknown; |
| 108 | }, |
| 109 | catch: (cause) => |
| 110 | new LocalD1QueryError({ |
| 111 | message: "Failed to reach the local D1 gateway", |
| 112 | cause, |
| 113 | }), |
| 114 | }).pipe( |
| 115 | Effect.flatMap((responseBody) => { |
| 116 | // Protocol errors come back `{ success: false, error }` (a |
| 117 | // single object); successes are one envelope per statement. |
| 118 | const responses = ( |
| 119 | Array.isArray(responseBody) ? responseBody : [responseBody] |
| 120 | ) as D1StatementResponse[]; |
| 121 | const failed = responses.find((r) => !r.success); |
| 122 | if (failed) { |
| 123 | return Effect.fail( |
| 124 | new LocalD1QueryError({ |
| 125 | message: failed.error ?? "Local D1 query failed", |
| 126 | }), |
| 127 | ); |
| 128 | } |
| 129 | return Effect.succeed({ |
| 130 | result: responses.map((r) => ({ |
| 131 | results: r.results, |
| 132 | success: true, |
| 133 | meta: r.meta, |
| 134 | })), |
| 135 | } as D1QueryResult); |
| 136 | }), |
| 137 | ); |
| 138 | |
| 139 | return yield* use(query); |
| 140 | }), |
| 141 | ); |
| 142 | |
| 143 | /** |
| 144 | * SQL-only view of {@link withLocalD1Query} matching the migration flow's |
| 145 | * {@link D1SqlExecutor} contract. |
| 146 | */ |
| 147 | export const withLocalD1Executor = <A, E, R>( |
| 148 | databaseId: string, |
| 149 | use: (executor: D1SqlExecutor<LocalD1QueryError>) => Effect.Effect<A, E, R>, |
| 150 | ) => withLocalD1Query(databaseId, (query) => use((sql) => query({ sql }))); |