1import type * as runtime from "@cloudflare/workers-types";
2import * as d1 from "@distilled.cloud/cloudflare/d1";
3import * as Effect from "effect/Effect";
4import type * as HttpClient from "effect/http/HttpClient";
5import type { Credentials } from "../Credentials.ts";
6import {
7 type QueryDatabaseClient,
8 PreparedStatement,
9} from "./QueryDatabase.ts";
10
11// Shared HTTP scaffolding for the D1 `QueryDatabase` binding. NOT re-exported
12// from `index.ts` — only the contract and the impl layers are public. A future
13// token-scoped `QueryDatabaseHttp` layer reuses this builder with an auth
14// minted from an `AccountApiToken`; `QueryDatabaseLocal` builds the auth from
15// the ambient current-credentials context.
16//
17// PreparedStatement (shared with QueryDatabaseBinding) drives a
18// `runtime.D1Database` whose executors return Promises. The shim below
19// implements the slice PreparedStatement uses (prepare/exec/batch + stmt
20// bind/all/first/run/raw) by running `d1.queryDatabase` over HTTP.
21
22/**
23 * Injectable auth shared by the Local (current-credentials) impl and a future
24 * Http (scoped-token) impl. `authorize` discharges the
25 * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the
26 * Cloudflare account the ops run against.
27 */
28export interface D1Auth {
29 authorize: <A, E>(
30 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
31 ) => Effect.Effect<A, E>;
32 accountId: string;
33}
34
35/**
36 * Build a {@link QueryDatabaseClient} over the D1 HTTP query API.
37 *
38 * `databaseId` is an Effect so the resolution stays deferred to each call —
39 * inside an Action it resolves through the apply-time RuntimeContext. The
40 * credentials are provided ONLY around the distilled op (via `auth.authorize`),
41 * never around the id accessor, matching the KV / Vectorize Local variants.
42 */
43export const makeHttpQueryDatabaseClient = (
44 auth: D1Auth,
45 databaseId: Effect.Effect<string>,
46): QueryDatabaseClient =>
47 makeQueryDatabaseClientFrom(
48 Effect.map(databaseId, (id) => makeHttpD1Database(auth, id)),
49 );
50
51/**
52 * Build a {@link QueryDatabaseClient} from a deferred `D1Database` — shared
53 * by the cloud HTTP transport above and the local-simulator gateway
54 * transport (`QueryDatabaseLocal` with a `dev:` id).
55 */
56export const makeQueryDatabaseClientFrom = (
57 rawEff: Effect.Effect<runtime.D1Database>,
58): QueryDatabaseClient => {
59 return {
60 raw: rawEff,
61 prepare: (query: string) => new PreparedStatement(query, [], rawEff),
62 exec: (query: string) =>
63 Effect.flatMap(rawEff, (raw) => Effect.promise(() => raw.exec(query))),
64 batch: <T = unknown>(statements: PreparedStatement[]) =>
65 Effect.flatMap(rawEff, (raw) =>
66 Effect.promise(() =>
67 raw.batch<T>(statements.map((s) => s._build(raw))),
68 ),
69 ),
70 } satisfies QueryDatabaseClient;
71};
72
73const runQuery = (
74 auth: D1Auth,
75 databaseId: string,
76 body:
77 | { sql: string; params?: unknown[] }
78 | { batch: { sql: string; params?: unknown[] }[] },
79): Promise<d1.QueryDatabaseResponse> =>
80 auth
81 .authorize(
82 d1.queryDatabase({
83 accountId: auth.accountId,
84 databaseId,
85 ...(body as any),
86 }),
87 )
88 .pipe(Effect.runPromise);
89
90const toResult = <T>(
91 r:
92 | { results?: unknown; success?: boolean | null; meta?: unknown }
93 | undefined,
94): runtime.D1Result<T> =>
95 ({
96 results: (r?.results ?? []) as T[],
97 success: r?.success ?? true,
98 meta: (r?.meta ?? {}) as any,
99 }) as runtime.D1Result<T>;
100
101/**
102 * Normalize a bound value the way the native D1 binding does before it reaches
103 * SQLite. Over the raw HTTP query API, unlike the Worker binding, values are
104 * bound verbatim — a JS `true` would arrive as the string `"true"`. Match the
105 * native semantics: booleans become integers (1/0) and binary becomes a byte
106 * array (BLOB). `null`, numbers, and strings pass through unchanged.
107 */
108const normalizeBind = (value: unknown): unknown => {
109 if (typeof value === "boolean") return value ? 1 : 0;
110 if (value instanceof ArrayBuffer) {
111 return Array.from(new Uint8Array(value));
112 }
113 if (ArrayBuffer.isView(value)) {
114 return Array.from(
115 new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
116 );
117 }
118 return value;
119};
120
121/** A `D1Database` facade over the cloud HTTP query API. */
122export const makeHttpD1Database = (
123 auth: D1Auth,
124 databaseId: string,
125): runtime.D1Database =>
126 makeD1DatabaseFromTransport((body) => runQuery(auth, databaseId, body));
127
128/**
129 * The transport a {@link runtime.D1Database} facade is built over: run one
130 * query (or batch) and return the D1 result envelope(s). Two transports
131 * exist — the cloud HTTP API (`runQuery`) and the local-simulator gateway
132 * (`withLocalD1Query`).
133 */
134export type D1QueryTransport = (
135 body:
136 | { sql: string; params?: unknown[] }
137 | { batch: { sql: string; params?: unknown[] }[] },
138) => Promise<{
139 result: Array<{
140 results?: unknown;
141 success?: boolean | null;
142 meta?: unknown;
143 }>;
144}>;
145
146export const makeD1DatabaseFromTransport = (
147 transport: D1QueryTransport,
148): runtime.D1Database => {
149 const makeStatement = (
150 query: string,
151 binds: ReadonlyArray<unknown>,
152 ): runtime.D1PreparedStatement => {
153 const exec = async () => {
154 const res = await transport({
155 sql: query,
156 params: binds.length ? binds.map(normalizeBind) : undefined,
157 });
158 return res.result[0];
159 };
160 return {
161 bind: (...values: unknown[]) => makeStatement(query, values),
162 first: (async (column?: string) => {
163 const first = (
164 (await exec())?.results as Record<string, unknown>[] | undefined
165 )?.[0];
166 if (first == null) return null;
167 return column !== undefined ? (first[column] ?? null) : first;
168 }) as runtime.D1PreparedStatement["first"],
169 all: (async () =>
170 toResult(await exec())) as runtime.D1PreparedStatement["all"],
171 run: (async () =>
172 toResult(await exec())) as runtime.D1PreparedStatement["run"],
173 raw: (async (options?: { columnNames?: boolean }) => {
174 const rows = ((await exec())?.results ?? []) as Record<
175 string,
176 unknown
177 >[];
178 const arrays = rows.map((row) => Object.values(row));
179 if (options?.columnNames && rows[0]) {
180 return [Object.keys(rows[0]), ...arrays];
181 }
182 return arrays;
183 }) as runtime.D1PreparedStatement["raw"],
184 // Carry query + params so `batch` can reconstruct the request.
185 __query: query,
186 __params: binds,
187 } as unknown as runtime.D1PreparedStatement;
188 };
189
190 return {
191 prepare: (query: string) => makeStatement(query, []),
192 exec: async (query: string) => {
193 const res = await transport({ sql: query });
194 const meta = res.result[res.result.length - 1]?.meta as
195 | { duration?: number }
196 | undefined;
197 return {
198 count: res.result.length,
199 duration: meta?.duration ?? 0,
200 } as runtime.D1ExecResult;
201 },
202 batch: async <T = unknown>(statements: runtime.D1PreparedStatement[]) => {
203 const res = await transport({
204 batch: statements.map((s) => ({
205 sql: (s as any).__query as string,
206 params: ((s as any).__params as unknown[]).length
207 ? ((s as any).__params as unknown[]).map(normalizeBind)
208 : undefined,
209 })),
210 });
211 return res.result.map((r) => toResult<T>(r));
212 },
213 } as unknown as runtime.D1Database;
214};