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 */
22import { open } from "@alchemy.run/cloudflare-runtime/core/platform-proxy";
23import { D1 } from "@alchemy.run/cloudflare-runtime/core/bindings";
24import { SERVICE_D1 } from "@alchemy.run/cloudflare-runtime/core/bindings/d1/D1Options";
25import type { BindingHook } from "@alchemy.run/cloudflare-runtime/core/PluginContext";
26import * as Data from "effect/Data";
27import * as Effect from "effect/Effect";
28import { gatewayName, localGatewayRuntime } from "../LocalGateway.ts";
29import type { D1QueryResult, D1SqlExecutor } from "./ApplyMigrations.ts";
30
31export { localGatewayRuntime as localD1GatewayRuntime };
32
33export 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 */
43export type D1QueryBody =
44 | { sql: string; params?: unknown[] }
45 | { batch: Array<{ sql: string; params?: unknown[] }> };
46
47interface 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 */
59const 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 */
73export 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 */
147export 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 })));